0%

在实际的工作中,我们很多时候开发环境跟应用程序最终运行的环境是不同的操作系统,比如在 Windows 上进行开发,但是应用程序最终是要在 Linux 上运行的, 又或者是在 mac 下开发,在 Linux 下运行。这个时候我们就需要进行交叉编译,即在一个操作系统上编译出另一个操作系统的可执行文件。

使用 Go 的时候,我们可以很方便的进行交叉编译,只需要设置好环境变量或者设置构建标签即可,本文会通过一个简单的例子来演示如何进行交叉编译。

GOOS 和 GOARCH 环境变量所有可能的值

在 Go 语言中,我们可以通过设置环境变量 GOOSGOARCH 来指定目标操作系统和目标架构。 比如在我的系统上,查看 GOOSGOARCH 的值:

1
2
3
➜ go env GOOS GOARCH
darwin
amd64

在 Go 编译的时候,默认的 GOOSGOARCH 的值是当前系统的操作系统和架构,比如在我的系统上,GOOS 的值是 darwinGOARCH 的值是 amd64。 所以编译出来的就是当前系统可以执行的二进制文件,如果我们想要编译出其他系统的二进制文件,就需要设置 GOOSGOARCH 的值。

首先,我们需要了解这两个环境变量支持哪些值。下面是所有可能的值,我们可以通过 go tool dist list 列出来:

1
2
3
4
5
6
7
8
9
10
11
aix/ppc64        freebsd/amd64   linux/mipsle   openbsd/386
android/386 freebsd/arm linux/ppc64 openbsd/amd64
android/amd64 illumos/amd64 linux/ppc64le openbsd/arm
android/arm js/wasm linux/s390x openbsd/arm64
android/arm64 linux/386 nacl/386 plan9/386
darwin/386 linux/amd64 nacl/amd64p32 plan9/amd64
darwin/amd64 linux/arm nacl/arm plan9/arm
darwin/arm linux/arm64 netbsd/386 solaris/amd64
darwin/arm64 linux/mips netbsd/amd64 windows/386
dragonfly/amd64 linux/mips64 netbsd/arm windows/amd64
freebsd/386 linux/mips64le netbsd/arm64 windows/arm

在上面的输出中,/ 前面操作系统,/ 后面是架构。以 linux/386 为例,键值对以 GOOS 开始,在本例中将是 linux ,指的是 Linux 操作系统。这里的 GOARCH 将是 386 ,代表 Intel 80386 微处理器。

我们发现其实 Go 支持很多操作系统和架构,但是大多数情况下,你最终会使用 linuxwindowsdarwin 中的一个作为 GOOS 的值,这涵盖了三大操作系统平台:Linux、Windows 和 macOS。

使用文件名后缀实现交叉编译

使用场景:不同操作系统需要通过不同代码来实现。

Go 标准库中 path/filepath 包中的 Join 函数,在不同平台下会有不同的效果。该函数接受一些字符串,并返回一个使用正确文件路径分隔符连接在一起的字符串。

这是一个很好的示例程序,因为程序的操作取决于它运行的操作系统。在 Windows 上,路径分隔符是反斜杠 \,而 Unix 系统使用正斜杠 /

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

import (
"fmt"
"path/filepath"
)

func main() {
s := filepath.Join("a", "b", "c")
fmt.Println(s)
}

这个程序在 Windows 上运行时,将输出 a\b\c,而在 Unix 系统上运行时,将输出 a/b/c

这是如何实现的呢?这就涉及到了 Go 中实现交叉编译的其中一种方式,就是指定文件名后缀, 我们看 Go 的源码或者一些开源项目的源码,就会发现有些文件的文件名带了操作系统的后缀,比如 file_windows.gofile_linux.gofile_darwin.go 等等。

同样的,path/filepath 包中的 Join 函数也是这样实现的,我们可以看到 path/filepath 包中有很多文件,比如 path_windows.gopath_unix.go 等等,其中:

  • path_windows.go 中实现了 Join 函数在 Windows 上的实现
  • path_unix.go 中实现了 Join 函数在 Unix 系统上的实现

我们点开 path_unix.go 文件,可以看到如下的代码:

1
2
3
4
5
// ...
const (
PathSeparator = '/' // OS-specific path separator
PathListSeparator = ':' // OS-specific path list separator
)

也就是说,Join 函数的路径分隔符是在这里通过 PathSeparator 定义成 / 的,而在 path_windows.go 文件中,PathSeparator 是定义成 \ 的。

1
2
3
4
5
6
// path_windows.go
// ...
const (
PathSeparator = '\\' // OS-specific path separator
PathListSeparator = ';' // OS-specific path list separator
)

有两个 \ 是因为需要转义。

同时在文件名中加上 GOARCH 后缀

在命名文件时,您可以按照顺序将 GOOSGOARCH 添加为文件名的后缀,用下划线(_)分隔这些值。如果您有一个名为 filename.go 的 Go 文件,您可以通过将文件名更改为 filename_GOOS_GOARCH.go 来指定操作系统和架构。例如,如果您希望将其编译为具有 64 位 ARM 架构的 Windows 文件,您将文件名更改为 filename_windows_arm64.go 。这种命名约定有助于保持代码整洁有序。

在我们编译的时候,如果我们当前的 GOOSGOARCH 跟文件名不匹配,则 Go 会忽略这个文件。

使用构建标签实现交叉编译

使用场景:不同操作系统需要使用不同代码。(跟上一个类似)

除了指定文件名后缀以外,我们还可以使用构建标签来实现交叉编译。具体来说,就是在文件的第一行添加 // +build 标签,比如:

1
2
3
4
5
// +build windows

package main

const PathSeparator = "\\"

这样的话,这个文件就只会在 Windows 上编译,而在其他系统上不会编译。

使用你本地 GOOS 和 GOARCH 的值进行交叉编译

使用场景:在本地开发环境编译出其他系统的可执行文件。

之前,您运行了 go env GOOS GOARCH 命令来查看您正在使用的操作系统和架构。当您运行 go env 命令时,它会查找两个环境变量 GOOSGOARCH;如果找到,它们的值将被使用,但如果未找到,则 Go 将使用当前平台的信息来设置它们。这意味着您可以更改 GOOSGOARCH,以便它们不会默认为您的本地操作系统和架构。这样就可以编译出其他平台的可执行文件。

go build 命令的行为方式类似于 go env 命令。您可以使用 go build 设置 GOOSGOARCH 环境变量以构建不同平台的应用程序。

如果您没有使用 Windows 系统,请在运行 go build 命令时将 GOOS 环境变量设置为 windows

1
GOOS=windows go build

你也可以同时设置 GOARCH 环境变量:

1
GOOS=linux GOARCH=amd64 go build

这将编译出一个 Linux 平台上的 64 位可执行文件,我们如果使用的是 macOS,我们可以通过 file 命令查看编译出来的文件的信息:

1
2
file main
main: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, Go BuildID=xx, with debug_info, not stripped

我们会看到这是一个 64 位的 ELF 可执行文件,而 ELF 是 Linux 下的可执行文件格式。

更加现代化的交叉编译方式

我们前面讲了很多如何进行交叉编译,但是如果我们每次都需要针对不同平台来手动编译,未免过于麻烦,当然我们可以写一个脚本来自动化这个过程。

这一小节,我将介绍一个比较好用的交叉编译工具 goreleaser,我们只需要简单的配置一下,它就可以帮我们自动化交叉编译的过程。 比如 frp 这个开源项目就是使用 goreleaser 来进行发布新版本的。

下面是一个示例配置文件:

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
# .goreleaser.yml,放在项目根目录下
# 项目名称
project_name: goss
# 在执行前需要执行的命令
before:
hooks:
- go mod tidy
# 编译配置
builds:
- env:
# 可以指定环境变量
- CGO_ENABLED=0
goos: # 需要编译的操作系统
- linux
- windows
- darwin
archives:
- replacements: # 将 GOARCH 替换,因为用户更熟悉 x86_64
386: i386
amd64: x86_64
checksum:
name_template: 'checksums.txt'
snapshot:
name_template: "{{ incpatch .Version }}"
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'

说明:

  • project_name 是项目的名称
  • before 是在执行前需要执行的命令
  • builds 是编译配置,env 是环境变量,goos 是需要编译的操作系统
  • archives 是归档配置,replacements 是将 GOARCH 替换。
  • checksum 是生成 checksum 的配置

接着我们只需要执行 goreleaser build 命令即可进行编译:

goreleaser 的安装方式可参考它的官网。

1
goreleaser build

输出:

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
• starting build...
• loading config file file=.goreleaser.yaml
• loading environment variables
• getting and validating git state
• building... commit=e913b9258e649f8f2784d9daaebbf3a4d7cf7a17 latest tag=v0.0.9
• parsing tag
• setting defaults
• running before hooks
• running hook=go mod tidy
• running hook=go generate ./...
• checking distribution directory
• loading go mod information
• build prerequisites
• writing effective config file
• writing config=dist/config.yaml
• generating changelog
• writing changelog=dist/CHANGELOG.md
• building binaries
• building binary=dist/goss_windows_amd64_v1/goss.exe
• building binary=dist/goss_darwin_arm64/goss
• building binary=dist/goss_windows_arm64/goss.exe
• building binary=dist/goss_darwin_amd64_v1/goss
• building binary=dist/goss_linux_amd64_v1/goss
• building binary=dist/goss_linux_arm64/goss
• building binary=dist/goss_windows_386/goss.exe
• building binary=dist/goss_linux_386/goss
• took: 39s
• storing release metadata
• writing file=dist/artifacts.json
• writing file=dist/metadata.json
• build succeeded after 39s

编译完成后,我们会在 dist 目录下看到编译好的文件:

1
ls -l dist/

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
total 32
-rw-r--r-- 1 ruby staff 36 Feb 3 11:12 CHANGELOG.md
-rw-r--r-- 1 ruby staff 1803 Feb 3 11:13 artifacts.json
-rw-r--r-- 1 ruby staff 3509 Feb 3 11:12 config.yaml
drwxr-xr-x 3 ruby staff 96 Feb 3 11:13 goss_darwin_amd64_v1
drwxr-xr-x 3 ruby staff 96 Feb 3 11:13 goss_darwin_arm64
drwxr-xr-x 3 ruby staff 96 Feb 3 11:13 goss_linux_386
drwxr-xr-x 3 ruby staff 96 Feb 3 11:13 goss_linux_amd64_v1
drwxr-xr-x 3 ruby staff 96 Feb 3 11:13 goss_linux_arm64
drwxr-xr-x 3 ruby staff 96 Feb 3 11:13 goss_windows_386
drwxr-xr-x 3 ruby staff 96 Feb 3 11:13 goss_windows_amd64_v1
drwxr-xr-x 3 ruby staff 96 Feb 3 11:13 goss_windows_arm64
-rw-r--r-- 1 ruby staff 219 Feb 3 11:13 metadata.json

接着,我们就可以来发布这些二进制文件了。如果我们有其他个性化的需求,我们可以通过修改 .goreleaser.yml 文件来满足我们的需求。它还有很多配置可以自定义。 如果后续我们需要调整,只需要修改一下配置文件就行了,比如我们需要支持一个新的操作系统,只需要在 goos 下面增加一个新的操作系统即可。

使用 goreleaser 进行交叉编译的好处是,它会自动帮我们打包、生成 checksum、生成 changelog 等等,省去了很多手动操作。

另外,它还支持直接发布到 Github,使用 Github Actions 来自动化这个过程,这样我们只需要 push 代码,就可以自动进行编译、打包、发布。

下面是一个 github workflow 的示例配置文件:

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
name: goreleaser

on:
push:
# run only against tags
tags:
- '*'

permissions:
contents: write
# packages: write
# issues: write

jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
-
name: Fetch all tags
run: git fetch --force --tags
-
name: Set up Go
uses: actions/setup-go@v2
with:
go-version: 1.19
-
name: Run GoReleaser
uses: goreleaser/goreleaser-action@v2
with:
distribution: goreleaser
version: latest
args: release --rm-dist
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

这个配置文件的意思是,当我们 push tag 的时候,就会触发这个 workflow,它会自动运行 goreleaser,然后进行编译、打包、发布。

总结

Go 支持我们很方便的进行交叉编译,只需要设置好环境变量或者设置构建标签即可:

  • 环境变量:GOOSGOARCH
  • 文件名后缀:filename_GOOS_GOARCH.go
  • 构建标签:// +build 标签

另外,我们还可以使用 goreleaser 这个工具来自动化交叉编译的过程,它还支持直接发布到 Github,使用 Github Actions 来自动化这个过程,这样我们只需要 push tag,就可以自动进行编译、打包、发布。

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 接口标准化日志输出。