0%

什么是分布式锁?

分布式锁是一种在分布式系统中用于控制并发访问的机制。在分布式系统中,多个客户端可能会同时对同一个资源进行访问,这可能导致数据不一致的问题。分布式锁的作用是确保同一时刻只有一个客户端能够对某个资源进行访问,从而避免数据不一致的问题。

分布式锁的实现通常依赖于一些具有分布式特性的技术,如 ZooKeeperRedis、数据库等。这些技术提供了在分布式环境中实现互斥访问的机制,使得多个客户端在竞争同一个资源时能够有序地进行访问。

通过使用分布式锁,可以确保分布式系统中的数据一致性和并发访问的有序性,从而提高系统的可靠性和稳定性。

Zookeeper 与 Redis 的分布式锁对比

ZooKeeperRedis 都是常用的实现分布式锁的工具,但它们在实现方式、特性、适用场景等方面有一些区别。以下是 ZooKeeper 分布式锁与 Redis 分布式锁的比较:

实现方式

  • ZooKeeper 分布式锁主要依赖于其临时节点和顺序节点的特性。客户端在 ZooKeeper 中创建临时顺序节点,并通过监听机制来实现锁的获取和释放。
  • Redis 分布式锁通常使用 SETNX(set if not exists 命令来尝试设置一个 key,如果设置成功则获取到锁。也可以通过设置过期时间和轮询机制来防止死锁和提高锁的可靠性。

特性

  • ZooKeeper 分布式锁具有严格的顺序性和公平性,保证了锁的获取顺序与请求顺序一致,避免了饥饿问题。
  • Redis 分布式锁的性能通常更高,因为它是一个内存数据库,读写速度非常快。然而,它可能存在不公平性和死锁的风险,需要额外的机制来避免这些问题。

适用场景

  • ZooKeeper 分布式锁适用于对顺序性和公平性要求较高的场景,如分布式调度系统、分布式事务等。
  • Redis 分布式锁适用于对性能要求较高的场景,如缓存系统、高并发访问的系统等。Redis 的高性能使得它在处理大量并发请求时具有优势。

可靠性

  • ZooKeeper 分布式锁具有较高的可靠性,因为它依赖于 ZooKeeper 的高可用性和强一致性保证。即使部分节点宕机,ZooKeeper 也能保证锁的正确性和一致性。
  • Redis 分布式锁的可靠性取决于其实现方式和配置。在某些情况下,如 Redis 节点宕机或网络故障,可能会导致锁失效或死锁。因此,需要合理配置 Redis 和采取额外的措施来提高锁的可靠性。

综上所述,ZooKeeper 分布式锁和 Redis 分布式锁各有优缺点,具体选择哪种方式取决于实际业务场景和需求。在需要保证顺序性和公平性的场景下,ZooKeeper 分布式锁可能更适合;而在需要高性能和快速响应的场景下,Redis 分布式锁可能更合适。

为什么 Zookeeper 可以实现分布式锁

ZooKeeper 可以实现分布式锁,主要得益于其以下几个特性:

  1. 临时节点:ZooKeeper 支持创建临时节点,这些节点在创建它们的客户端会话结束时会被自动删除。这种特性使得 ZooKeeper 的节点具有生命周期,可以随着客户端的存活而存在,客户端断开连接后自动消失,非常适合作为锁的标识。
  2. 顺序节点:ZooKeeper 的另一个重要特性是支持创建顺序节点。在创建节点时,ZooKeeper 会在节点名称后自动添加一个自增的数字,确保节点在 ZNode 中的顺序性。这个特性使得 ZooKeeper 可以实现分布式锁中的公平锁,按照请求的顺序分配锁。
  3. Watcher 机制:ZooKeeper 还提供了 Watcher 机制,允许客户端在指定的节点上注册监听事件。当这些事件触发时,ZooKeeper 服务端会将事件通知到感兴趣的客户端,从而允许客户端做出相应的措施。这种机制使得 ZooKeeper 的分布式锁可以实现阻塞锁,即当客户端尝试获取已经被其他客户端持有的锁时,它可以等待锁被释放。

基于以上特性,ZooKeeper 可以实现分布式锁。具体实现流程如下:

  1. 客户端需要获取锁时,在 ZooKeeper 中创建一个临时顺序节点作为锁标识。
  2. 客户端判断自己创建的节点是否是所有临时顺序节点中序号最小的。如果是,则客户端获得锁;如果不是,则客户端监听序号比它小的那个节点。
  3. 当被监听的节点被删除时(即持有锁的客户端释放锁),监听者会收到通知,然后重新判断自己是否获得锁。
  4. 当客户端释放锁时,只需要将会话关闭,临时节点就会被自动删除,从而释放了锁。

因此,ZooKeeper 通过其临时节点、顺序节点和 Watcher 机制等特性,实现了分布式锁的功能。

使用 Golang 实现 Zookeeper 分布式锁

下面我们通过一个简单的例子来演示如何使用 Golang 实现 ZooKeeper 分布式锁。

创建 zookeeper 客户端连接

1
2
3
4
5
6
7
8
9
10
import "github.com/go-zookeeper/zk"

func client() *zk.Conn {
// 默认端口 2181
c, _, err := zk.Connect([]string{"192.168.2.168"}, time.Second)
if err != nil {
panic(err)
}
return c
}

创建父节点 - /lock

我们可以在获取锁之前,先创建一个父节点,用于存放锁节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
type Lock struct {
c *zk.Conn
}

// 父节点 /lock 不存在的时候进行创建
func NewLock() *Lock {
c := client()
e, _, err := c.Exists("/lock")
if err != nil {
panic(err)
}
if !e {
_, err := c.Create("/lock", []byte(""), 0, zk.WorldACL(zk.PermAll))
if err != nil {
panic(err)
}
}

return &Lock{c: c}
}

获取锁

在 Zookeeper 分布式锁实现中,获取锁的过程实际上就是创建一个临时顺序节点,并判断自己是否是所有临时顺序节点中序号最小的。

获取锁的关键是:

  1. 创建的需要是临时节点
  2. 创建的需要是顺序节点

具体创建代码如下:

1
p, err := l.c.Create("/lock/lock", []byte(""), zk.FlagEphemeral|zk.FlagSequence, zk.WorldACL(zk.PermAll))

其中 zk.FlagEphemeral 表示创建的是临时节点,zk.FlagSequence 表示创建的是顺序节点。

判断当前创建的节点是否是最小节点

具体步骤如下:

  1. 通过 l.c.Children("/lock") 获取 /lock 下的所有子节点
  2. 对所有子节点进行排序
  3. 判断当前创建的节点是否是最小节点
  4. 如果是最小节点,则获取到锁,函数调用返回;如果不是,则监听前一个节点(这会导致函数调用阻塞)
1
2
3
4
5
6
7
8
9
10
11
12
13
childs, _, err := l.c.Children("/lock")
if err != nil {
return "", err
}

// childs 是无序的,所以需要排序,以便找到当前节点的前一个节点,然后监听前一个节点
sort.Strings(childs)

// 成功获取到锁
p1 := strings.Replace(p, "/lock/", "", 1)
if childs[0] == p1 {
return p, nil
}

不是最小节点,监听前一个节点

具体步骤如下:

  1. 通过 sort.SearchStrings 找到当前节点在所有子节点中的位置
  2. 调用 l.c.ExistsW 判断前一个节点是否依然存在(锁有可能在调用 ExistsW 之前已经被释放了),如果不存在则获取到锁
  3. 如果前一个节点依然存在,则阻塞等待前一个节点被删除
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 监听锁,等待锁释放
// 也就是说,如果当前节点不是最小的节点,那么就监听前一个节点
// 一旦前一个节点被删除,那么就可以获取到锁
index := sort.SearchStrings(childs, p1)
b, _, ev, err := l.c.ExistsW("/lock/" + childs[index-1])
if err != nil {
return "", err
}

// 在调用 ExistsW 之后,前一个节点已经被删除
if !b {
return p, nil
}

// 等待前一个节点被删除
<-ev

return p, nil

在调用 ExistsW 的时候,如果前一个节点已经被删除,那么 ExistsW 会立即返回 false,否则我们可以通过 ExistsW 返回的第三个参数 ev 来等待前一个节点被删除。

<-ev 处,我们通过 <-ev 来等待前一个节点被删除,一旦前一个节点被删除,ev 会收到一个事件,这个时候我们就可以获取到锁了。

释放锁

如果调用 Lock 可以成功获取到锁,我们会返回当前创建的节点的路径,我们可以通过这个路径来释放锁。

1
2
3
func (l *Lock) Unlock(p string) error {
return l.c.Delete(p, -1)
}

完整代码

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
package main

import (
"github.com/go-zookeeper/zk"
"sort"
"strings"
"time"
)

func client() *zk.Conn {
c, _, err := zk.Connect([]string{"192.168.2.168"}, time.Second) //*10)
if err != nil {
panic(err)
}
return c
}

type Lock struct {
c *zk.Conn
}

func NewLock() *Lock {
c := client()
e, _, err := c.Exists("/lock")
if err != nil {
panic(err)
}
if !e {
_, err := c.Create("/lock", []byte(""), 0, zk.WorldACL(zk.PermAll))
if err != nil {
panic(err)
}
}

return &Lock{c: c}
}

func (l *Lock) Lock() (string, error) {
p, err := l.c.Create("/lock/lock", []byte(""), zk.FlagEphemeral|zk.FlagSequence, zk.WorldACL(zk.PermAll))
if err != nil {
return "", err
}
childs, _, err := l.c.Children("/lock")
if err != nil {
return "", err
}

// childs 是无序的,所以需要排序,以便找到当前节点的前一个节点,然后监听前一个节点
sort.Strings(childs)

// 成功获取到锁
p1 := strings.Replace(p, "/lock/", "", 1)
if childs[0] == p1 {
return p, nil
}

// 监听锁,等待锁释放
// 也就是说,如果当前节点不是最小的节点,那么就监听前一个节点
// 一旦前一个节点被删除,那么就可以获取到锁
index := sort.SearchStrings(childs, p1)
b, _, ev, err := l.c.ExistsW("/lock/" + childs[index-1])
if err != nil {
return "", err
}

// 在调用 ExistsW 之后,前一个节点已经被删除
if !b {
return p, nil
}

// 等待前一个节点被删除
<-ev

return p, nil
}

func (l *Lock) Unlock(p string) error {
return l.c.Delete(p, -1)
}

测试代码

下面这个例子模拟了分布式的 counter 操作,我们通过 ZooKeeper 分布式锁来保证 counter 的原子性。

当然这个例子只是为了说明 ZooKeeper 分布式锁的使用,实际上下面的功能通过 redis 自身提供的 incr 就可以实现,不需要这么复杂。

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
package main

import (
"context"
"fmt"
"github.com/redis/go-redis/v9"
"sync"
)

func main() {
var count = 1000
var wg sync.WaitGroup
wg.Add(count)

l := NewLock()
// 创建 redis 客户端连接
redisClient = redis.NewClient(&redis.Options{
Addr: "192.168.2.168:6379",
Password: "", // no password set
DB: 0, // use default DB
})

for i := 0; i < count; i++ {
go func(i1 int) {
defer wg.Done()

// 获取 Zookeeper 分布式锁
p, err := l.Lock()
if err != nil {
return
}
// 成功获取到了分布式锁:
// 1. 从 redis 获取 zk_counter 的值
// 2. 然后对 zk_counter 进行 +1 操作
// 3. 最后将 zk_counter 的值写回 redis
cmd := redisClient.Get(context.Background(), "zk_counter")
i2, _ := cmd.Int()
i2++
redisClient.Set(context.Background(), "zk_counter", i2, 0)
// 释放分布式锁
err = l.Unlock(p)
if err != nil {
println(fmt.Errorf("unlock error: %v", err))
return
}
}(i)
}

wg.Wait()

l.c.Close()
}

我们需要将测试程序放到不同的机器上运行,这样才能模拟分布式环境。

总结

最后,再来回顾一下本文内容:

  1. sync.Mutex 这种锁只能保证单进程内的并发安全,无法保证分布式环境下的并发安全。
  2. 使用 ZookeeperRedis 都能实现分布式锁,但是 Zookeeper 可以保证顺序性和公平性,而 Redis 可以保证高性能。
  3. Zookeeper 通过其临时节点、顺序节点和 Watcher 机制等特性,实现了分布式锁的功能。

在实际的工作中,我们很多时候开发环境跟应用程序最终运行的环境是不同的操作系统,比如在 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 函数在后台启动进程并等待其完成。