1.Go Modules 的前世今生

流行的现代编程语言一般都提供依赖库管理工具,如 Java 的 Maven 、Python 的 PIP、Node.js 的 NPM 和 Rust 的 Cargo 等。Go 最为一门新生代语言,自然也有其自己的库管理方式。

1.1 GOPATH

在 Go 1.5 之前,Go 最原始的依赖管理使用的是 go get,执行命令后会拉取代码放入 GOPATH/src 下面。但是它是作为 GOPATH 下全局的依赖,并且 go get 还不能进行版本控制,以及隔离项目的包依赖。

而随着 Go 生态圈的快速壮大,无法进行版本控制,会导致项目中的依赖库经常出现 API broken 的情况。因为依赖的库相关接口改变了,导致我们的项目更新了依赖库后编译不过,我们不得不需要修改自己的代码以便适应依赖库的最新版本。更困难的是,如果多个依赖库分别依赖第三个依赖库的第三个版本,版本冲突就出现了。

依赖库冲突几乎每个编程语言都有这样的问题,甚至操作系统也有 DLL 地狱问题,所以各种编程语言都尝试使用自己的方式解决依赖库版本的问题。

1.2 Go vendor

Go 1.5 版本推出了 vendor 机制。但是需要手动设置环境变量 GO15VENDOREXPERIMENT= 1,Go 编译器才能启用。从 Go1.6 起,默认开启 vendor 机制。

所谓 vendor 机制,就是每个项目的根目录下可以有一个 vendor 目录,里面存放了该项目的依赖的 package。go build 的时候会先去 vendor 目录查找依赖,如果没有找到会再去 GOPATH 目录下查找。

vendor 将原来放在 $GOPATH/src 的第三方包放到当前工程的 vendor 目录中进行管理。它为工程独立的管理自己所依赖第三方包提供了保证,多个工程独立地管理自己的第三方依赖包,它们之间不会相互影响。 vendor 将原来包共享模式转换为每个工程独立维护的模式,vendor的另一个好处是保证了工程目录下代码的完整性,将工程代码复制到其他Go编译环境,不需要再去下载第三方包,直接就能编译,这种隔离和解耦的设计思路是一大进步。

但 vendor 也有缺点,那就是对外部依赖的第三方包的版本管理。

我们通常使用 go get -u 更新第三方包。默认的是将工程的默认分支的最新版本拉取到本地,但并不能指定第三方包的版本。而在实际包升级过程中,如果发现新版本有问题,则不能很快回退,这是个问题。好在社区有很多优秀的第三方包管理工具可以解决此问题。

1.3 第三方管理工具

在 Go 1.11 之前,很多优秀的第三方包管理工具起到了举足轻重的作用,弥补了 Go 在依赖管理方面的不足,比如 godepgovendorglidedep 等。其中 dep 拥趸众多,而且也得到了 Go 官方的支持,项目也放在 Golang 组织之下 golang/dep

但是蜜月期没有多久,2018 年 Russ Cox 经过深思熟虑以及一些早期的试验,决定 Go 库版本的方式需要从头再来,深度集成 Go 的各种工具(go get、go list等),实现精巧的最小化版本选择算法,解决 broken API 共存等问题,所以 dep 就被废弃了,这件事还导致 dep 的作者相当的失望和数次争辩。

随着历史车轮的滚滚向前,这些工具均淹没在历史长河之终,完成了它们的使命后,光荣地退出历史舞台。

1.4 Go Modules 横空出世

从 2018 年 Go 1.11 开始,Go 官方推出了 Go Modules。为了保持向后兼容,Go 官方旧的依赖管理方式依然存在。启用 Go Modules 需要显示通过设置一个环境变量 GO111MODULE=on。在之后的 go 1.12 正式推出后,Go Modules 成为默认的依赖管理方式。

先前,我们的库都是以 package 来组织的,package 以一个文件或者多个文件实现单一的功能。一个项目包含一个package 或者多个 package。Go modules 就是一个统一打版和发布的 package 的集合,在项目根文件下有 go.mod 文件定义 module path 和依赖库的版本,还有一个 go.sum 的文件,该文件包含特定依赖包的版本内容的散列哈希值。

一般我们项目都是单 module 的形式,项目根目录下包含 go.mod 和 go.sum 文件,子文件夹定义 package,或者主文件夹也是一个 package。但是一个项目也可以包含多个 module,只不过这种方式不常用而已。

2.go.mod 文件

go modules 最重要的是 go.mod 文件的定义,它用来标记一个 module 和它的依赖库以及依赖库的版本。会放在 module 的主文件夹下,一般以 go.mod 命名。

一个 go.mod 内容类似下面的格式:

module github.com/dablelv/go-huge-util

go 1.17

replace github.com/coreos/bbolt => ../r

require (
	github.com/cenk/backoff v2.2.1+incompatible
	github.com/edwingeng/doublejump v0.0.0-20200330080233-e4ea8bd1cbed
	github.com/go-sql-driver/mysql v1.5.0
	github.com/spf13/cast v1.4.1
	github.com/stretchr/testify v1.7.0
	golang.org/x/text v0.3.2
)

require (
	github.com/davecgh/go-spew v1.1.1 // indirect
	github.com/pmezard/go-difflib v1.0.0 // indirect
	gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
)

exclude (
	go.etcd.io/etcd/client/v2 v2.305.0-rc.0
	go.etcd.io/etcd/client/v3 v3.5.0-rc.0
)

retract (
    v1.0.0 // 废弃的版本,请使用v1.1.0
)

虽然是一个简单的文件,但是里面的乾坤不少,让我们依次介绍它们。

2.1 语义化版本 2.0.0

Go module 遵循语义化版本 2.0.0。语义化版本规范 2.0.0 规定了版本号的格式,每个字段的意义以及版本号比较的规则等等。
在这里插入图片描述
如果你想为你的项目发版,你可以设置tag为上面的格式,比如 v1.3.0、v2.0.0-alpha.1 等等。metadata中在Go版本比较时是不参与运算的,只是一个辅助信息。

2.2 module path

go.mod 的第一行是 module path,一般采用“仓库+module name” 的方式定义。这样我们获取一个 module 的时候,就可以到它的仓库中去查询,或者让 go proxy 到仓库中去查询。

module github.com/dablelv/go-huge-util

如果你的版本已经 >=2.0.0,按照 Go 的规范,你应该加上 major 的后缀,module path 改成下面的方式:

module github.com/dablelv/go-huge-util/v2

module github.com/dablelv/go-huge-util/v3

而且引用代码的时候,也要加上v2、v3、vx后缀,以便和其它major版本进行区分。

这是一个建议性的约定,带来的好处是你一个项目中可以使用依赖库的不同的 major 版本,它们可以共存。

2.3 go directive

第二行是 go directive。格式是 go 1.xx,它并不是指你当前使用的 Go 版本,而是指名你的代码所需要的 Go 的最低版本。

go 1.17

因为 Go 的标准库在不断迭代,一些新的 API 会陆续被加进来。如果你的代码用到了这些新的 API,你可能需要指明它依赖的 Go 版本。

这一行不是必须的,你可以不写。

2.4 require

require段中列出了项目所需要的各个依赖库以及它们的版本,除了正规的v1.3.0这样的版本外,还有一些奇奇怪怪的版本和注释,那么它们又是什么意思呢?

2.4.1 伪版本号

github.com/edwingeng/doublejump v0.0.0-20200330080233-e4ea8bd1cbed

上面这个库中的版本号就是一个伪版本号v0.0.0-20200330080233-e4ea8bd1cbed,这是 go module 为它生成的一个类似符合语义化版本 2.0.0 版本,实际这个库并没有发布这个版本。

正式因为这个依赖库没有发布版本,而 go module 需要指定这个库的一个确定的版本,所以才创建的这样一个伪版本号。

go module 的目的就是在 go.mod 中标记出这个项目所有的依赖以及它们确定的某个版本。

这里的 20200330080233 是这次提交的时间,格式是 yyyyMMddhhmmss, 而 e4ea8bd1cbed 就是这个版本的 commit id。通过这个字段,就可以确定这个库的特定的版本。

而前面的 v0.0.0 可能有多种生成方式,主要看你这个 commit 的 base version:

  • vX.0.0-yyyymmddhhmmss-abcdefabcdef

如果没有 base version,那么就是 vX.0.0 的形式。

  • vX.Y.Z-pre.0.yyyymmddhhmmss-abcdefabcdef

如果 base version 是一个预发布的版本,比如 vX.Y.Z-pre,那么它就用 vX.Y.Z-pre.0 的形式。

  • vX.Y.(Z+1)-0.yyyymmddhhmmss-abcdefabcdef

如果 base version 是一个正式发布的版本,那么它就 patch 号加1,如 vX.Y.(Z+1)-0。

2.4.2 indirect 注释

require (
	github.com/davecgh/go-spew v1.1.1 // indirect
	github.com/pmezard/go-difflib v1.0.0 // indirect
	gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect
)

有些库后面加了 indirect 注释,这又是什么意思呢?

如果用一句话总结,间接的使用了这个库,但是又没有被列到某个 go.mod 中,当然这句话也不算太准确,更精确的说法是下面的情况之一就会对这个库加 indirect 注释。

  • 当前项目依赖 A,但是 A 的go.mod 遗漏了 B,那么就会在当前项目的 go.mod 中补充 B,加 indirect 注释;
  • 当前项目依赖 A,但是 A 没有 go.mod,同样就会在当前项目的 go.mod 中补充 B,加 indirect 注释;
  • 当前项目依赖 A,A 又依赖 B。当对 A 降级的时候,降级的 A 不再依赖 B,这个时候 B 就标记 indirect 注释。我们可以执行go mod tidy来清理不依赖的 module。

需要注意的是,从 go 1.17 开始,indirect 的 module 将被放在单独 require 块的,这样看起来更加清晰明了。

2.4.3 incompatible

有些库后面加了 incompatible 后缀,但是你如果看这些项目,它们只是发布了 v2.2.1 的 tag,并没有+incompatible后缀。

github.com/cenk/backoff v2.2.1+incompatible

这些库采用了 go.mod 的管理,但是不幸的是,虽然这些库的版 major 版本已经 >=2 了,但是他们的 module path 中依然没有添加 v2、v3 这样的后缀,不符合 Go 的 module 管理规范。

所以 go module 把它们标记为 incompatible 的,虽然可以引用,但是实际它们是不符合规范的。

2.5 exclude

如果你想在你的项目中跳过某个依赖库的某个版本,你就可以使用这个段。

exclude (
	go.etcd.io/etcd/client/v2 v2.305.0-rc.0
	go.etcd.io/etcd/client/v3 v3.5.0-rc.0
)

这样,Go 在版本选择的时候,就会主动跳过这些版本,比如你使用go get -u ......或者go get github.com/xxx/xxx@latest等命令时,会执行version query的动作,这些版本不在考虑的范围之内。

2.6 replace

replace 也是常用的一个手段,用来解决一些错误的依赖库的引用或者调试依赖库。

replace github.com/coreos/bbolt => go.etcd.io/bbolt v1.3.3
replace github.com/panicthis/A v1.1.0 => github.com/panicthis/R v1.8.0
replace github.com/coreos/bbolt => ../r

比如 etcd v3.3.x 的版本中错误地使用了github.com/coreos/bbolt作为 bbolt 的 module path,其实这个库在它自己的go.mod 中声明的 module path 是 go.etcd.io/bbolt。又比如 etcd 使用的 grpc 版本有问题,你也可以通过 replace 替换成所需的 grpc 版本。

甚至你觉得某个依赖库有问题,自己 fork 到本地做修改,想调试一下,你也可以替换成本地的文件夹。

replace 可以替换某个库的所有版本到另一个库的特定版本,也可以替换某个库的特定版本到另一个库的特定版本。

2.7 retract

retract 是 go 1.16 中新增加的内容,借用学术界期刊撤稿的术语,宣布撤回库的某个版本。

如果你误发布了某个版本,或者事后发现某个版本不成熟,那么你可以推一个新的版本,在新的版本中,声明前面的某个版本被撤回,提示大家都不要用了。

撤回的版本tag依然还存在,go proxy也存在这个版本,所以你如果强制使用,还是可以使用的,否则这些版本就会被跳过。

和 exclude 的区别是 retract 是这个库的 owner 定义的, 而 exclude 是库的使用者在自己的 go.mod 中定义的。

3.go.sum 文件

上面我们说到,Go 在做依赖管理时会创建两个文件,go.mod 和 go.sum。

相比于 go.mod,关于 go.sum 的资料明显少得多。自然,go.mod 的重要性不言而喻,这个文件几乎提供了依赖版本的全部信息。而 go.sum 则是记录了所有依赖的 module 的校验信息,以防下载的依赖被恶意篡改,主要用于安全校验。

每行的格式如下:

<module> <version> <hash>
<module> <version>/go.mod <hash>

比如:

github.com/spf13/cast v1.4.1 h1:s0hze+J0196ZfEMTs80N7UlFt0BDuQ7Q+JDnHiMWKdA=
github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=

其中 module 是依赖的路径。

version 是依赖的版本号。如果 version 后面跟/go.mod表示对哈希值是 module 的 go.mod 文件;否则,哈希值是 module 的.zip文件。

hash 是以h1:开头的字符串,表示生成 checksum 的算法是第一版的 HASH 算法(SHA256)。如果将来在 SHA-256 中发现漏洞,将添加对另一种算法的支持,可能会命名为 h2。

4.小结

本文简单介绍了 Go 依赖管理的发展历史,以及 Go module 的核心文件 go.mod 内的相关内容。

Go moudle 用起来虽然简单,但是隐藏了很多很细的知识点。希望大家在日常使用时多多实践,多多思考,全面理解 Go module 的方方面面。


参考文献

Go Modules Reference
深入Go Module之go.mod文件解析
Golang包管理,go module模式、go mod和go sum等文件介绍

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐