众所周知,Go 在做依赖管理时会创建两个文件,go.mod 和 go.sum。
相比于 go.mod,关于 go.sum 的资料明显少得多。自然,go.mod 的重要性不言而喻,这个文件几乎提供了依赖版本的全部信息。而 go.sum 看上去就是 go module 构建出来的天书,而不是什么人类可读的数据。
但实际上,日常开发中我们仍然不得不跟 go.sum 打交道(通常是解决这个文件带来的合并冲突,抑或试图手工调整里面的内容)。如果不了解 go.sum,只凭经验随便涂改,不一定能够改对。因此,为了更好地掌握 Go 的依赖管理,完全有必要了解 go.sum 的来龙去脉。
鉴于涉及 go.sum 的资料是如此地稀少(即使 Go 官方文档中,对于 go.sum 的描述也是支离破碎的),我花了些时间整理了相关的资料,希望读者可以从中受益。
go.sum 的每一行都是一个条目,大致是这样的格式:
1
<module> <version>/go.mod <hash>
或者
1
2
<module> <version> <hash>
<module> <version>/go.mod <hash>
其中module是依赖的路径,version是依赖的版本号。hash是以h1:开头的字符串,表示生成checksum的算法是第一版的hash算法(sha256)。
有些项目实际上并没有 go.mod 这个文件,所以 Go 文档里提到这个 /go.mod 的 checksum,用了 "possibly synthesized" (也许是合成的)的说法。估计对于没有 go.mod 的项目,Go 会尝试生成一个可能的 go.mod,并取它的 checksum。
如果只有对于 go.mod 的 checksum,那么可能是因为对应的依赖没有单独下载。比如用 vendor 管理起来的依赖,便只有 go.mod 的 checksum。
由于 go 的依赖管理背负着沉重的历史包袱,确定 version 的规则较为复杂。整个过程就像一个调查问卷,需要回答一个接一个的问题:
一、项目是否打tag?
如果项目没有打 tag,会生成一个版本号,格式如下:
v0.0.0-commit日期-commitID
比如 github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=。
引用一个项目的特定分支,比如 develop branch,也会生成类似的版本号:
v当前版本+1-commit日期-commitID
比如 github.com/DATA-DOG/go-sqlmock v1.3.4-0.20191205000432-012d92843b00 h1:Cnt/xQ9MO4BiAjZrVpl0BiqqtTJjXUkWhIqwuOCVtWo=。
二、项目有没有用 go module?
如果项目有用到 go module,那么就是正常地用 tag 来作为版本号。
比如 github.com/DATA-DOG/go-sqlmock v1.3.3 h1:CWUqKXe0s8A2z6qCgkP4Kru7wC11YoAnoupUKFDnH08=。
如果项目打了 tag,但是没有用到 go module,为了跟用了 go module 的项目相区别,需要加个 +incompatible 的标志。
比如 github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
三、项目用的 go module 版本是不是 v2+?
关于 go module v2+ 的特性,可以参考 Go 的官方文档:
https://blog.golang.org/v2-go...。简单而言,就是通过让依赖路径带版本号后缀来区分同一个项目里不同版本的依赖,类似于 gopkg.in/xxx.v2 的效果。
对于使用了 v2+ go module 的项目,项目路径会有个版本号的后缀。
比如 github.com/googleapis/gax-go/v2 v2.0.5 h1:sjZBwGj9Jlw33ImPtvFviGYvseOtDM7hkSKB7+Tv3SM=
之所以 Go 会在依赖管理时引入 go.sum 这样的角色,是为了实现下面的目标:
(1)提供分布式环境下的包管理依赖内容校验
不像其他包管理机制,Go 采用分布式的方式来管理包。这意味着缺乏一个可供信赖的中心来校验每个包的一致性。
在主流的包管理机制中,通常存在一个中央仓库来保证每个发布的版本的内容不会被篡改。比如在 pypi 里面,即使发布过的版本存在严重的bug,发布者也不能重新发布一个同样版本,只能发布一个新版本。(但是却可以删掉已发布的版本抑或删掉整个项目,参考当年 npm 的 leftpad 事件,所以主流的包管理机制并非严格意义上的 Append Only。不过这并不影响我的论证)
而 Go 并没有一个中央仓库。发布者在 GitHub 上给自己的项目打上 0.1 的 tag 之后,依旧可以删掉这个 tag ,提交不同的内容后再重新打个 0.1 的 tag。哪怕发布者都是老实人,发布平台也可能作恶。所以只能在每个项目里存储自己依赖到的所有组件的 checksum,才能保证每个依赖不会被篡改。
(2)作为 transparent log 来加强安全性
go.sum 还有一个很特别的地方,就是它不仅仅记录了当前依赖的checksum,还保留了历史上每次依赖的 checksum。这种做法效法了 transparent log 的
概念。transparent log 旨在维护一个 Append Only 的日志记录,提高篡改者的作案成本,同时方便审查哪些记录是篡改进来的。根据
Proposal: Secure the Public Go Module Ecosystem 的说法,go.sum 之所以要用 transparent log 的形式记录历史上的每个checksum,是为了便于 sum db 的工作。
不得不说的是,go.sum 也带来一些麻烦:
(1)容易产生合并冲突
这恐怕是 go.sum 最为人诟病的地方了。由于许多项目都没有通过打tag的方式来管理发布,每个commit都相当于新发布一个版本,这导致拉取它们的代码时会偶尔往 go.sum 文件里插入一条新记录。go.sum会记录间接依赖的特性,更是让这种情况雪上加霜。这一类的项目带来的影响可不小 —— 我粗略地统计下 go.sum 里这类记录的行数,大概占了总数的 40%。比如 golang.org/x/sys 在某个项目的 go.sum 里就有多达 37 个不同的版本。
如果只是莫名其妙的行数多,那最多不过是让人皱皱眉。在多人协作且用到几个经常升版本号的内部公共库的场景下,go.sum 会让人头疼。想象这种情况:
公共库原来有版本甲。
开发者A的分支a依赖了公共库版本乙,开发者B的分支b依赖了公共库版本丙。他们分别给 go.sum 添加记录如下:
1
2
common/lib 甲 h1:xxx
common/lib 乙 h1:yyy
1
2
common/lib 甲 h1:xxx
common/lib 丙 h1:zzz
之后公共库发布了版本丁,包含了版本乙和版本丙的功能。
然后合并分支a和分支b到主干,这时候就会有合并冲突。
现在有两个选择:
把两个中间版本都纳入到 go.sum 进来
既不选乙,也不选丙,直接采用版本丁
无论采用哪种方法,都需要手动介入。这无疑带来了不必要的工作量。
(2) 对于胡乱操作的第三方库,缺乏约束能力
go.sum 的本意在于提供防篡改的保障,如果拉第三方库的时候发现其实际内容和记录的校验值不同,就让构建过程报错退出。然而它能做的也就只限于此。go.sum 的检测功能,给库的使用者带来的负担更甚于库的开发者。在有中央仓库保障的其他包管理器里,人们可以在源头上限制那些捣蛋鬼,不让他们随意变更已经发布出去的版本。但是 go.sum 带来的约束纯粹是道德上的。如果一个库乱改已经发布的版本,会让依赖这个库的项目构建失败。对此库的使用者除了咒骂几句,在 issue 或别的地方痛斥作者,然后更新go.sum文件,似乎也没别的解决办法。犯错的本来是库的作者,麻烦的却是库的用户。这种设计可算不上高明。一个可能的解决办法是由官方把知名的库的各个版本镜像起来。虽然知名的库通常不会犯乱改已发布版本的错误,但是如果发生了(或者出于某种不可抗力发生了),至少有个镜像可用。然而这又回到单一中央仓库的路子上去。
(3) 实际情况下,手动编辑go.sum不可避免。比如前面举的,编辑go.sum文件解决合并冲突的情况。我也见过有些项目只在go.sum里保留依赖的最新版本的checksum。如果 go.sum 不是完全由工具管理的,又怎么能保证它一定是 Append Only 呢?如果 go.sum 不是 Append Only 的,又怎么能把它当作 transparent log 使用呢?