关于 Go1.11 module 和语义版本

2018-11-18

把公司项目迁移到 Go1.11 的 module 了,这个过程中对于版本控制,依赖管理之类的事情有了更加深入的了解。 本来懒得写东西,有同事说起,“我们从 glide 到 dep,一直到现在切换到 module 了,为什么项目的依赖还是很难用”,所以我觉得还是有必要写一写。让更多的人理解 go module 以及语义版本,还是有意义的。

包是一个固定的路径,而包的代码是会动态的变化的。如果今天 import 一个包可以编译过,明天那个包升级一下,项目就挂了,肯定是不能忍的。过去的一个做法,我们把所有的依赖都放进 vendor 里面,就不受外部包的升级之类的影响了。

仅仅是代码拷到 vendor 不行,需要知道使用的是哪个版本的包,要有工具帮忙做这个事情,glide 到 dep 都是这样的工具。这些工具会有一个描述文件,一个 lock 文件,以及 vendor 代码。 描述文件里面,可以写很多灵活的规则,指定一个包使用哪个版本,比如使用 1.1.1,或者版本大于多少且小于多少,使用某个分支,又或者是某个 commit hash。lock 文件是工具根据描述文件的说明生成出来的,每个包使用具体到哪个 commit 都是确定的。

主流的依赖管理工具都是这么干的,无论是在 rust 还是 javascript 或者其它语言里面。一开始在 Go 做 dep 的时候,也准备就按主流搞法做,然而 russ cox 对这个方向越做越觉得不对。

使用 commit hash 是一个非常糟糕的行为,它让项目之间无法合作。比如项目 A 描述它要依赖 C 的 commit hash 5,项目 B 描述它依赖项目 C 的 commit hash 10,这也没问题。然后当 B 要引用 A 的时候,工具就懵逼了:你让我到底是选择 C 的 commit hash 5,还是 commit hash 10 呢? 于是工具只能把错误抛出来。

在描述文件里面指定依赖,是把所有的心智负担交给做依赖管理的人了。那个人当然会觉得难用!

假设我是项目 A 的维护者,我肯定要骂项目 B 的作者是傻逼。但是他听不到,也不知道自己很傻逼,我没法控制项目 B 的修改,于是只能默默去改自己的项目 A 的依赖,将 C 的版本依赖,改成一个既能兼容 commit hash 5,又能兼容 commit hash 10 的版本,好,我指定了一个大于版本 0.8 吧。然后再赞自己一句,真是太 TMD 机智了。

过了几天,项目 C 升级了,它做了一个不兼容的改动,然后我的项目 A 想 update 一下自己的依赖,结果就跪了。于是又得重新默默处理 A 的依赖,顺便再问候 C 的女性长辈。

一个投机取巧的处理方式是,把同一个包的不同版本给编译多份。A 依赖 C 的 commit hash 5,B 依赖 C 的 commit hash 10,A 依赖 B,那么,把 B 的版本 5 和 版本 10 分别都编译进 binary,问题就解决了。包的不同的版本,其实是不同的包。初看这个想法挺机智的,直到有一天遇到了这个问题:

func init() {
    http.Registe("/debug/requests")
}

这是一个只能做一次的代码,但是 C 在版本 5 和 版本 10 里面分别都做了一次,然后就 panic 了。

这所有的一切都是不可协作的方式,每个人写自己的包的时候,没有考虑别人。提供灵活的规则去指定版本是不管用的。在描述文件里面指定依赖,是把所有的心智负担交给做依赖管理的人了。那个人当然会觉得难用! 嗯,重要的话重复几遍。所以 Go 觉得这个方式不可行。靠谱的方式应该是,由写包的人要遵循某个约定,剩下的版本选择交给工具去做。这个约定就是 – 语义版本。

语义版本才是 Go module 的核心。写包的人必须遵循语义版本,他写出来的包才能跟其它人协作。这是重要的开发哲学,心智负担从维护项目的人身上,转移到了各个包的开发者身上。我们看一下语义版本是什么。

https://semver.org/

版本号是 MAJOR.MINOR.PATCH 的形式。当做了不兼容的 API 改动的时候,MAJOR 版本号就需要变。当做了能后向兼容的改动的时候,MINOR 版本号要变。对于前后完全兼容的版本,则只需要更改 PATCH 版本。

最小版本选择,这也是 Go module 一个很有特点的地方。在主流工具做法里面,所有的包会指定自己的依赖,然后求解得到满足依赖的结果,它们会倾向使用最新的版本。然而 Go 不一样,它是选择满足条件的最小的版本。也就是没事不要瞎升级。选择最小的版本,会让约束更松,从而可选择而更多,兼容性更好。Go 没有像普通工具多做一个 lock 文件,然后锁定版本。这些都是在 Go 的工具链里面整个的。只有用户决定要升级某个包的时候,才会去升级。升级方式是这样:

GO111MODULE=on go get -u github.com/repo/pkg@version

一行命令它会自动更新相应的依赖。可以是 @version,也可以是 branch。如果是 branch 会默认使用该 branch 上面的最新的版本,没打版本,则是最新的那次提交。

Go 是这样处理语义版本的,它假设 1.0 之后,是稳定的版本。如果没有打版本,它还是会把同一个包的不同版本,当前不同的包处理。打了版本之后,它会寻找一个兼容的版本。比如之前的例子,

  • 项目依赖 的 commit hash 5 跟 commmit hash 10,那么这个包的两个版本都会被编译进去
  • 如果依赖写成 v1.0.5 和 v1.0.10,这两版本是兼容的,则选择最小版本依赖 v1.0.5
  • 如果同时依赖是 v1.5.0 和 v1.10.0,最小的兼容版本是 v1.10.0。

注意,如果包不遵循语义版本会怎么样? 如果没有打版本,还是使用 commit hash,那么 Go module 也帮不上忙,又回到了 dep 那种时代,于是维护的人还是会觉得很痛苦。

如果一个项目,明明是不兼容的改动,然而它的版本依然是 v1.1.1 写成 v1.1.2,那么造成的结果是升级就跪了。比如加了一个 API,那就应该 v1.1.1 就到 v1.2.0。这对所有暴露的 API 都需要慎重考虑, Go 语言在一开始就是遵循标准流程的。

处理开发分支,有一个 replace 命令:

GO111MODULE=on go mod edit -replace github.com/repo/pkg=github.com/your-fork/pkg@version

它可以让原本依赖的 github.com/repo/pkg 包,实际使用 github.com/your-fork/pkg@version。

实际使用过程中,说一个比较恶心的地方是 repo 间的相互引用成环。比如 github.com/pingcap/tidb-tools 要引用 github.com/pingcap/tidb,而 tidb 又要引用 tidb-tools/tidb-binlog/pump_client 就很麻烦,至今没想到好办法。

Go module 并不是万能药。万能药是靠语义版本来保证的。语义版本的理念非常的好,如果大家都照做了,天下大同,相互协作就简单了。如果不遵循,则依然是混乱。关键还是要写代码的人对自己的严格要求。

golangmodule