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

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐