0%

Go 语言中的泛型是指一种语言特性,允许创建可以处理不同类型的函数、数据结构和接口。换句话说,泛型使得可以创建不受特定类型或数据结构限制的代码。如果我们此前有使用 Java 或者 C++ 的经验,那么会很好理解。

在 Go 语言引入泛型之前,开发人员必须编写多个函数来处理不同类型的数据。这种方法通常很繁琐,并导致代码重复。有了泛型,开发人员可以编写更简洁和可重用的代码,可以处理不同类型的数据。

Go 语言中的泛型是在 2021 年 2 月发布的 1.18 版本中引入的。Go 语言中的泛型实现是基于类型参数的概念。类型参数是传递给函数或数据结构的类型的占位符,使它们能够处理不同类型的数据。

Go 中的泛型是什么?

泛型是一种代码,允许我们通过改变函数类型来在各种函数中使用它们。泛型的创建是为了使代码独立于类型和函数。

泛型的主要目的是通过添加更少的代码行来实现更大的灵活性。

为了更好地理解,看下面的例子。我们创建一个打印任何类型参数的函数,就像这样:

1
2
3
4
5
func Print(s[] string) {
for _, v := range s {
fmt.Print(v)
}
}

现在,我们突然希望打印一个整数,所以我们相应地改变了代码。

1
2
3
4
5
func Print(s[] int) {
for _, v := range s {
fmt.Print(v)
}
}

但是每次像这样更改代码可能看起来令人生畏,这就是泛型发挥作用的地方。通过将任何类型分配给其泛型形式,我们可以将相同的代码用于不同的函数。看一下这个:

1
2
3
4
5
func Print[T any](s[] T) {
for _, v := range s {
fmt.Print(v)
}
}

在这里,我们将 "T" 定义为 any 类型。这个任意类型允许我们在同一个函数中解析不同类型的变量。S 是相应的变量,它是 T 类型的一个切片。现在,调用该方法,我们可以在同一个函数中打印一个字符串和一个整数。

1
2
3
4
5
6
func main() {
str := []string{"Hello", "Again Hello"}
intArray := []int{1, 2, 3}
Print(str)
Print(intArray)
}

Go 中的泛型是如何工作的?

Go 中的泛型是使用类型参数实现的,它允许创建可以在不同类型上操作的泛型函数和数据结构,而无需显式类型转换。

考虑以下示例,其中类型参数 “T” 是使用 “any” 关键字定义的,该关键字指定该函数可以与任何类型一起使用。

1
2
3
func Swap[T any](a, b * T) {
*a, *b = *b, *a
}

函数体然后执行传入的两个指针指向的值的简单交换。

当函数被调用时,编译器为与函数一起使用的类型生成特定版本的函数。例如,如果函数被用于两个整数指针,编译器会生成一个操作整数的函数版本。

类型参数是什么?

在 Go 中,类型参数是使用方括号括起的类型参数列表来指定的,紧跟在函数、数据结构或接口名称之后。类型参数由单个大写字母或一系列大写字母表示,并用尖括号括起来。

类型参数用于在 Go 中创建通用函数、数据结构和接口。类型参数是在编译时确定的类型的占位符。

1
2
3
4
5
6
7
// 这里的 T 是类型参数,any 是类型约束;
// 这里表示 T 可以是任何类型。
func Print[T any](s []T) {
for _, v := range s {
fmt.Print(v)
}
}

使用:

1
2
3
4
5
6
func main() {
str := []string{"Hello", "Again Hello"}
intArray := []int{1, 2, 3}
Print(str)
Print(intArray)
}

例如,考虑上面的示例,显式了使用类型参数的函数声明。在这个函数中,类型参数由大写字母 "T" 表示。"any" 关键字表示函数可以使用任何类型。当调用此函数时,类型参数将被替换为传递给函数的实际类型。

类型参数使得在 Go 语言中可以创建更通用和可重用的代码,因为它允许函数和数据结构可以处理不同类型的数据。

在泛型中使用类型参数

在上面的例子中,我们看到了如何在同一个函数下结合多种类型的变量。

在这个例子中,使用 "any" 关键字声明了一个带有类型参数 "T" 的函数。"any" 关键字表示该函数可以处理任何类型。该函数以类型 "T" 的切片作为参数,并打印其内容。

T 是类型参数,any 是类型约束;这里表示 T 可以是任何类型。

要使用此功能,您可以使用下面给出的任何类型的切片来调用它:

1
2
3
4
5
6
7
8
intSlice := []int{
1, 2, 3, 4, 5,
}
stringSlice := []string{
"apple", "banana", "cherry",
}
Print(intSlice) // prints 1 2 3 4 5
Print(stringSlice) // prints apple banana cherry

在这个例子中,Print 函数被调用时使用了整数切片和字符串切片。类型参数 "T" 被实际传递给函数的参数类型所替换。

您还可以使用类型参数在 Go 中创建通用数据结构和接口。以下是一个使用类型参数的通用数据结构示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type Stack[T any] struct {
items []T
}

func (s *Stack[T]) Push(item T) {
s.items = append(s.items, item)
}

func (s *Stack[T]) Pop() T {
if len(s.items) == 0 {
panic("stack is empty")
}
item := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return item
}
  • 在这里,使用 “any” 关键字声明了带有类型参数 “T” 的栈数据结构。
  • Push 方法接受类型为 "T" 的项目作为参数,并将其添加到栈中。
  • Pop 方法从栈顶返回一个类型为 "T" 的项目。

要使用这种数据结构,您可以创建任何类型的栈:

1
2
3
4
5
6
7
8
9
10
intStack := &Stack[int]{}
stringStack := &Stack[string]{}
intStack.Push(1)
intStack.Push(2)
intStack.Push(3)
stringStack.Push("apple")
stringStack.Push("banana")
stringStack.Push("cherry")
fmt.Println(intStack.Pop()) // prints 3
fmt.Println(stringStack.Pop()) // prints cherry

在这个例子中,创建了两个栈,一个是 int 类型,另一个是 string 类型。类型参数 “T” 被替换为创建栈的实际类型。

类型约束

泛型中的类型约束定义了可以与泛型函数或数据结构一起使用的类型集合。类型约束允许编译器强制执行类型安全,并确保只有兼容的类型与泛型结构一起使用。

类型约束使用 "interface" 关键字指定,后跟接口的名称和类型必须实现的方法。例如,考虑以下使用类型约束的通用函数:

1
2
3
4
5
6
func Equal[T comparable](a, b T) T {
if a == b {
return a
}
return b
}

在这个例子中,类型参数 "T" 受到 "comparable" 接口的约束,该接口要求类型可以进行 ==!= 比较。这确保了函数只能被支持比较的类型调用。

comparable 是一个内置接口,用于将泛型类型参数限制为仅支持比较运算符(!= ,和 ==)的类型。

comparable 接口是由 Go 语言规范隐式定义的,并不需要在代码中显式定义。这意味着任何支持比较运算符的类型都可以作为 Equal 函数的类型参数,而无需额外声明 comparable 接口。

类型约束也可以是用户定义的接口,它允许对可以与通用函数或数据结构一起使用的类型进行更具体的约束。例如,考虑以下用户定义的接口:

1
2
3
4
5
6
type Number interface {
Add(other Number) Number
Sub(other Number) Number
Mul(other Number) Number
Div(other Number) Number
}

该接口定义了一组方法,一个类型必须实现这些方法才能被视为 “Number”。使用该接口作为类型约束的泛型函数或数据结构只能与实现了这些方法的类型一起使用,确保类型安全和兼容性。

Go 中的泛型类型约束提供了一种确保类型安全并限制可以与泛型结构一起使用的类型集的方法,同时仍然允许泛型提供的灵活性和可重用性。

在 Golang 中使用泛型的示例

这里有一些在Go中使用泛型的例子:

通用函数

该函数接受任何类型 T 的切片和类型 T 的值,并返回该值在切片中的索引。类型参数中的 any 关键字指定可以使用任何类型。

1
2
3
4
5
6
7
8
func findIndex[T any](slice []T, value T) int {
for i, v := range slice {
if reflect.DeepEqual(v, value) {
return i
}
}
return -1
}

通用类型

这定义了一个通用的栈类型,可以保存任何类型 T 的元素。关键字 any 指定任何类型都可以用作元素类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Stack[T any] []T

func (s *Stack[T]) Push(value T) {
*s = append(*s, value)
}

func (s *Stack[T]) Pop() T {
if len(*s) == 0 {
panic("Stack is empty")
}
value := (*s)[len(*s)-1]
*s = (*s)[:len(*s)-1]
return value
}

类型参数的约束

这定义了对类型参数 T 的类型约束,要求其实现 Equatable 接口。这允许 findIndex 函数使用 Equals 方法来比较类型T的值。

1
2
3
4
5
6
7
8
9
10
11
12
type Equatable interface {
Equals(other interface{}) bool
}

func findIndex[T Equatable](slice []T, value T) int {
for i, v := range slice {
if v.Equals(value) {
return i
}
}
return -1
}

支持多种数据类型的加法

让我们编写一个函数 SumGenerics ,它对各种数值类型进行加法操作,比如 intint16int32int64int8float32float64

1
2
3
4
5
6
7
8
9
10
func SumGenerics[T int | int16 | int32 | int64 | int8 | float32 | float64](a, b T) T {
return a + b
}

func main() {
sumInt := SumGenerics[int](2, 3) // returns 5
sumFloat := SumGenerics[float32](2.5, 3.5) // returns 6.0
sumInt64 := SumGenerics[int64](10, 20) // returns 30
println(sumInt, sumFloat, sumInt64)
}

在上面的代码中,我们可以看到,在调用泛型函数时通过在方括号 [] 中指定类型参数,我们可以对不同的数值类型执行加法操作。类型约束确保只有指定的类型 [T int, int16, int32, int64, int8, float32, or float64] 可以用作类型参数。

map 中的泛型

map 的泛型需要两种类型,一个 key 类型和一个 value 类型。值类型没有任何限制,但键类型应该始终满足 comparable 约束。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// keys 返回一个 map 的所有 key
// m 参数是使用了 K 和 V 泛型的 map
// K 是使用了 comparable 约束的泛型,也就是说 K 必须支持 != 和 == 操作
// V 是使用了 any 约束的泛型,也就是说 V 可以是任意类型
func keys[K comparable, V any](m map[K]V) []K {
// 创建一个长度为 map 长度的 K 类型的 slice
key := make([]K, len(m))
i := 0
for k, _ := range m {
key[i] = k
i++
}
return key
}

结构体中的泛型

Go 允许使用类型参数定义 struct 。语法类似于泛型函数。类型参数可用于结构体上的方法和数据成员。

1
2
3
4
5
6
7
8
9
10
11
12
// T 是类型参数,使用了 any 约束
type MyStruct[T any] struct {
inner T
}

// 在 struct 方法中不允许使用新的类型参数
func (m *MyStruct[T]) Get() T {
return m.inner
}
func (m *MyStruct[T]) Set(v T) {
m.inner = v
}

在结构体方法中不允许定义新的类型参数,但在结构体定义中定义的类型参数可以在方法中使用。

多个泛型参数

泛型可以嵌套在其他类型中。在函数或结构中定义的类型参数可以传递给具有类型参数的任何其他类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 拥有两个泛型类型的泛型 struct
type Entries[K comparable, V any] struct {
Key K
Value V
}

// entries 函数返回一个 Entries 的 slice,代表了传入的 map 的所有 key 和 value
// K 和 V 是泛型类型参数,K 有 comparable 约束,V 没有约束
func entries[K comparable, V any](m map[K]V) []*Entries[K, V] {
// 创建一个 Entries 类型的 slice,传入 K 和 V 类型参数
e := make([]*Entries[K, V], len(m))
i := 0
for k, v := range m {
// 定义一个 Entries 类型的变量
newEntry := new(Entries[K, V])
newEntry.Key = k
newEntry.Value = v
e[i] = newEntry
i++
}
return e
}

我们可以通过逗号分隔多个类型参数来实现多个泛型参数。

类型并集

我们知道,在以往的 interface 定义中,往往都是只包含了方法定义的,如下面这样:

1
2
3
type Stringer interface {
String() string
}

而现在,我们还可以在 interface 中定义多个类型,如下面这样:

1
2
3
type Number interface {
int | int8
}

这种带有类型的 interface 可以帮助我们写出更加简洁的泛型代码,因为它可以用一个 intreface 来表示多个不同的相似类型。 但是这种带有类型的接口,不能用于定义变量,只能用于泛型的类型约束中。

在上面的泛型加法实现中,我们使用了 [T int | int16 | int32 | int64 | int8 | float32 | float64] 这种方式来给 T 定义了一个约束, 但是这种方式并不是很优雅,我们可以将约束定义为一个 interface,然后将 interface 作为约束。

我们称通过 | 连接的多个类型的 interface 为类型并集。

1
2
3
type Number interface {
int | int8 | int16 | int32 | int64 | float32 | float64
}

使用 Number 来作为泛型的约束:

1
2
3
4
5
6
7
8
// T 可以是任意 int 或 float 类型
// T 只能是支持算术运算的类型
func Min[T Number](x, y T) T {
if x < y {
return x
}
return y
}

使用多种类型的联合允许执行这些类型支持的常见操作,并编写适用于联合中所有类型的代码。

这些只是一些示例,说明了在 Go 中如何使用泛型来编写更灵活、可重用的代码。

类型交集

类似的,还有一种类型交集的概念,它是通过在 interface 中写多行类型来实现的:每一行定义了一种或多种类型的并集。

1
2
3
4
5
6
7
type AllInt interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
}

type Uint interface {
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
}

在上面的代码中,AllInt 是一个类型并集,它包含了所有整数类型。Uint 是一个类型并集,它包含了所有无符号整数类型。

下面是一个使用类型交集的例子:

1
2
3
4
5
6
// 取 AllInt 和 Uint 的交集
// 也就是:~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
type Int interface {
AllInt
Uint
}

其实它的最终的结果等同于:

1
2
3
type Int interface {
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
}

除此之外,如果其中不同行之间没有任何交集,那么它们的交集就是空集。在现实中可能意义不大。

泛型接口和泛型结构体

在 Go 中,structinterface 都可以使用泛型。

例如,在下面的代码片段中,类型参数 T 的任何值只支持 String 方法 - 您可以使用 len() 或对其进行任何其他操作。

1
2
3
4
5
6
7
8
9
// Stringer 是一个约束
type Stringer interface {
String() string
}

// T 需要实现 Stringer 接口,T 只能执行 Stringer 接口中定义的操作
func stringer[T Stringer](s T) string {
return s.String()
}

再比如,下面的例子中,是一个使用了泛型的 struct

1
2
3
4
5
6
7
type Person[T int] struct {
age T
}

func (p Person[T]) Age() T {
return p.age
}

使用这个 struct

1
2
3
var p Person[int]
p.age = 10
fmt.Println(p.Age()) // 10

使用 ~ 指定底层类型

在 Go 中,定义了一个 cmp.Ordered 接口:

1
2
3
4
5
6
type Ordered interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
~float32 | ~float64 |
~string
}

这个声明表示 Ordered 是所有整数、浮点数、和字符串类型的集合。

对于类型约束,我们通常不关心特定类型,比如 string,我们对所有字符串类型感兴趣,所以我们使用 ~string 来表示所有字符串类型的集合。 ~string 表达式表示所有底层类型为 string 的类型的集合,这包括类型 string 本身以及所有使用如 type MyString string 声明定义的类型。

下面是一个错误的例子:

1
2
3
4
5
6
7
8
type Slice[T int] struct {
}

var s1 Slice[int] // 正确

type MyInt int
// 错误。MyInt 类型底层类型是 int 但并不是 int 类型,不符合 Slice[T] 的类型约束
var s2 Slice[MyInt]

正确的做法是,将 Slice 的类型约束修改为 ~int

1
2
3
4
5
6
7
8
9
// T 的底层类型是 int 即可,不一定是 int 类型
type Slice[T ~int] struct {
}

var s1 Slice[int] // 正确

type MyInt int
// 错误。MyInt 类型底层类型是 int 但并不是 int 类型,不符合 Slice[T] 的类型约束
var s2 Slice[MyInt]

使用 ~ 有个限制:

  • ~ 后面的类型不能为接口
  • ~ 后面的类型必须为基础类型

比如,下面是一个错误的例子:

1
2
3
// 错误:Invalid use of ~ ('cmp.Ordered' is an interface)
type Ab[T ~cmp.Ordered] struct {
}

泛型的限制

尽管 Go 语言中的泛型带来了许多好处和新的可能性,但它们的实现仍然存在一些限制和挑战。以下是 Go 语言中泛型的一些主要限制:

  • 性能:在 Go 语言中,泛型的一个主要问题是对性能的潜在影响。引入泛型后,Go 编译器需要在编译时为不同类型生成代码,这可能导致更大的二进制文件和更慢的编译时间。
  • 类型约束:Go 语言的泛型实现依赖于类型约束来确保类型安全。然而,这些约束可能会限制可以与泛型函数和数据结构一起使用的类型。
  • 语法复杂性:声明和使用泛型函数和数据结构的语法可能会很复杂,尤其对于初学者来说难以理解。
  • 错误消息:Go 编译器生成的与泛型相关的问题的错误消息可能难以理解,使得调试和故障排除更具挑战性。
  • 代码可读性:在 Go 中,泛型有时会使代码变得不太易读,更难理解,特别是在大量使用类型约束和类型参数的情况下。
  • 无法进行切换:当您想要从一个基础泛型类型切换到另一个时,使用泛型是不可能的。唯一的方法是使用接口,并在运行时运行类型切换函数。

总结

泛型为创建通用接口、结构体和函数提供了一种强大而简单的方法。

它们可以减少冗余信息,并且至少在某些情况下,提供了一种比反射更优越的替代方案。

当然,长时间以来,泛型受到激烈反对的主要原因是它们可能使代码更难阅读和解析,这似乎与 Go 语言的简洁性相悖。 鉴于此,本文也不会介绍太多复杂的泛型用法,上面提到的这些用法应该可以覆盖 90% 以上的使用场景了,因为复杂的代码必然会牺牲不少代码的可维护性。

另一方面,泛型是语言中的一个很好且必要的补充,如果明智地使用并且在有意义的地方使用的话。

Linting 是识别和报告代码中发现的模式的过程,旨在提高一致性,并在开发周期的早期捕捉错误。 在团队合作时特别有用,因为它有助于使所有代码看起来都一样,无论是谁写的,这减少了复杂性,使代码更易于维护。 在本文中,将演示针对 Go 程序的全面 linting 设置,并讨论将其引入现有项目的最佳方法。

代码检查是确保项目中一致的编码规范的最基本的事情之一。 Go语言已经比大多数其他编程语言走得更远,它捆绑了一个格式化工具(也就是 gofmt),确保所有的Go代码看起来都一样,但它只处理代码的格式。 go vet 工具也可用于帮助检测可能不会被编译器捕捉到的可疑结构,但它只能捕捉有限数量的潜在问题。

开发更全面的代码检查工具的任务已交给更广泛的社区,这产生了大量的代码检查工具,每个工具都有特定的目的。其中一些著名的例子包括:

  • unused - 检查 Go 代码中未使用的常量、变量、函数和类型。
  • goconst - 查找可以用常量替换的重复字符串。
  • gocyclo - 计算并检查函数的圈复杂度。
  • errcheck - 检测Go程序中未检查的错误。

拥有如此多独立的代码检查工具的问题在于你必须自己下载每个单独的代码检查工具并管理它们的版本。 此外,依次运行每一个可能会太慢。因此,golangci-lint,一个Go代码检查工具聚合器,可以并行运行代码检查工具,重用 Go 构建缓存,并缓存分析结果,从而在后续运行中大大提高性能,是在 Go 项目中设置代码检查的首选方式。

该项目是为了方便和提高性能而开发的,可以同时聚合和运行多个单独的代码检查工具。安装该程序后,您将获得约 48 个代码检查工具,您可以选择其中对您的项目重要的工具。除了在开发过程中本地运行外,您还可以将其设置为持续集成(CI)工作流程的一部分。

安装 golangci-lint

你可以通过下面的命令将 golangci-lint 安装到你的系统中:

1
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest

安装完成后,您应该检查已安装的版本:

1
2
➜  ~ golangci-lint version
golangci-lint has version v1.55.2 built with go1.21.6 from (unknown, mod sum: "h1:yllEIsSJ7MtlDBwDJ9IMBkyEUz2fYE0b5B8IUgO1oP8=") on (unknown)

您还可以通过以下命令查看所有可用的代码检查器:

1
golangci-lint help linters

输出:

1
2
3
4
5
Enabled by default linters:
errcheck: errcheck is a program for checking for unchecked errors in Go code. These unchecked errors can be critical bugs in some cases [fast: false, auto-fix: false]
gosimple (megacheck): Linter for Go source code that specializes in simplifying code [fast: false, auto-fix: false]
govet (vet, vetshadow): Vet examines Go source code and reports suspicious constructs, such as Printf calls whose arguments do not align with the format string [fast: false, auto-fix: false]
......

也就是说 golangci-lint 默认已经启用了 errcheckgovet 等代码检查器。

默认启用小部分,大部分默认处于禁用状态。

如果在项目目录的根目录运行启用的代码检查工具,可能会看到一些错误。 每个问题都会报告所有您需要修复它的上下文,包括问题的简短描述,以及它发生的文件和行号。

进入项目目录,执行下面的命令:

1
golangci-lint run

输出:

1
2
3
main.go:1: : # gopprof
./main.go:31:2: err declared and not used (typecheck)
package main

golangci-lint 提供了带颜色、源代码行和标识符的良好输出,以便您可以轻松地找到问题所在。

您还可以通过传递一个或多个目录或文件路径来选择要分析的目录和文件。

1
golangci-lint run dir1 dir2 dir3/main.go

配置 golangci-lint

golangci-lint 旨在尽可能灵活,适用于各种用例。 可以通过命令行选项或配置文件来管理 golangci-lint 的配置,尽管如果同时使用两者,前者的优先级更高。 以下是一个使用命令行选项禁用所有检查器并配置应该运行的特定检查器的示例:

1
golangci-lint run --disable-all -E revive -E errcheck -E nilerr -E gosec

这个命令只会进行 reviveerrchecknilerrgosec 检查。

通过 --disable-all 禁用所有检查器,然后使用 -E 选项启用特定的检查器。

您还可以运行由 golangci-lint 提供的预设。以下是了解可用预设的方法:

1
golangci-lint help linters | sed -n '/Linters presets:/,$p'

输出:

1
2
3
4
5
...
error: errcheck, errorlint, goerr113, wrapcheck
format: decorder, gci, gofmt, gofumpt, goimports, sloglint, tagalign
import: depguard, gci, goimports, gomodguard
...

然后,您可以通过将其名称传递给 --preset-p 标志来运行预设:

1
golangci-lint run -p bugs -p error

golangci-lintpreset 可以被理解为预定义的配置集,每个 preset 对应一组特定的配置和规则。通过使用 preset ,用户可以方便地启用一组默认的规则,而无需手动配置每个 linter 的选项。

最好通过配置文件来为项目配置 golangci-lint。这样,您可以配置特定的代码检查器选项,这是通过命令行选项无法实现的。 您可以将配置文件指定为 YAMLTOMLJSON 格式,但我建议坚持使用 YAML 格式(.golangci.yml.golangci.yaml),因为官方文档页面上使用的就是这种格式。

一般来说,你应该在项目目录的根目录中创建特定于项目的配置。程序会自动在待检查文件所在的目录以及一直向上到文件系统根目录的父目录中寻找它们。这意味着你可以通过在 home 目录中放置一个配置文件来实现所有项目的全局配置(不建议)。如果本地范围的配置文件不存在,将使用该文件。

官网上提供了一个示例配置文件,其中包含所有支持的选项、它们的描述和默认值。在创建自己的配置时,您可以将其作为起点。 请记住,一些代码检查工具执行类似的功能,因此您需要有意地启用代码检查工具,以避免重复的条目。 以下是我在个人项目中使用的一般配置(.golangci.yml):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
linters-settings:
errcheck:
check-type-assertions: true
goconst:
min-len: 2
min-occurrences: 3
gocritic:
enabled-tags:
- diagnostic
- experimental
- opinionated
- performance
- style
govet:
check-shadowing: true
enable:
- fieldalignment
nolintlint:
require-explanation: true
require-specific: true

linters:
disable-all: true
enable:
- bodyclose
- deadcode
- depguard
- dogsled
- dupl
- errcheck
- exportloopref
- exhaustive
- goconst
- gocritic
- gofmt
- goimports
- gomnd
- gocyclo
- gosec
- gosimple
- govet
- ineffassign
- misspell
- nolintlint
- nakedret
- prealloc
- predeclared
- revive
- staticcheck
- structcheck
- stylecheck
- thelper
- tparallel
- typecheck
- unconvert
- unparam
- varcheck
- whitespace
- wsl

run:
issues-exit-code: 1

抑制 linting 错误

有时需要禁用文件或包中出现的特定代码检查问题。这可以通过两种主要方式实现:通过 nolint 指令和配置文件中的排除规则。让我们依次看看每种方法。

nolint 指令

假设我们有以下代码,它会将伪随机整数打印到标准输出:

1
2
3
4
5
6
7
8
9
10
11
12
package main

import (
"fmt"
"math/rand"
"time"
)

func main() {
rand.Seed(time.Now().UnixNano())
fmt.Println(rand.Int())
}

运行 golangci-lint 时(golangci-lint run --disable-all -E gosec),会看到以下输出:

1
2
3
main.go:11:14: G404: Use of weak random number generator (math/rand instead of crypto/rand) (gosec)
fmt.Println(rand.Int())
^

linter 鼓励使用 crypto/randInt 方法,因为它在密码学上更安全,但它的 API 不太友好,性能较慢。 如果你可以接受速度更快的代价来换取不太安全的伪随机数,你可以通过在必要的行上添加 nolint 指令来忽略错误。

1
2
3
4
func main() {
rand.Seed(time.Now().UnixNano())
fmt.Println(rand.Int()) //nolint
}

根据 Go 的约定,机器可读的注释不应包含空格,因此应该使用 //nolint 而不是 // nolint

当您在文件顶部使用 nolint 指令时,它会禁用该文件的所有 linting 问题:

1
2
//nolint:govet,errcheck
package main

您还可以通过在代码块(如函数)的开头使用 nolint 指令来排除问题。

添加 nolint 指令后,建议添加一条注释,解释为什么需要该指令。该注释应放置在与标志本身相同的行上:

1
2
3
4
func main() {
rand.Seed(time.Now().UnixNano())
fmt.Println(rand.Int()) //nolint:gosec // for faster performance
}

排除规则

在配置文件中可以指定排除规则,以更精细地控制对哪些文件进行代码检查,以及报告哪些问题。 例如,您可以禁用某些代码检查器在测试文件上的运行,或者可以禁用某个代码检查器在整个项目中产生特定的错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
#.golangci.yml
issues:
exclude-rules:
- path: _test\.go # disable some linters on test files
linters:
- gocyclo
- gosec
- dupl

# Exclude some gosec messages project-wide
- linters:
- gosec
text: "weak cryptographic primitive"

与现有项目集成

在向现有项目添加 golangci-lint 时,可能会出现许多问题,一次性解决所有问题可能会很困难。 但这并不意味着你应该因此放弃对项目进行代码检查的想法。有一个 new-from-rev 设置,允许你仅显示在特定 git 修订版本之后创建的新问题,这样可以轻松地只对新代码进行代码检查,直到有足够的时间来解决旧问题。一旦找到要从中开始进行代码检查的修订版本(使用 git log ),你可以在配置文件中指定如下:

1
2
3
4
#.golangci.yml
issues:
# Show only new issues created after git revision: 02270a6
new-from-rev: 02270a6

这样只会检查 02270a6 版本后的代码。

在你的编辑器中集成 golangci-lint

golangci-lint 支持与多个编辑器集成,以便快速获得反馈。在 Visual Studio Code中,您只需安装 Go 扩展,并将以下行添加到您的 settings.json 文件中:

1
2
3
4
5
6
{
"go.lintTool":"golangci-lint",
"go.lintFlags": [
"--fast"
]
}

持续集成

在每个 PR 上运行项目的代码检查规则,可以防止不符合标准的代码进入代码库。这也可以通过将 golangci-lint 添加到持续集成流程中实现自动化。 比如:

  • Github Actions(如果你使用 Github)
  • Gitlab CI(如果你使用 Gitlab)

下面是一个 Github Action 配置的示例(当然,下面这个例子不太好,没有指定确定的版本):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
name: Go

on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]

jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v3
with:
version: latest
args: --timeout 5m

在设置过程中,请确保固定使用的 golangci-lint 版本,以便在本地环境中产生一致的结果。

总结

本文介绍了 Golang 中代码检查的工具 golangci-lint,并讨论了如何将其集成到现有项目中。 使用 golangci-lint,您可以轻松地在团队协作中保持一致的代码风格,并在开发周期的早期捕捉错误。

在本教程中,我们将介绍在 golang 中执行 shell 命令的多种方法和场景。

使用 exec.Command() 运行简单的 shell 命令

这是一个简单的 golang 代码,它使用 exec.Command() 函数打印当前目录的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import (
"fmt"
"os/exec"
)

func main() {
cmd := exec.Command("ls")
out, err := cmd.Output()

if err != nil {
panic(err)
}

fmt.Println(string(out))
}

如果要将参数传递给命令,可以将它们作为附加参数包含在 exec.Command(). 例如,要运行 ls -l -a,您可以使用:

1
2
3
// 你可以传递多个参数给 exec.Command()
// exec.Command("cmd", "arg1", "arg2", "argn")
cmd := exec.Command("ls", "-l", "-a")

是否可以在不存储输出的情况下执行shell命令?

如果您需要仅执行某些 shell 命令而不存储输出,那么我们可以使用 Run() 函数而不是 Output()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import (
"os/exec"
)

func main() {
cmd := exec.Command("/bin/bash", "-c", "ls")

// 执行 shell 命令,但不存储输出
err := cmd.Run()

if err != nil {
panic(err)
}
}

该代码不会产生任何输出,它只会触发 ls 命令并退出。

为什么我们不应该使用 exec.Command() 函数?

虽然 exec.Command() 可以让我们执行 shell 命令,但是我们应该尽量避免 exec.Command(),原因有多种:

  • 安全风险:如果没有正确清理,传递给的参数 exec.Command 可能容易受到命令注入攻击。
  • 资源使用:exec.Command 为每个命令创建一个新进程,这可能会占用大量资源并导致性能不佳。
  • 有限控制:exec.Command 将命令作为单独的进程启动并立即返回,这意味着命令运行后您对其的控制权有限。
  • 错误处理:如果 exec.Command 执行的命令以非零状态代码退出,则返回错误,但不提供有关错误的详细信息。
  • 不可预测的行为:当命令在不同平台上运行或环境发生变化时,可能会出现意外的行为。
  • 有限的互操作性:当您需要在默认 shell 之外的不同 shell 中运行命令时,这不是最佳选择。

虽然 exec.Command 对于运行简单的 shell 命令很有用,但对于更复杂的命令或当您需要对命令执行进行更多控制时,它可能不是最佳选择。 您可以尝试考虑使用其他库(例如 Cobra)来处理应用程序中的命令行参数和命令。

在后台执行 shell 命令并等待其完成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
"fmt"
"os/exec"
)

func main() {
cmd := exec.Command("sleep", "10")
fmt.Println("Starting now!")
// 开始执行命令
err := cmd.Start()

if err != nil {
panic(err)
}

// 等待命令执行完成
err = cmd.Wait()
fmt.Println("Completed..")
if err != nil {
panic(err)
}
}

输出:

1
2
Starting now!
Completed..

使用上下文执行 shell 命令

我们还可以使用 os/exec 包的 CommandContext 功能,它允许传递上下文并将参数作为字符串切片传递。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import (
"context"
"fmt"
"os/exec"
)

func main() {
ctx := context.Background()
cmd := exec.CommandContext(ctx, "ls", "-l", "-a")
out, err := cmd.Output()
if err != nil {
panic(err)
}
fmt.Println(string(out))
}

这里的 context 可以用于取消命令的执行(使用 context.WithCancel() 即可)。

如何将变量传递给 shell 命令?

我们可能还需要将变量从 golang 代码传递到 shell 命令作为输入参数。这需要一些额外的处理,这里有一些可能的方法。

方法 1:传递变量作为输入参数

我们可以将变量作为输入参数传递给 exec.Command() 如下例所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import (
"fmt"
"os/exec"
)

func main() {
message := "Hello, World!"
cmd := exec.Command("echo", message)
out, err := cmd.Output()
if err != nil {
panic(err)
}
fmt.Println(string(out))
}

方法 2:使用 fmt.Sprintf() 函数

我们还可以使用 Sprintf 函数创建一个包含命令和变量的字符串,然后将该字符串传递给 Command 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import (
"fmt"
"os/exec"
)

func main() {
message := "Hello, World!"
cmdStr := fmt.Sprintf("echo %s", message)
cmd := exec.Command("bash", "-c", cmdStr)
out, err := cmd.Output()
if err != nil {
panic(err)
}
fmt.Println(string(out))
}

将整数作为变量传递给 shell 命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import (
"fmt"
"os/exec"
)

func main() {
x := 42
cmd := exec.Command("echo", fmt.Sprintf("%d", x))
out, err := cmd.Output()
if err != nil {
panic(err)
}
fmt.Println(string(out)) // 42
}

将浮点数作为变量传递给 shell 命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package main

import (
"fmt"
"os/exec"
)

func main() {
y := 3.14
cmd := exec.Command("echo", fmt.Sprintf("%f", y))
out, err := cmd.Output()
if err != nil {
panic(err)
}
fmt.Println(string(out)) // 3.140000
}

使用管道符 (|) 传递 shell 命令

方法 1:使用 exec.Command()

我们可以通过使用 exec.Command() 并将命令作为由管道字符 “|” 分隔的单个字符串来传递,从而使用管道运行 shell 命令。以下是运行简单命令 ls、将其输出通过管道传输到 grep 命令并搜索特定文件的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import (
"fmt"
"os/exec"
)

func main() {
cmd := exec.Command("bash", "-c", "ls | grep main.go")
out, err := cmd.Output()
if err != nil {
panic(err)
}
fmt.Println(string(out))
}

我们还可以使用以下格式的管道传递多个命令:

1
cmd := exec.Command("bash", "-c", "command1 | command2 | command3")

方法2:使用context包

我们可以使用 os/exec 包的 CommandContext 函数来实现相同的目的,该函数允许传递上下文并在字符串切片中传递命令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import (
"context"
"fmt"
"os/exec"
)

func main() {
ctx := context.Background()
cmd := exec.CommandContext(ctx, "bash", "-c", "ls | grep main.go")
out, err := cmd.Output()
if err != nil {
panic(err)
}
fmt.Println(string(out))
}

运行多个 shell 命令

方法 1:使用 exec.Command() 函数

我们可以再次使用 exec.Command() 函数来提供要按顺序执行的命令列表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package main

import (
"fmt"
"os/exec"
)

func main() {
commands := []string{
"ping -c 2 google.com",
"ping -c 2 facebook.com",
"ping -c 2 www.golinuxcloud.com",
}
for _, command := range commands {
cmd := exec.Command("bash", "-c", command)
out, err := cmd.Output()
if err != nil {
fmt.Println(err)
}
fmt.Println(string(out))
}
}

方法2:使用上下文功能

我们还可以使用 os/exec 包的 CommandContext 函数来实现相同的目的,该函数允许传递上下文并在字符串切片中传递命令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
"context"
"fmt"
"os/exec"
)

func main() {
ctx := context.Background()
commands := []string{
"ping -c 2 google.com",
"ping -c 2 yahoo.com",
"ping -c 2 www.golinuxcloud.com",
}
for _, command := range commands {
cmd := exec.CommandContext(ctx, "bash", "-c", command)
out, err := cmd.Output()
if err != nil {
fmt.Println(err)
}
fmt.Println(string(out))
}
}

总结

在本文中,我们尝试介绍可在 golang 中使用的各种可能的方法来执行 shell 命令。以下是我们使用的一些方法:

  • exec.Command:这是在 Go 中运行 shell 命令最常用的方法。它创建一个新进程并在该进程中运行命令。该函数将命令及其参数作为单独的参数,并返回一个 exec.Cmd 结构体,该结构体提供与命令交互的方法。
  • exec.CommandContext:它类似于 exec.Command,但它允许将上下文传递给命令(功能类似我们 http 中常用的 context)。

我们还学习了如何使用 StartWait 函数在后台启动进程并等待其完成。

在过去多年里,我们在 Go 中写日志的时候,通常都是使用 Zerolog 或者 Zap 这两个包,

在本文中,我们将重点探讨 Go 最近引入的 log/slog 包,该包旨在将高性能、结构化和分级日志记录引入 Go 标准库。

该软件包起源于某位用户在 GitHub 上发起的讨论:structured, leveled logging,后来演变为描述软件包设计的提案。经最终确定,该软件包在 Go 1.21 中发布,也就是现在的 log/slog

slog 旨在提供一个简单的 API,用于记录结构化的、分级的日志。它也可以很容易地与现有的日志记录库集成,例如 ZerologZap,这样你就可以在不改变太多现有代码的情况下,使用 slog 来记录日志。(这种情况下,slog 只是作为日志记录库的一个 “前端”。)

在接下来的章节中,我将详细介绍 Slog 提供的内容,并附上示例。

开始使用 Slog

让我们通过对其设计和架构的讲解来开始探索 log/slog 包。它提供了三种主要类型,你应该熟悉:

  • Logger:记录 “前端”,提供诸如(Info()Error())的级别方法,用于记录感兴趣的事件。
  • Record:由 Logger 创建的每个独立的日志对象的表示。
  • Handler: 一种接口,一旦实现,就确定了每个 Record 的格式和目的地。 log/slog 包中包含两个内置处理程序: TextHandlerJSONHandler 分别用于 key=valueJSON 输出。

与大多数 Go 日志库一样, slog 包公开了一个默认的 Logger ,可以通过顶层函数访问。这个记录器产生的输出几乎与旧的 log.Printf() 方法完全相同,只是包含了日志级别:

1
2
3
4
5
6
7
8
9
10
11
package main

import (
"log"
"log/slog"
)

func main() {
log.Print("Info message")
slog.Info("Info message")
}

输出:

1
2
2024/01/03 10:24:22 Info message
2024/01/03 10:24:22 INFO Info message

这是一个有点奇怪的默认设置,因为 Slog 的主要目的是将结构化日志记录引入标准库。

通过使用 slog.New() 方法创建自定义 Logger 实例来纠正这个问题是相当容易的。它接受 Handler 接口的实现,该接口确定日志的格式和写入位置。

这是一个使用内置 JSONHandler 类型将 JSON 日志输出到 stdout 的示例:

1
2
3
4
5
6
7
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Debug("Debug message")
logger.Info("Info message")
logger.Warn("Warning message")
logger.Error("Error message")
}

输出:

1
2
3
{"time":"2023-03-15T12:59:22.227408691+01:00","level":"INFO","msg":"Info message"}
{"time":"2023-03-15T12:59:22.227468972+01:00","level":"WARN","msg":"Warning message"}
{"time":"2023-03-15T12:59:22.227472149+01:00","level":"ERROR","msg":"Error message"}

当使用 TextHandler 类型时,每个日志记录将按照 Logfmt 标准进行格式化:

1
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))

输出:

1
2
3
time=2023-03-15T13:00:11.333+01:00 level=INFO msg="Info message"
time=2023-03-15T13:00:11.333+01:00 level=WARN msg="Warning message"
time=2023-03-15T13:00:11.333+01:00 level=ERROR msg="Error message"

所有 Logger 实例默认记录在 INFO 级别,这会导致 DEBUG 条目被抑制,但您可以根据需要轻松更新。

自定义默认记录器

定制默认 Logger 最直接的方法是利用 slog.SetDefault() 方法,允许您用自定义的日志记录器替换默认的日志记录器。

1
2
3
4
5
6
7
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

slog.SetDefault(logger)

slog.Info("Info message")
}

您现在会注意到,软件包的顶层日志记录方法现在会生成如下所示的 JSON 输出:

1
{"time":"2023-03-15T13:02:22.227408691+01:00","level":"INFO","msg":"Info message"}

使用 SetDefault() 方法还会改变 log 包使用的默认 log.Logger。这种行为允许利用旧 log 包的现有应用程序无缝过渡到结构化日志记录:

1
2
3
4
5
6
7
8
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

slog.SetDefault(logger)

// elsewhere in the application
log.Println("Hello from old logger")
}

输出:

1
{"time":"2023-03-15T13:03:22.227408691+01:00","level":"INFO","msg":"Hello from old logger"}

当您需要使用需要后者(例如 http.Server.ErrorLog)的 API 时,也可以使用 slog.NewLogLogger() 方法将 slog.Logger 转换为 log.Logger

1
2
3
4
5
6
7
8
9
10
func main() {
handler := slog.NewJSONHandler(os.Stdout, nil)

logger := slog.NewLogLogger(handler, slog.LevelError)

_ = http.Server{
// this API only accepts `log.Logger`
ErrorLog: logger,
}
}

将上下文属性添加到日志记录

结构化日志比非结构化格式的一个重要优势是能够在日志记录中添加任意属性作为键值对。

这些属性提供了有关已记录事件的额外上下文,这对于诸如故障排除、生成指标、审计和其他各种目的非常有价值。

这里有一个示例,说明了它在 Slog 中是如何工作的:

1
2
3
4
5
6
7
8
logger.Info(
"incoming request",
"method", "GET",
"time_taken_ms", 158,
"path", "/hello/world?q=search",
"status", 200,
"user_agent", "Googlebot/2.1 (+http://www.google.com/bot.html)",
)

输出:

1
2
3
4
5
6
7
8
9
10
{
"time":"2023-02-24T11:52:49.554074496+01:00",
"level":"INFO",
"msg":"incoming request",
"method":"GET",
"time_taken_ms":158,
"path":"/hello/world?q=search",
"status":200,
"user_agent":"Googlebot/2.1 (+http://www.google.com/bot.html)"
}

所有级别方法(Info()Debug() 等)都接受日志消息作为它们的第一个参数,以及之后无限数量的松散类型的键/值对。

这个 API 类似于 Zap 中的 SugaredLogger API(特别是它的以 w 结尾的级别方法),因为它在追求简洁的同时牺牲了额外的内存分配。

但要小心,因为这种方法可能会导致意想不到的问题。具体来说,不平衡的键/值对可能会导致问题输出:

1
2
3
4
5
logger.Info(
"incoming request",
"method", "GET",
"time_taken_ms", // the value for this key is missing
)

由于 time_taken_ms 键没有对应的值,它将被视为具有键 !BADKEY 的值。这并不好,因为属性不对齐可能会产生错误的条目,直到您需要使用日志时才会知道。

输出:

1
2
3
4
5
6
7
{
"time": "2023-03-15T13:15:29.956566795+01:00",
"level": "INFO",
"msg": "incoming request",
"method": "GET",
"!BADKEY": "time_taken_ms"
}

为了防止这样的问题,您可以运行 vet 命令或使用一个代码检查工具来自动报告这些问题:

1
2
$ go vet .
./main.go:11:2: call to slog.Info missing a final value

另一种防止这种错误的方法是使用如下所示的强类型上下文属性:

1
2
3
4
5
6
7
8
9
10
11
logger.Info(
"incoming request",
slog.String("method", "GET"),
slog.Int("time_taken_ms", 158),
slog.String("path", "/hello/world?q=search"),
slog.Int("status", 200),
slog.String(
"user_agent",
"Googlebot/2.1 (+http://www.google.com/bot.html)",
),
)

虽然这是一种更好的上下文日志记录方法,但它并非百分之百可靠,因为没有阻止你像这样混合使用强类型和弱类型的键值对:

1
2
3
4
5
6
7
8
9
10
11
logger.Info(
"incoming request",
"method", "GET",
slog.Int("time_taken_ms", 158),
slog.String("path", "/hello/world?q=search"),
"status", 200,
slog.String(
"user_agent",
"Googlebot/2.1 (+http://www.google.com/bot.html)",
),
)

为了确保在向记录添加上下文属性时的类型安全性,您必须像这样使用 LogAttrs() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
logger.LogAttrs(
context.Background(),
slog.LevelInfo,
"incoming request",
slog.String("method", "GET"),
slog.Int("time_taken_ms", 158),
slog.String("path", "/hello/world?q=search"),
slog.Int("status", 200),
slog.String(
"user_agent",
"Googlebot/2.1 (+http://www.google.com/bot.html)",
),
)

该方法只接受自定义属性的 slog.Attr 类型,因此不可能存在不平衡的键/值对。然而,它的 API 更加复杂,因为您总是需要传递上下文(或 nil )和日志级别到该方法,除了日志消息和自定义属性。

分组上下文属性

Slog 还允许将多个属性分组在一个名称下,但输出取决于使用的 Handler 。例如,使用 JSONHandler ,每个组都嵌套在 JSON 对象中:

1
2
3
4
5
6
7
8
9
10
11
logger.LogAttrs(
context.Background(),
slog.LevelInfo,
"image uploaded",
slog.Int("id", 23123),
slog.Group("properties",
slog.Int("width", 4000),
slog.Int("height", 3000),
slog.String("format", "jpeg"),
),
)

输出:

1
2
3
4
5
6
7
8
9
10
11
{
"time":"2023-02-24T12:03:12.175582603+01:00",
"level":"INFO",
"msg":"image uploaded",
"id":23123,
"properties":{
"width":4000,
"height":3000,
"format":"jpeg"
}
}

在使用 TextHandler 时,组中的每个键都将以组名作为前缀,就像这样:

1
2
time=2023-02-24T12:06:20.249+01:00 level=INFO msg="image uploaded" id=23123
properties.width=4000 properties.height=3000 properties.format=jpeg

创建和使用子记录器

在特定范围内的所有记录中包含相同的属性可能有益,可以确保它们的存在,而无需重复的记录语句。

这就是孩子记录器可以发挥作用的地方,因为它们创建了一个新的日志上下文,继承自其父级,同时允许包含额外的字段。

Slog 中,使用 Logger.With() 方法可以创建子记录器。它接受一个或多个键/值对,并返回一个包含指定属性的新 Logger

考虑以下代码片段,它将程序的进程ID和用于编译的 Go 版本添加到每个日志记录中,并将它们存储在一个 program_info 属性中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func main() {
handler := slog.NewJSONHandler(os.Stdout, nil)
buildInfo, _ := debug.ReadBuildInfo()

logger := slog.New(handler)

child := logger.With(
slog.Group("program_info",
slog.Int("pid", os.Getpid()),
slog.String("go_version", buildInfo.GoVersion),
),
)

// . . .
}

有了这个配置,child 记录器创建的所有记录都将包含指定属性在 program_info 属性下

1
2
3
4
5
6
7
8
9
func main() {
// . . .

child.Info("image upload successful", slog.String("image_id", "39ud88"))
child.Warn(
"storage is 90% full",
slog.String("available_space", "900.1 mb"),
)
}

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
{
"time": "2023-02-26T19:26:46.046793623+01:00",
"level": "INFO",
"msg": "image upload successful",
"program_info": {
"pid": 229108,
"go_version": "go1.20"
},
"image_id": "39ud88"
}
{
"time": "2023-02-26T19:26:46.046847902+01:00",
"level": "WARN",
"msg": "storage is 90% full",
"program_info": {
"pid": 229108,
"go_version": "go1.20"
},
"available_space": "900.1 MB"
}

您还可以使用 WithGroup() 方法创建一个子记录器,以便启动一个组,使所有添加到记录器的属性(包括在日志点添加的属性)都嵌套在组名称下:

1
2
3
4
5
6
7
8
9
10
11
12
13
handler := slog.NewJSONHandler(os.Stdout, nil)
buildInfo, _ := debug.ReadBuildInfo()
logger := slog.New(handler).WithGroup("program_info")

child := logger.With(
slog.Int("pid", os.Getpid()),
slog.String("go_version", buildInfo.GoVersion),
)

child.Warn(
"storage is 90% full",
slog.String("available_space", "900.1 MB"),
)

输出:

1
2
3
4
5
6
7
8
9
10
{
"time": "2023-05-24T19:00:18.384136084+01:00",
"level": "WARN",
"msg": "storage is 90% full",
"program_info": {
"pid": 1971993,
"go_version": "go1.20.2",
"available_space": "900.1 mb"
}
}

自定义 Slog 级别

log/slog 包默认提供了四个日志级别,每个级别都与一个整数值相关联:DEBUG(-4)INFO(0)WARN(4)ERROR(8)

每个级别之间的间隔为 4,这是一个经过深思熟虑的设计决定,以适应具有自定义级别的日志方案。例如,您可以在 INFOWARN 之间创建一个自定义级别,其值为1、2或3。

我们先前观察到,默认情况下,所有记录器都配置为以 INFO 级别记录日志,这会导致记录在更低严重性(如 DEBUG )的事件被抑制。您可以通过以下 HandlerOptions 类型自定义此行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
opts := &slog.HandlerOptions{
Level: slog.LevelDebug,
}

handler := slog.NewJSONHandler(os.Stdout, opts)

logger := slog.New(handler)
logger.Debug("Debug message")
logger.Info("Info message")
logger.Warn("Warning message")
logger.Error("Error message")
}

输出:

1
2
3
4
{"time":"2023-05-24T19:03:10.70311982+01:00","level":"DEBUG","msg":"Debug message"}
{"time":"2023-05-24T19:03:10.703187713+01:00","level":"INFO","msg":"Info message"}
{"time":"2023-05-24T19:03:10.703190419+01:00","level":"WARN","msg":"Warning message"}
{"time":"2023-05-24T19:03:10.703192892+01:00","level":"ERROR","msg":"Error message"}

这种设置级别的方法会在整个生命周期中固定级别。如果需要动态变化的最小级别,必须使用下面所示的类型。

1
2
3
4
5
6
7
8
9
10
11
func main() {
logLevel := &slog.LevelVar{} // INFO

opts := &slog.HandlerOptions{
Level: logLevel,
}

handler := slog.NewJSONHandler(os.Stdout, opts)

// . . .
}

您随时可以使用以下方法更新日志级别:

1
logLevel.Set(slog.LevelDebug)

创建自定义日志级别

如果您需要超出 Slog 默认提供的自定义级别,可以通过实现以下签名的 Leveler 接口来创建它们:

1
2
3
type Leveler interface {
Level() Level
}

通过下面显示的类型很容易实现这个接口(因为 Level 本身实现了 Leveler):

1
2
3
4
const (
LevelTrace = slog.Level(-8)
LevelFatal = slog.Level(12)
)

一旦您按上述方式定义了自定义级别,您只能通过 Log()LogAttrs() 方法使用它们:

1
2
3
4
5
6
7
8
9
opts := &slog.HandlerOptions{
Level: LevelTrace,
}

logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))

ctx := context.Background()
logger.Log(ctx, LevelTrace, "Trace message")
logger.Log(ctx, LevelFatal, "Fatal level")

输出:

1
2
{"time":"2023-02-24T09:26:41.666493901+01:00","level":"DEBUG-4","msg":"Trace level"}
{"time":"2023-02-24T09:26:41.666602404+01:00","level":"ERROR+4","msg":"Fatal level"}

注意自定义级别是如何以默认级别标记的。这绝对不是你想要的,所以你应该通过 HandlerOptions 类型自定义级别名称,就像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
. . .

var LevelNames = map[slog.Leveler]string{
LevelTrace: "TRACE",
LevelFatal: "FATAL",
}

func main() {
opts := slog.HandlerOptions{
Level: LevelTrace,
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
if a.Key == slog.LevelKey {
level := a.Value.Any().(slog.Level)
levelLabel, exists := LevelNames[level]
if !exists {
levelLabel = level.String()
}

a.Value = slog.StringValue(levelLabel)
}

return a
},
}

. . .
}

ReplaceAttr() 函数用于自定义 Handler 处理 Record 中每个键值对的方式。它可以用于自定义键名,或以某种方式处理值。

在上面的示例中,它将自定义日志级别映射到它们各自的标签,分别生成 TRACEFATAL

1
2
{"time":"2023-02-24T09:27:51.747625912+01:00","level":"TRACE","msg":"Trace level"}
{"time":"2023-02-24T09:27:51.747737319+01:00","level":"FATAL","msg":"Fatal level"}

自定义 Slog 处理程序(Handler)

如前所述,TextHandlerJSONHandler 都可以使用 HandlerOptions 类型进行自定义。您已经看到了如何调整最小级别并修改属性以记录它们。

如果需要,可以通过包括日志来源来实现另一种定制化:

1
2
3
4
opts := &slog.HandlerOptions{
AddSource: true,
Level: slog.LevelDebug,
}

输出:

1
2
3
4
5
6
7
8
9
10
{
"time": "2024-01-03T11:06:50.971029852+01:00",
"level": "DEBUG",
"source": {
"function": "main.main",
"file": "/home/ayo/dev/betterstack/demo/slog/main.go",
"line": 17
},
"msg": "Debug message"
}

根据应用环境轻松切换足够的处理程序也很容易。例如,您可能更喜欢在开发日志中使用 TextHandler ,因为它更容易阅读,然后在生产环境中切换到 JSONHandler ,以获得更灵活性和与各种日志工具的兼容性。

这种行为可以通过环境变量轻松实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var appEnv = os.Getenv("APP_ENV")

func main() {
opts := &slog.HandlerOptions{
Level: slog.LevelDebug,
}

var handler slog.Handler = slog.NewTextHandler(os.Stdout, opts)
if appEnv == "production" {
handler = slog.NewJSONHandler(os.Stdout, opts)
}

logger := slog.New(handler)

logger.Info("Info message")
}

输出:

1
time=2023-02-24T10:36:39.697+01:00 level=INFO msg="Info message"

执行:

1
APP_ENV=production go run main.go

输出:

1
{"time":"2023-02-24T10:35:16.964821548+01:00","level":"INFO","msg":"Info message"}

创建自定义处理程序

由于 Handler 是一个接口,可以创建自定义处理程序,以不同的格式格式化日志或将其写入其他目的地。

它的签名如下:

1
2
3
4
5
6
type Handler interface {
Enabled(context.Context, Level) bool
Handle(context.Context, r Record) error
WithAttrs(attrs []Attr) Handler
WithGroup(name string) Handler
}

这是每种方法的作用:

  • Enabled: 根据其级别确定是否处理或丢弃日志记录。也可以使用 context 做出决定。
  • Handle: 处理每个发送到处理程序的日志记录。仅在 Enabled() 返回 true 时调用。
  • WithAttrs: 从现有的处理程序创建一个新的处理程序,并将指定的属性添加到其中。
  • WithGroup: 从现有的处理程序创建一个新的处理程序,并将指定的组名添加到其中,以便该名称限定后续的属性。

这是一个使用 logjsoncolor 包来实现日志记录的美化开发输出的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// 注意:下面代码没有经过完整测试,只是为了说明可能的用法
package main

import (
"context"
"encoding/json"
"io"
"log"
"log/slog"

"github.com/fatih/color"
)

type PrettyHandlerOptions struct {
SlogOpts slog.HandlerOptions
}

type PrettyHandler struct {
slog.Handler
l *log.Logger
}

func (h *PrettyHandler) Handle(ctx context.Context, r slog.Record) error {
level := r.Level.String() + ":"

switch r.Level {
case slog.LevelDebug:
level = color.MagentaString(level)
case slog.LevelInfo:
level = color.BlueString(level)
case slog.LevelWarn:
level = color.YellowString(level)
case slog.LevelError:
level = color.RedString(level)
}

fields := make(map[string]interface{}, r.NumAttrs())
r.Attrs(func(a slog.Attr) bool {
fields[a.Key] = a.Value.Any()

return true
})

b, err := json.MarshalIndent(fields, "", " ")
if err != nil {
return err
}

timeStr := r.Time.Format("[15:05:05.000]")
msg := color.CyanString(r.Message)

h.l.Println(timeStr, level, msg, color.WhiteString(string(b)))

return nil
}

func NewPrettyHandler(
out io.Writer,
opts PrettyHandlerOptions,
) *PrettyHandler {
h := &PrettyHandler{
Handler: slog.NewJSONHandler(out, &opts.SlogOpts),
l: log.New(out, "", 0),
}

return h
}

当你在代码中像这样使用 PrettyHandler 时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func main() {
opts := PrettyHandlerOptions{
SlogOpts: slog.HandlerOptions{
Level: slog.LevelDebug,
},
}
handler := NewPrettyHandler(os.Stdout, opts)
logger := slog.New(handler)
logger.Debug(
"executing database query",
slog.String("query", "SELECT * FROM users"),
)
logger.Info("image upload successful", slog.String("image_id", "39ud88"))
logger.Warn(
"storage is 90% full",
slog.String("available_space", "900.1 MB"),
)
logger.Error(
"An error occurred while processing the request",
slog.String("url", "https://example.com"),
)
}

当您执行该程序时,您将观察到以下着色的输出:

使用 Slog 的上下文包

到目前为止,我们主要使用了级别方法的标准变体,比如 Info()Debug() 等,但 Slog 还提供了接受 context.Context 值作为其第一个参数的上下文感知变体。以下是每个方法的签名:

1
func (ctx context.Context, msg string, args ...any)

通过这种方法,您可以通过将上下文属性存储在 Context 中,在函数之间传播它们,这样当找到这些值时,它们会被添加到任何生成的记录中。

请考虑以下程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import (
"context"
"log/slog"
"os"
)

func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

ctx := context.WithValue(context.Background(), "request_id", "req-123")

logger.InfoContext(ctx, "image uploaded", slog.String("image_id", "img-998"))
}

request_id 添加到 ctx 变量,并传递给 InfoContext 方法。然而,当程序运行时, request_id 字段不会出现在日志中:

1
2
3
4
5
6
{
"time": "2024-01-02T11:04:28.590527494+01:00",
"level": "INFO",
"msg": "image uploaded",
"image_id": "img-998"
}

要使其正常工作,您需要创建一个自定义处理程序,并按照下面所示重新实现 Handle 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
type ctxKey string

const (
slogFields ctxKey = "slog_fields"
)

type ContextHandler struct {
slog.Handler
}

// 添加上下文属性到 Record 中,然后调用底层的 handler
func (h ContextHandler) Handle(ctx context.Context, r slog.Record) error {
if attrs, ok := ctx.Value(slogFields).([]slog.Attr); ok {
for _, v := range attrs {
r.AddAttrs(v)
}
}

return h.Handler.Handle(ctx, r)
}

// AppendCtx 将 slog 属性添加到提供的上下文中,
// 以便在使用此类上下文创建的任何 Record 中都会包含该属性
func AppendCtx(parent context.Context, attr slog.Attr) context.Context {
if parent == nil {
parent = context.Background()
}

if v, ok := parent.Value(slogFields).([]slog.Attr); ok {
v = append(v, attr)
return context.WithValue(parent, slogFields, v)
}

v := []slog.Attr{}
v = append(v, attr)
return context.WithValue(parent, slogFields, v)
}

ContextHandler 结构嵌入了 slog.Handler 接口,并实现了 Handle 方法,以提取存储在提供的上下文中的 Slog 属性。如果找到,它们将被添加到 Record 中,然后调用底层的 Handler 来格式化和输出记录。

另一方面, AppendCtx 函数使用 slogFields 键向 context.Context 添加 Slog 属性,以便 ContextHandler 可访问。

这是如何同时使用它们的方法:

1
2
3
4
5
6
7
8
9
func main() {
h := &ContextHandler{slog.NewJSONHandler(os.Stdout, nil)}

logger := slog.New(h)

ctx := AppendCtx(context.Background(), slog.String("request_id", "req-123"))

logger.InfoContext(ctx, "image uploaded", slog.String("image_id", "img-998"))
}

您现在将会观察到,request_id 将包含在使用 ctx 参数创建的任何记录中:

输出:

1
2
3
4
5
6
7
{
"time": "2024-01-02T11:29:15.229984723+01:00",
"level": "INFO",
"msg": "image uploaded",
"image_id": "img-998",
"request_id": "req-123"
}

使用 Slog 进行错误日志记录

在记录错误时,大多数框架都没有为 error 类型提供辅助程序,因此您必须像这样使用 slog.Any()

1
2
3
err := errors.New("something happened")

logger.ErrorContext(ctx, "upload failed", slog.Any("error", err))

输出:

1
2
3
4
5
6
{
"time": "2024-01-02T14:13:44.41886393+01:00",
"level": "ERROR",
"msg": "upload failed",
"error": "something happened"
}

要获取和记录错误堆栈跟踪,您可以使用类似 xerrors 的库来创建带有堆栈跟踪的错误:

1
2
3
err := xerrors.New("something happened")

logger.ErrorContext(ctx, "upload failed", slog.Any("error", err))

在你能够观察错误日志中的堆栈跟踪之前,你还需要提取、格式化并通过之前演示的 ReplaceAttr() 函数将其添加到相应的 Record 中。

这是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
package main

import (
"context"
"log/slog"
"os"
"path/filepath"

"github.com/mdobak/go-xerrors"
)

type stackFrame struct {
Func string `json:"func"`
Source string `json:"source"`
Line int `json:"line"`
}

func replaceAttr(_ []string, a slog.Attr) slog.Attr {
switch a.Value.Kind() {
case slog.KindAny:
switch v := a.Value.Any().(type) {
case error:
a.Value = fmtErr(v)
}
}

return a
}

// marshalStack 从错误中提取堆栈帧
func marshalStack(err error) []stackFrame {
trace := xerrors.StackTrace(err)

if len(trace) == 0 {
return nil
}

frames := trace.Frames()

s := make([]stackFrame, len(frames))

for i, v := range frames {
f := stackFrame{
Source: filepath.Join(
filepath.Base(filepath.Dir(v.File)),
filepath.Base(v.File),
),
Func: filepath.Base(v.Function),
Line: v.Line,
}

s[i] = f
}

return s
}

// fmtErr 返回一个 slog.Value,其中包含键 `msg` 和 `trace`。如果错误没有实现
// interface { StackTrace() errors.StackTrace },则省略 `trace` 键。
func fmtErr(err error) slog.Value {
var groupValues []slog.Attr

groupValues = append(groupValues, slog.String("msg", err.Error()))

frames := marshalStack(err)

if frames != nil {
groupValues = append(groupValues,
slog.Any("trace", frames),
)
}

return slog.GroupValue(groupValues...)
}

func main() {
h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
ReplaceAttr: replaceAttr,
})

logger := slog.New(h)

ctx := context.Background()
err := xerrors.New("something happened")

logger.ErrorContext(ctx, "image uploaded", slog.Any("error", err))
}

有了这个设置,使用 xerrors.New() 创建的任何错误都将被记录为格式良好的堆栈跟踪,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
{
"time": "2024-01-03T07:09:31.013954119+01:00",
"level": "ERROR",
"msg": "image uploaded",
"error": {
"msg": "something happened",
"trace": [
{
"func": "main.main",
"source": "slog/main.go",
"line": 82
},
{
"func": "runtime.main",
"source": "runtime/proc.go",
"line": 267
},
{
"func": "runtime.goexit",
"source": "runtime/asm_amd64.s",
"line": 1650
}
]
}
}

现在您可以轻松追踪导致应用程序中任何意外错误的执行路径。

使用 LogValuer 接口隐藏敏感字段

该接口允许您通过指定自定义类型的日志记录方式来标准化日志输出。以下是其签名:

1
2
3
type LogValuer interface {
LogValue() Value
}

实现此接口的主要用例是隐藏自定义类型中的敏感字段。例如,这是一个未实现该接口的类型。注意当实例被记录时,敏感细节是如何暴露的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// User 没有实现 `LogValuer` 接口
type User struct {
ID string `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Email string `json:"email"`
Password string `json:"password"`
}

func main() {
handler := slog.NewJSONHandler(os.Stdout, nil)
logger := slog.New(handler)

u := &User{
ID: "user-12234",
FirstName: "Jan",
LastName: "Doe",
Email: "jan@example.com",
Password: "pass-12334",
}

logger.Info("info", "user", u)
}

输出:

1
2
3
4
5
6
7
8
9
10
11
12
{
"time": "2023-02-26T22:11:30.080656774+01:00",
"level": "INFO",
"msg": "info",
"user": {
"id": "user-12234",
"first_name": "Jan",
"last_name": "Doe",
"email": "jan@example.com",
"password": "pass-12334"
}
}

这是有问题的,因为该类型包含不应出现在日志中的秘密字段(如电子邮件和密码),还会使您的日志变得不必要地冗长。

您可以通过指定日志中要表示的类型来解决此问题。例如,您可以指定仅将 ID 字段记录如下:

1
2
3
4
// User 实现 LogValuer 接口
func (u *User) LogValue() slog.Value {
return slog.StringValue(u.ID)
}

您现在将观察到以下输出:

1
2
3
4
5
6
{
"time": "2023-02-26T22:43:28.184363059+01:00",
"level": "INFO",
"msg": "info",
"user": "user-12234"
}

您也可以像这样对多个属性进行分组:

1
2
3
4
5
6
func (u *User) LogValue() slog.Value {
return slog.GroupValue(
slog.String("id", u.ID),
slog.String("name", u.FirstName+" "+u.LastName),
)
}

输出:

1
2
3
4
5
6
7
8
9
{
"time": "2023-03-15T14:44:24.223381036+01:00",
"level": "INFO",
"msg": "info",
"user": {
"id": "user-12234",
"name": "Jan Doe"
}
}

使用 Slog 与第三方日志后端

Slog 的主要设计目标之一是为 Go 应用程序提供统一的日志前端(slog.Logger),而后端(slog.Handler)可以根据程序的不同进行定制。

这样一来,即使后端不同,日志记录 API 在所有依赖项中保持一致。这也避免了将日志记录实现与特定包耦合,因为在项目中要求更改时,可以轻松切换到不同的后端。

这是一个使用 Slog 前端和 Zap 后端的示例,可能会提供两全其美的效果:

1
2
go get go.uber.org/zap
go get go.uber.org/zap/exp/zapslog
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

import (
"log/slog"

"go.uber.org/zap"
"go.uber.org/zap/exp/zapslog"
)

func main() {
zapL := zap.Must(zap.NewProduction())

defer zapL.Sync()

logger := slog.New(zapslog.NewHandler(zapL.Core(), nil))

logger.Info(
"incoming request",
slog.String("method", "GET"),
slog.String("path", "/api/user"),
slog.Int("status", 200),
)
}

这段代码创建了一个新的 Zap 生产日志记录器,随后被用作 Slog 包的处理程序。有了这个,你只需要使用 slog.Logger 上提供的方法来编写日志,但生成的记录将根据提供的 zapL 配置进行处理。

输出:

1
{"level":"info","ts":1697453912.4535635,"msg":"incoming request","method":"GET","path":"/api/user","status":200}

切换到不同的日志记录非常简单,因为日志记录是根据 slog.Logger 完成的。例如,您可以像这样从 Zap 切换到 Zerolog

1
2
go get github.com/rs/zerolog
go get github.com/samber/slog-zerolog
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
"log/slog"
"os"

"github.com/rs/zerolog"
slogzerolog "github.com/samber/slog-zerolog"
)

func main() {
zerologL := zerolog.New(os.Stdout).Level(zerolog.InfoLevel)

logger := slog.New(
slogzerolog.Option{Logger: &zerologL}.NewZerologHandler(),
)

logger.Info(
"incoming request",
slog.String("method", "GET"),
slog.String("path", "/api/user"),
slog.Int("status", 200),
)
}

输出:

1
{"level":"info","time":"2023-10-16T13:22:33+02:00","method":"GET","path":"/api/user","status":200,"message":"incoming request"}

在上面的代码片段中,Zap 处理程序已被自定义的 Zerolog 处理程序替换。由于日志记录不是使用任何库的自定义 API 进行的,迁移过程只需要几分钟,而不是在整个应用程序中切换一个日志记录 API 到另一个的情况。

编写和存储 Go 日志的最佳实践

一旦您配置了 Slog 或您偏爱的第三方 Go 日志框架,就有必要采用以下最佳实践,以确保您充分利用应用程序日志:

1. 标准化您的日志接口

实现 LogValuer 接口可以使您标准化应用程序中各种类型的日志记录,确保它们在日志中的表示在整个应用程序中保持一致。这也是一种有效的策略,可以确保敏感字段不会出现在应用程序日志中,正如我们在本文中之前所探讨的那样。

2. 在错误日志中添加堆栈跟踪

为了提高您在生产环境中调试意外问题的能力,您应该在错误日志中添加堆栈跟踪。这样,就能更容易地确定错误在代码库中的起源位置以及导致问题的程序流程。

Slog 目前没有内置的方法来向错误添加堆栈跟踪,但正如我们之前所演示的,可以使用 pkgerrorsgo-xerrors 等包以及一些辅助函数来实现这个功能。

3. 对您的 Slog 语句进行检查,以确保一致性

Slog API 的主要缺点之一是它允许两种不同类型的参数,这可能导致代码库中的不一致性。除此之外,您还希望强制执行一致的键名约定(snake_casecamelCase 等),或者确定日志调用是否应始终包括上下文参数。

sloglint 这样的 linter 可以帮助您根据您喜欢的代码风格强制执行 Slog 的各种规则。以下是在通过 golangci-lint 使用时的示例配置:

.golangci.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
linters-settings:
sloglint:
# Enforce not mixing key-value pairs and attributes.
# Default: true
no-mixed-args: false
# Enforce using key-value pairs only (overrides no-mixed-args, incompatible with attr-only).
# Default: false
kv-only: true
# Enforce using attributes only (overrides no-mixed-args, incompatible with kv-only).
# Default: false
attr-only: true
# Enforce using methods that accept a context.
# Default: false
context-only: true
# Enforce using static values for log messages.
# Default: false
static-msg: true
# Enforce using constants instead of raw keys.
# Default: false
no-raw-keys: true
# Enforce a single key naming convention.
# Values: snake, kebab, camel, pascal
# Default: ""
key-naming-case: snake
# Enforce putting arguments on separate lines.
# Default: false
args-on-sep-lines: true

4. 集中管理日志,但首先将它们持久化到本地文件

通常最好将编写日志的任务与将其发送到集中式日志管理系统分离。首先将日志写入本地文件可确保在日志管理系统或网络出现问题时备份,防止关键数据的潜在丢失。比如存储到本地,然后通过阿里云的日志客户端上传到阿里云、又或者通过 Logstash 上传到 Elasticsearch

此外,在发送日志之前将其存储在本地有助于缓冲日志,从而实现批量传输,有助于优化网络带宽使用,并最大程度减少对应用程序性能的影响。

本地日志存储还提供了更大的灵活性,因此,如果需要转换到不同的日志管理系统,只需要在传输方法中进行修改,而不是整个应用程序日志记录机制。

5. 采样你的日志

日志抽样是仅记录日志条目的代表性子集的做法,而不是每个日志事件都记录。这种技术在高流量环境中非常有益,因为系统会产生大量的日志数据,处理每个条目可能会非常昂贵,因为集中式日志记录解决方案通常根据数据流入速度或存储数据量收费。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package main

import (
"fmt"
"log/slog"
"os"

slogmulti "github.com/samber/slog-multi"
slogsampling "github.com/samber/slog-sampling"
)

func main() {
// Will print 20% of entries.
option := slogsampling.UniformSamplingOption{
Rate: 0.2,
}

logger := slog.New(
slogmulti.
Pipe(option.NewMiddleware()).
Handler(slog.NewJSONHandler(os.Stdout, nil)),
)

for i := 1; i <= 10; i++ {
logger.Info(fmt.Sprintf("a message from the gods: %d", i))
}
}

输出:

1
2
{"time":"2023-10-18T19:14:09.820090798+02:00","level":"INFO","msg":"a message from the gods: 4"}
{"time":"2023-10-18T19:14:09.820117844+02:00","level":"INFO","msg":"a message from the gods: 5"}

6. 使用日志管理服务

将日志集中在日志管理系统中,可以轻松搜索、分析和监控应用程序在多个服务器和环境中的行为。所有日志都集中在一个地方,您可以更快速地识别和诊断问题,不再需要在不同服务器之间跳转以收集有关您的服务的信息。

目前我们使用的是阿里云的日志,但是它的前端性能很差,所以用起来体验较差,优点是部署简单,功能较全。你也可以使用 ElasticSearch 和 Kiabana 来搭建自己的日志系统,但是这个需要自己搭建,成本较高。

总结

在本文中,我们探讨了 Go 语言中日志记录的最佳实践,以及如何使用 Slog 包来实现它们。我们还讨论了如何使用 Slog 与第三方日志后端,以及如何使用 LogValuer 接口标准化日志输出。

Zap 是由 Uber 开发的专为 Go 应用程序设计的结构化日志记录包。根据它们在 GitHub 上的 README 文档,它提供了 “极快” 的结构化、分级日志记录,且分配资源最小。 这一说法得到了它们的基准测试结果的支持,这些结果表明 Zap 在性能上几乎优于 Go 的其他大部分可比较的结构化日志记录库,除了 Zerolog

开始使用 Zap

在开始使用Zap之前,您需要通过以下命令将其安装到您的项目中:

1
go get -u go.uber.org/zap

一旦你安装了 Zap,你可以像这样在你的程序中开始使用它:

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import (
"go.uber.org/zap"
)

func main() {
logger := zap.Must(zap.NewProduction())

defer logger.Sync()

logger.Info("Hello from Zap logger!")
}

输出:

1
{"level":"info","ts":1706518203.525694,"caller":"gopprof/main.go:12","msg":"Hello from Zap logger!"}

与 Go 的大多数其他日志记录包不同,Zap 不提供预先配置的全局日志记录器供直接使用。因此,在开始记录日志之前,您必须创建一个 zap.Logger 实例。 NewProduction() 方法返回一个 Logger,配置为以 JSON 格式记录到标准错误,并将其最低日志级别设置为 INFO

生成的输出相对简单,没有什么意外,除了其默认的时间戳格式(ts),它以自 1970年1月1日 UTC 起经过的纳秒数呈现,而不是典型的 ISO-8601 格式。

您还可以利用 NewDevelopment 来创建更适用于开发环境的日志记录,以 DEBUG 级别记录并使用更符合人类习惯的格式:

1
logger := zap.Must(zap.NewDevelopment())

输出:

1
2023-05-14T20:42:39.137+0100    INFO    zap/main.go:12  Hello from Zap logger!

您可以轻松地通过环境变量在开发和生产 Logger 之间进行切换:

1
2
3
4
logger := zap.Must(zap.NewProduction())
if os.Getenv("APP_ENV") == "development" {
logger = zap.Must(zap.NewDevelopment())
}

设置全局 logger

如果您想在不先创建实例的情况下编写日志,可以在 init() 函数中使用 ReplaceGlobals() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import (
"go.uber.org/zap"
)

func init() {
zap.ReplaceGlobals(zap.Must(zap.NewProduction()))
}

func main() {
zap.L().Info("Hello from Zap!")
}

这种方法将通过 zap.L() 访问的全局记录器替换为一个功能 Logger 实例,这样你就可以直接通过将 zap 包导入到你的文件中来使用它。

Zap 基本 API

Zap 提供了两个主要的日志 API。第一个是低级别的 Logger 类型,它提供了一种结构化的记录消息的方式。 它专为在性能敏感的环境中使用而设计,其中每个分配都很重要,它只支持强类型的上下文字段:

1
2
3
4
5
6
7
8
9
10
11
func main() {
logger := zap.Must(zap.NewProduction())

defer logger.Sync()

logger.Info("User logged in",
zap.String("username", "johndoe"),
zap.Int("userid", 123456),
zap.String("provider", "google"),
)
}

输出:

1
{"level":"info","ts":1706518317.8943732,"caller":"gopprof/main.go:12","msg":"User logged in","username":"johndoe","userid":123456,"provider":"google"}

该类型为每个支持的日志级别(Info()Warn()Error()等)公开了一个方法,并且每个方法都接受一个消息和零个或多个字段,这些字段是强类型的键/值对,如上例所示。

第二个高级 API 是 SugaredLogger 类型,它代表了一种更随意的日志记录方法。它的 API 比 Logger 类型更简洁,但性能略有损失。在底层,它依赖于 Logger 类型进行实际的日志记录操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func main() {
logger := zap.Must(zap.NewProduction())

defer logger.Sync()

sugar := logger.Sugar()

sugar.Info("Hello from Zap logger!")
sugar.Infoln(
"Hello from Zap logger!",
)
sugar.Infof(
"Hello from Zap logger! The time is %s",
time.Now().Format("03:04 AM"),
)

sugar.Infow("User logged in",
"username", "johndoe",
"userid", 123456,
zap.String("provider", "google"),
)
}

输出:

1
2
3
4
{"level":"info","ts":1684147807.960761,"caller":"zap/main.go:17","msg":"Hello from Zap logger!"}
{"level":"info","ts":1684147807.960845,"caller":"zap/main.go:18","msg":"Hello from Zap logger!"}
{"level":"info","ts":1684147807.960909,"caller":"zap/main.go:21","msg":"Hello from Zap logger! The time is 11:50 AM"}
{"level":"info","ts":1684148355.2692218,"caller":"zap/main.go:25","msg":"User logged in","username":"johndoe","userid":123456,"provider":"google"}

一个 Logger 可以通过调用它的 Sugar() 方法转换为 SugaredLogger 类型。相反,Desugar() 方法将 SugaredLogger 转换为 Logger ,您可以根据需要执行这些转换,因为性能开销可以忽略不计。

1
2
3
4
5
6
7
8
sugar := zap.Must(zap.NewProduction()).Sugar()

defer sugar.Sync()
sugar.Infow("Hello from SugaredLogger!")

logger := sugar.Desugar()

logger.Info("Hello from Logger!")

这个特性意味着你不必在你的代码库中选择其中一个。例如,你可以在通常情况下默认使用 SugaredLogger 以获得灵活性,然后在性能敏感代码的边界处转换为 Logger 类型。

SugaredLogger 类型为每个支持的级别提供了四种方法:

  1. 第一个(Info()Error(),等等)与 Logger 上的 level 方法同名,但它接受一个或多个 any 类型的参数。在内部,它们使用 fmt.Sprint() 方法将参数连接到输出的 msg 属性中。

  2. ln 结尾的方法(如 Infoln()Errorln())与第一个方法相同,只是使用 fmt.Sprintln() 来构建和记录消息。

  3. f 结尾的方法使用 fmt.Sprintf() 方法来构建和记录一个模板化的消息。

  4. 最后,以 w 结尾的方法允许您向日志记录中添加强类型和弱类型的键值对混合。日志消息是第一个参数;随后的参数应该按照上面的示例以键值对的形式提供。

使用松散类型的键/值对时需要注意的一点是,键始终预期为字符串,而值可以是任何类型。如果使用非字符串键,程序将在开发中出现恐慌:

1
sugar.Infow("User logged in", 1234, "userID")
1
2
3
4
5
6
7
8
2023-05-15T12:06:12.996+0100    ERROR   zap@v1.24.0/sugar.go:210        Ignored key-value pairs with non-string keys.   {"invalid": [{"position": 0, "key": 1234, "value": "userID"}]}
go.uber.org/zap.(*SugaredLogger).Infow
/home/ayo/go/pkg/mod/go.uber.org/zap@v1.24.0/sugar.go:210
main.main
/home/ayo/dev/demo/zap/main.go:14
runtime.main
/usr/local/go/src/runtime/proc.go:250
2023-05-15T12:06:12.996+0100 INFO zap/main.go:14 User logged in

在生产环境中,会记录一个单独的错误,并跳过键/值对:

1
2
{"level":"error","ts":1684148883.086758,"caller":"zap@v1.24.0/sugar.go:210","msg":"Ignored key-value pairs with non-string keys.","invalid":[{"position":0,"key":1234,"value":"userID"}],"stacktrace":"go.uber.org/zap.(*SugaredLogger).Infow\n\t/home/ayo/go/pkg/mod/go.uber.org/zap@v1.24.0/sugar.go:210\nmain.main\n\t/home/ayo/dev/demo/zap/main.go:14\nruntime.main\n\t/usr/local/go/src/runtime/proc.go:250"}
{"level":"info","ts":1684148883.0867138,"caller":"zap/main.go:14","msg":"User logged in"}

传递一个孤立的键(没有对应的值)的行为类似:在开发中会引发恐慌,在生产中会产生错误。由于这些松散类型的键/值对存在所有这些警告,我们建议始终使用强类型的上下文字段,无论您是否使用 LoggerSugaredLogger

Zap 中的日志级别

Zap 提供以下日志级别,按严重程度递增。每个级别都与相应的整数关联:

  • DEBUG (-1): 用于记录调试消息的有用信息。
  • INFO (0): 用于描述正常应用程序操作的消息。
  • WARN (1): 用于记录指示发生了可能需要注意的异常情况的消息,以防升级为更严重的问题。
  • ERROR (2): 用于记录程序中的意外错误条件。
  • DPANIC (3): 用于记录开发中的严重错误条件。它在开发中的行为类似于 PANIC ,在生产中的行为类似于 ERROR
  • PANIC (4): 在记录错误条件后调用 panic()
  • FATAL (5): 在记录错误条件后调用 os.Exit(1)

这些级别在 zapcore 包中定义,该包定义并实现了 Zap 构建的底层接口。

值得注意的是,没有 TRACE 级别,也没有办法向记录器添加自定义级别,这可能会成为一些人的瓶颈。 如前所述,生产记录器的默认日志级别是 INFO 。如果您希望修改此设置,必须创建一个自定义记录器,我们将在接下来的部分详细介绍。

创建自定义记录器(logger)

到目前为止,我们已经展示了如何通过 Zap 提供的生产和开发预设来使用默认配置。现在让我们来看看如何使用自定义配置选项创建一个实例。

使用Zap创建自定义 Logger 有两种主要方法。第一种方法是使用其 Config 类型来构建自定义记录器,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package main

import (
"os"

"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)

func createLogger() *zap.Logger {
encoderCfg := zap.NewProductionEncoderConfig()
encoderCfg.TimeKey = "timestamp"
encoderCfg.EncodeTime = zapcore.ISO8601TimeEncoder

config := zap.Config{
Level: zap.NewAtomicLevelAt(zap.InfoLevel),
Development: false,
DisableCaller: false,
DisableStacktrace: false,
Sampling: nil,
Encoding: "json",
EncoderConfig: encoderCfg,
OutputPaths: []string{
"stderr",
},
ErrorOutputPaths: []string{
"stderr",
},
InitialFields: map[string]interface{}{
"pid": os.Getpid(),
},
}

return zap.Must(config.Build())
}

func main() {
logger := createLogger()

defer logger.Sync()

logger.Info("Hello from Zap!")
}

输出:

1
{"level":"info","timestamp":"2023-05-15T12:40:16.647+0100","caller":"zap/main.go:42","msg":"Hello from Zap!","pid":2329946}

上面的 createLogger() 函数返回一个新的 zap.Logger,它的功能类似于 NewProduction() Logger,但有一些不同之处。 我们将 Zap 的生产配置作为我们自定义日志记录器的基础,通过调用 NewProductionEncoderConfig() 并稍微修改它,将 ts 字段更改为 timestamp ,并将时间格式更改为 ISO-8601zapcore 包公开了 Zap 构建在其上的接口,以便您可以自定义和扩展其功能。

Config 对象包含创建新 Logger 时所需的许多常见配置选项。每个字段代表的详细描述都在项目文档中,因此我们在这里不会重复列举它们,除了一些特殊情况:

  • OutputPaths 指定一个或多个日志输出的目标(详见 Open 以获取更多详情)。
  • ErrorOutputPaths 类似于 OutputPaths ,但仅用于 Zap 内部错误,而不是由您的应用程序生成或记录的错误(例如由不匹配的松散类型键/值对引起的错误)。
  • InitialFields 指定了应该包含在从 Config 对象创建的每个记录器产生的每个日志条目中的全局上下文字段。我们这里只包括程序的进程 ID,但您可以添加其他有用的全局元数据,比如运行程序的 Go 版本、git 提交哈希或应用程序版本、环境或部署信息等。

一旦您设置了首选配置设置,必须调用 Build() 方法来生成 Logger。请查看 Configzapcore.EncoderConfig 的文档,了解所有可用选项。

创建自定义记录器的第二种更高级的方法涉及使用 zap.New() 方法。它接受一个 zapcore.Core 接口和零个或多个选项来配置 Logger 。以下是一个示例,同时将彩色输出记录到控制台,并以 JSON 格式记录到文件中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
func createLogger() *zap.Logger {
stdout := zapcore.AddSync(os.Stdout)

file := zapcore.AddSync(&lumberjack.Logger{
Filename: "logs/app.log",
MaxSize: 10, // megabytes
MaxBackups: 3,
MaxAge: 7, // days
})

level := zap.NewAtomicLevelAt(zap.InfoLevel)

productionCfg := zap.NewProductionEncoderConfig()
productionCfg.TimeKey = "timestamp"
productionCfg.EncodeTime = zapcore.ISO8601TimeEncoder

developmentCfg := zap.NewDevelopmentEncoderConfig()
developmentCfg.EncodeLevel = zapcore.CapitalColorLevelEncoder

consoleEncoder := zapcore.NewConsoleEncoder(developmentCfg)
fileEncoder := zapcore.NewJSONEncoder(productionCfg)

core := zapcore.NewTee(
zapcore.NewCore(consoleEncoder, stdout, level),
zapcore.NewCore(fileEncoder, file, level),
)

return zap.New(core)
}

func main() {
logger := createLogger()

defer logger.Sync()

logger.Info("Hello from Zap!")
}
1
2023-05-15T16:15:05.466+0100    INFO    Hello from Zap!
1
{"level":"info","timestamp":"2023-05-15T16:15:05.466+0100","msg":"Hello from Zap!"}

这个示例使用 Lumberjack 包自动旋转日志文件,以防它们变得过大。NewTee()方法将日志条目复制到两个或更多目的地。 在这种情况下,日志以带颜色的纯文本格式发送到标准输出,而 JSON 等效内容发送到logs/app.log文件。

顺便说一句,我们通常建议使用像 Logrotate 这样的外部工具来管理和轮转日志文件,而不是在应用程序本身中进行操作。

为您的日志添加上下文

如前所述,使用 Zap 进行上下文日志记录是通过在日志消息后传递强类型的键值对来完成的,就像这样:

1
2
3
4
5
logger.Warn("User account is nearing the storage limit",
zap.String("username", "john.doe"),
zap.Float64("storageUsed", 4.5),
zap.Float64("storageLimit", 5.0),
)
1
{"level":"warn","ts":1684166023.952419,"caller":"zap/main.go:46","msg":"User account is nearing the storage limit","username":"john.doe","storageUsed":4.5,"storageLimit":5}

使用子记录器,您还可以向特定范围内产生的所有日志添加上下文属性。这有助于避免在日志点处不必要的重复。子记录器是使用 With() 上的 Logger 方法创建的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func main() {
logger := zap.Must(zap.NewProduction())

defer logger.Sync()

childLogger := logger.With(
zap.String("service", "userService"),
zap.String("requestID", "abc123"),
)

childLogger.Info("user registration successful",
zap.String("username", "john.doe"),
zap.String("email", "john@example.com"),
)

childLogger.Info("redirecting user to admin dashboard")
}

注意两个日志中都存在 servicerequestID

1
2
{"level":"info","ts":1684164941.7644951,"caller":"zap/main.go:52","msg":"user registration successful","service":"userService","requestID":"abc123","username":"john.doe","email":"john@example.com"}
{"level":"info","ts":1684164941.764551,"caller":"zap/main.go:57","msg":"redirecting user to admin dashboard","service":"userService","requestID":"abc123"}

您可以使用相同的方法向所有日志添加全局元数据。例如,您可以像这样做,将程序的进程ID和编译程序所使用的 Go 版本包含在所有记录中:

1
2
3
4
5
6
7
8
9
10
func createLogger() *zap.Logger {
// . . .
buildInfo, _ := debug.ReadBuildInfo()

return zap.New(samplingCore.With([]zapcore.Field{
zap.String("go_version", buildInfo.GoVersion),
zap.Int("pid", os.Getpid()),
},
))
}

使用 Zap 记录错误

错误是最重要的日志记录目标之一,因此在采用框架之前了解框架如何处理错误是至关重要的。在 Zap 中,您可以使用 Error() 方法记录错误。如果使用 zap.Error() 方法,则输出中还包括 stacktraceerror 属性:

1
2
3
4
5
6
logger.Error("Failed to perform an operation",
zap.String("operation", "someOperation"),
zap.Error(errors.New("something happened")), // the key will be `error` here
zap.Int("retryAttempts", 3),
zap.String("user", "john.doe"),
)

输出:

1
{"level":"error","ts":1684164638.0570025,"caller":"zap/main.go:47","msg":"Failed to perform an operation","operation":"someOperation","error":"something happened","retryAttempts":3,"user":"john.doe","stacktrace":"main.main\n\t/home/ayo/dev/demo/zap/main.go:47\nruntime.main\n\t/usr/local/go/src/runtime/proc.go:250"}

对于更严重的错误,可使用 Fatal() 方法。在写入和刷新日志消息后,它会调用 os.Exit(1)

1
2
3
4
5
logger.Fatal("Something went terribly wrong",
zap.String("context", "main"),
zap.Int("code", 500),
zap.Error(errors.New("An error occurred")),
)

输出:

1
2
{"level":"fatal","ts":1684170760.2103574,"caller":"zap/main.go:47","msg":"Something went terribly wrong","context":"main","code":500,"error":"An error occurred","stacktrace":"main.main\n\t/home/ayo/dev/demo/zap/main.go:47\nruntime.main\n\t/usr/local/go/src/runtime/proc.go:250"}
exit status 1

如果错误是可恢复的,您可以使用 Panic() 方法。它记录在 PANIC 级别,并调用 panic() 而不是 os.Exit(1)。 还有一个 DPanic() 级别,只在开发中记录在 DPANIC 级别后才会引发恐慌。在生产环境中,它会在 DPANIC 级别记录,而不会实际引发恐慌。

如果您不想使用非标准级别,比如 PANICDPANIC ,您可以使用以下代码将两种方法都配置为记录在 ERROR 级别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
func lowerCaseLevelEncoder(
level zapcore.Level,
enc zapcore.PrimitiveArrayEncoder,
) {
if level == zap.PanicLevel || level == zap.DPanicLevel {
enc.AppendString("error")
return
}

zapcore.LowercaseLevelEncoder(level, enc)
}

func createLogger() *zap.Logger {
stdout := zapcore.AddSync(os.Stdout)

level := zap.NewAtomicLevelAt(zap.InfoLevel)

productionCfg := zap.NewProductionEncoderConfig()
productionCfg.TimeKey = "timestamp"
productionCfg.EncodeTime = zapcore.ISO8601TimeEncoder
productionCfg.EncodeLevel = lowerCaseLevelEncoder

jsonEncoder := zapcore.NewJSONEncoder(productionCfg)

core := zapcore.NewCore(jsonEncoder, stdout, level)

return zap.New(core)
}

func main() {
logger := createLogger()

defer logger.Sync()

logger.DPanic(
"this was never supposed to happen",
)
}

输出:

1
{"level":"error","timestamp":"2023-05-15T18:55:33.534+0100","msg":"this was never supposed to happen"}

使用 Zap 进行日志采样

日志抽样是一种技术,通过选择性地捕获和记录日志事件的子集来减少应用程序日志量。其目的是在需要全面记录日志和记录过多数据可能带来的潜在性能影响之间取得平衡。

与捕获每个日志事件不同,日志抽样允许您根据特定标准或规则选择代表性的日志消息子集。这样可以大大减少生成的日志数据量,在高吞吐量系统中尤其有益。

在Zap中,可以通过使用 zapcore.NewSamplerWithOptions() 方法来配置采样,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func createLogger() *zap.Logger {
stdout := zapcore.AddSync(os.Stdout)

level := zap.NewAtomicLevelAt(zap.InfoLevel)

productionCfg := zap.NewProductionEncoderConfig()
productionCfg.TimeKey = "timestamp"
productionCfg.EncodeTime = zapcore.ISO8601TimeEncoder
productionCfg.EncodeLevel = lowerCaseLevelEncoder
productionCfg.StacktraceKey = "stack"

jsonEncoder := zapcore.NewJSONEncoder(productionCfg)

jsonOutCore := zapcore.NewCore(jsonEncoder, stdout, level)

samplingCore := zapcore.NewSamplerWithOptions(
jsonOutCore,
time.Second, // interval
3, // log first 3 entries
0, // thereafter log zero entires within the interval
)

return zap.New(samplingCore)
}

通过记录在指定时间间隔内具有特定级别和消息的前N个条目来采样 Zap 样本。在上述示例中,仅在一秒间隔内记录具有相同级别和消息的前 3 个日志条目。由于此处指定了 0 ,在该间隔内将丢弃每个其他日志条目。

你可以通过登录一个 for 循环来测试这个:

1
2
3
4
5
6
7
8
9
10
func main() {
logger := createLogger()

defer logger.Sync()

for i := 1; i <= 10; i++ {
logger.Info("an info message")
logger.Warn("a warning")
}
}

因此,您应该只看到六个日志条目,而不是观察 20 个。

1
2
3
4
5
6
{"level":"info","timestamp":"2023-05-17T16:00:17.611+0100","msg":"an info message"}
{"level":"warn","timestamp":"2023-05-17T16:00:17.611+0100","msg":"a warning"}
{"level":"info","timestamp":"2023-05-17T16:00:17.611+0100","msg":"an info message"}
{"level":"warn","timestamp":"2023-05-17T16:00:17.611+0100","msg":"a warning"}
{"level":"info","timestamp":"2023-05-17T16:00:17.611+0100","msg":"an info message"}
{"level":"warn","timestamp":"2023-05-17T16:00:17.611+0100","msg":"a warning"}

在这里,只有循环的前三次迭代产生了一些输出。这是因为在其他七次迭代中产生的日志由于采样配置而被丢弃。同样,当类似条目由于负载过重或应用程序出现一连串错误而被记录多次时,Zap 会删除重复条目。

尽管日志抽样可以减少日志量和日志记录的性能影响,但也可能导致一些日志事件被忽略,从而影响故障排除和调试工作。因此,在仔细考虑特定应用程序的要求之后,才应该应用抽样。

在日志中隐藏敏感细节

一种防止意外记录具有敏感字段的类型的技术是在记录点对数据进行编辑或掩码处理。在 Zap 中,可以通过实现 Stringer 接口,然后定义在记录类型时应返回的确切字符串来实现此目的。以下是一个简短的演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
type User struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}

func main() {
logger := createLogger()

defer logger.Sync()

user := User{
ID: "USR-12345",
Name: "John Doe",
Email: "john.doe@example.com",
}

logger.Info("user login", zap.Any("user", user))
}

输出:

1
{"level":"info","timestamp":"2023-05-17T17:00:59.899+0100","msg":"user login","user":{"id":"USR-12345","name":"John Doe","email":"john.doe@example.com"}}

在这个例子中,整个 user 都被记录下来,不必要地暴露了用户的电子邮件地址。您可以通过以下方式实现 Stringer 接口来防止这种情况发生:

1
2
3
func (u User) String() string {
return u.ID
}

这将在日志中用 ID 字段完全替换 User 类型:

1
{"level":"info","timestamp":"2023-05-17T17:05:01.081+0100","msg":"user login","user":"USR-12345"}

如果您需要更多控制,可以创建自己的 zapcore.Encoder,并将 JSON 编码器用作基础,同时过滤掉敏感字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
type SensitiveFieldEncoder struct {
zapcore.Encoder
cfg zapcore.EncoderConfig
}

// EncodeEntry is called for every log line to be emitted so it needs to be
// as efficient as possible so that you don't negate the speed/memory advantages
// of Zap
func (e *SensitiveFieldEncoder) EncodeEntry(
entry zapcore.Entry,
fields []zapcore.Field,
) (*buffer.Buffer, error) {
filtered := make([]zapcore.Field, 0, len(fields))

for _, field := range fields {
user, ok := field.Interface.(User)
if ok {
user.Email = "[REDACTED]"
field.Interface = user
}

filtered = append(filtered, field)
}

return e.Encoder.EncodeEntry(entry, filtered)
}

func NewSensitiveFieldsEncoder(config zapcore.EncoderConfig) zapcore.Encoder {
encoder := zapcore.NewJSONEncoder(config)
return &SensitiveFieldEncoder{encoder, config}
}

func createLogger() *zap.Logger {
. . .

jsonEncoder := NewSensitiveFieldsEncoder(productionCfg)

. . .

return zap.New(samplingCore)
}

这段代码确保 email 属性被编辑,而其他字段保持不变:

1
{"level":"info","timestamp":"2023-05-17T17:38:11.749+0100","msg":"user login","user":{"id":"USR-12345","name":"John Doe","email":"[REDACTED]"}}

当然,如果 User 类型被记录在不同的键下,比如 user_details ,这样做就不会有太大帮助。您可以移除 if field.Key == "user" 条件,以确保无论提供的键是什么,都会执行编辑。

自定义编码器的一些注意事项

在使用Zap的自定义编码时,就像在前一节中一样,您可能还需要在 zapcore.Encoder 接口上实现 Clone() 方法,以便它也适用于使用 With() 方法创建的子记录器:

1
2
child := logger.With(zap.String("name", "main"))
child.Info("an info log", zap.Any("user", u))

在实施 Clone() 之前,您会注意到自定义 EncodeEntry() 不会为子记录器执行,导致电子邮件字段显示为未编辑状态:

1
{"level":"info","timestamp":"2023-05-20T09:14:46.043+0100","msg":"an info log","name":"main","user":{"id":"USR-12345","name":"John Doe","email":"john.doe@example.com"}}

With() 用于创建子记录器时,将执行配置的 Encoder 上的 Clone() 方法来复制它,并确保添加的字段不会影响原始记录器。 如果在自定义编码器类型上未实现此方法,则将调用嵌入的 zapcore.Encoder(在本例中为JSON编码器)上声明的 Clone() 方法,这意味着子记录器将不使用您的自定义编码。

您可以通过以下方式实现 Clone() 方法来纠正这种情况:

1
2
3
4
5
func (e *SensitiveFieldEncoder) Clone() zapcore.Encoder {
return &SensitiveFieldEncoder{
Encoder: e.Encoder.Clone(),
}
}

您现在将观察到正确的已编辑输出:

1
{"level":"info","timestamp":"2023-05-20T09:28:31.231+0100","msg":"an info log","name":"main","user":{"id":"USR-12345","name":"John Doe","email":"[REDACTED]"}}

然而,请注意,自定义编码器不会影响使用 With() 方法附加的字段,因此如果您这样做:

1
2
child := logger.With(zap.String("name", "main"), zap.Any("user", u))
child.Info("an info log")

无论是否实现 Clone(),您都将获得先前的未编辑输出,因为 EncodeEntry() 的参数中只有在日志点添加的字段存在:

1
{"level":"info","timestamp":"2023-05-20T09:31:11.919+0100","msg":"an info log","name":"main","user":{"id":"USR-12345","name":"John Doe","email":"john.doe@example.com"}}

将 Zap 用作 Slog 的后端

Go 语言引入了新的结构化日志包Slog后,开始着手在 Zap 中实现 slog.Handler 接口,以便利用 Slog APIZap 后端。这种集成确保了在各种依赖项中日志 API 的一致性,并便于无需大幅更改代码即可无缝切换日志包。

目前为止,Slog 尚未包含在官方的 Go 发布版本中。因此,ZapSlog 的官方集成已经提供在一个单独的模块中,可以使用以下命令进行安装:

1
go get go.uber.org/zap/exp/zapslog

之后,您可以在您的程序中像这样使用它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
func main() {
logger := zap.Must(zap.NewProduction())

defer logger.Sync()

sl := slog.New(zapslog.NewHandler(zapL.Core(), nil))

sl.Info(
"incoming request",
slog.String("method", "GET"),
slog.String("path", "/api/user"),
slog.Int("status", 200),
)
}

输出:

1
{"level":"info","ts":1684613929.8395753,"msg":"incoming request","method":"GET","path":"/api/user","status":200}

如果您决定切换到不同的后端,唯一需要更改的是 slog.New() 方法的参数。例如,您可以通过进行以下更改从 Zap 切换到 SlogJSONHandler 后端:

1
2
3
4
5
6
7
8
9
10
func main() {
sl := slog.New(slog.NewJSONHandler(os.Stdout, nil))

sl.Info(
"incoming request",
slog.String("method", "GET"),
slog.String("path", "/api/user"),
slog.Int("status", 200),
)
}

除了日志输出可能会根据您的配置略有不同之外,其他一切都应该继续正常工作。

1
{"time":"2023-05-20T21:21:43.335894635+01:00","level":"INFO","msg":"incoming request","method":"GET","path":"/api/user","status":200}

总结

本文分析了 Zap 包,这是 Go 程序中最受欢迎的日志包之一。文章重点介绍了该包的许多关键特性,还涵盖了一些高级日志技术,以及如何将其与新的标准库 Slog 包集成。