go mod 之痛

从 rsc 力排众议设计并将 go mod 集成在 Go 语言中,已经两年过去了,时至今日,广大 Gopher 还是经常被 go mod 相关的问题折磨。

本文会列举一些我和我的同事使用 go mod 时碰到的问题,有些问题是 go mod 本身的问题,有些可能是第三方 goproxy 实现的问题。

如果你做过比较大型的 go 项目开发,相信总会有那么几个让你会心一笑。

Go 命令的副作用

从老版本一路升级过来的 gopher 很难理解为什么升级了新版本之后,go fmt 一个文件都变得非常卡顿。

go 的很多子命令都在引入 go mod 后增加了副作用,如 go test,go fmt(ide 常用),go build,go list(ide 常用)。

WeChataf0313e57f5e301abde33e663b19471c

例如上面的 go fmt,我只是想格式化一下我的文件,并没有想下载依赖,但还是得耐心等依赖下载完毕。

go test 时会自动修改 go.mod 文件就更令人困惑了:why go mod keeps changing with go testgo.mod be modified after go test

这也是 go.mod 和 go.sum 为什么总是会出现在我们的文件变更列表里。何况这两个文件在大项目开发的时候又尤其容易冲突。

go.sum git 合并冲突

当很多同事在同一个 git 仓库中做开发时,即使我们已经划分好了工作职责,在代码合并的时候还是没有办法 auto merge:

WeChat917b7c3d8ab465cb807f91bca1a03427

类似上面这样的合并冲突,下面躺着 go.sum 的情况相信你也见过很多了。

形同虚设的 semver 规范

go mod 的设计认为社区是严格遵守 semver 的规范的:

Given a version number MAJOR.MINOR.PATCH, increment the:
MAJOR version when you make incompatible API changes,
MINOR version when you add functionality in a backwards compatible manner, and
PATCH version when you make backwards compatible bug fixes.

小版本升级,如 1.7.4 -> 1.7.5 不应该引入不兼容升级,不过显然 Google 高估了开源社区的节操。不少开源库作者 API 修改起来都比较随便。

即使是 Google 自己的 grpc-go 项目,也在小版本升级中干过不兼容的事情:Update your SemVer Policy Please - Breaking changes in minor versions causing heartache

何况 grpc-go 的作者还光明正大地承认,他们在 semver 的前提下,依然允许一些不兼容的 例外

甚至还有那些从 release notes 中不易察觉的 behavior change 导致依赖 grpc-go 的 helm 项目在生产环境中遇到了 bug,令人大为光火。

好样的,Google 工程师。

除了人的问题之外,在 semver 规范中还存在一种例外情况:

Major version zero (0.y.z) is for initial development. Anything MAY change at any time. The public API SHOULD NOT be considered stable.

go mod 设计时并未考虑这种情况,mvs 算法在 0.y.z 范围内也会尽量在大版本不变的情况下,无情地帮你升级小版本,搞的百姓怨声载道,苦不堪言。

这两年爆火的云原生领域,有很多项目在 0.x.y 版本一待就是两三年。从业者依赖 0.x 的版本号再正常不过了。如果你问 go mod replace 谁用的最溜?那想必是云原生开发者啦。

版本信息扩散

由于 go mod 的设计,如果一个依赖库升级了新版本,我们的 import 路径就会发生变化:

WeChat0a94f2c0134986b3d0b5a0320b3ed62d

chi 项目升级 v5 了,所有引入 chi 下 lib 的代码都需要改 import,开心不开心。我们又要升级兼容新的 API,又要改这些到处散落的 import path。

这绝对不能说是优秀的设计。

goproxy 的实现各不相同

因为特殊原因,国内的 gopher 基本都需要配置国内公司/个人开发的 goproxy 来加速依赖下载,这些 proxy 没有使用相同的代码,所以实现细节上经常会有差别。

例如,当某个库不存在时,有的 goproxy 返回 404,而有的 goproxy 返回 500(这是笔者使用某司 goproxy 时的真实情况),匪夷所思。

我们来看一下更加令人诧异的例子,来帮你理解这种匪夷所思。

删库跑路

简单做个实验,遵从以下步骤:

  1. 在 github 上创建仓库 A
  2. 通过 goproxy X 来 go build
  3. 删除仓库 A
  4. 删除 mod cache,并使用 goproxy X/Y/Z 分别执行 go build
第一次 go build 删库后 goproxy.cn 删库后 goproxy.io 删库后 腾讯 goproxy 删库后 aliyun goproxy
goproxy.cn 可 build 不可 build 不可 build 不可 build
goproxy.io 可 build 可 build 不可 build 不可 build
腾讯 goproxy 可 build 不可 build 可 build 不可 build
aliyun goproxy 可 build 不可 build 不可 build 可 build

这次选取了国内使用最广泛的四个 goproxy,使用其中之一缓存过一次的外部依赖,在删库后还是可以 build 的。但如果之前未经该 goproxy 缓存的依赖,目前只有 goproxy.cn 依然能够正常地下载依赖。

经过对原作者的咨询,目前 goproxy.cn 在未找到依赖,但 gosumdb 中有值时,会去官方的 index.golang.org 上进行查找,而 gosumdb 中有值时,一般情况下官方的 proxy.golang.org 中会有相应的缓存(即使你设置的是第三方 goproxy)。这时 goproxy.cn 也会将从官方 goproxy 中拉取,所以用户的 build 还是能成功的。

一个不带 vendor 的项目,理论上就会出现因为 gopher 使用的 GOPROXY 不一样,导致薛定谔的 build 结果。

如果我们细看一下 sum.golang.org,官方对外部库的缓存期限描述也是比较模糊的。

模糊的存储期限

proxy.golang.org does not save all modules forever. There are a number of reasons for this, but one reason is if proxy.golang.org is not able to detect a suitable license. In this case, only a temporarily cached copy of the module will be made available, and may become unavailable if it is removed from the original source and becomes outdated. The checksums will still remain in the checksum database regardless of whether or not they have become unavailable in the mirror.

上面这段话来自 sum.golang.org,从官方的这种说法来看,依赖库在 goproxy 中的存储并不是永久的,至少在 proxy.golang.org 中不是永久的,官方给出的 a number of reasons 也非常的模糊。

我们没有办法把工作赌在这种虚无缥缈的措辞上,只能认为 goproxy 不会永久缓存我们的仓库。没有办法指望我们的依赖能够永远存在。原仓库从 github 消亡之后,迟早有一天也会在各个 goproxy 上消亡,reproducible build 沦为笑谈。

即使在 go mod 推出的两年后,对于我们来说,把依赖保存在 vendor 中依然是必要的。

多年前,left pad 在 js 社区引起的悲剧,也许并没有给当前的软件设计者提供多少教训:
how one programmer broke the internethave we forgotten how to program

go mod 之痛
Share this

欢迎关注我的公众号,我的微信 xargin_buaa,如果想交流也欢迎来加~

京ICP备15065353号-1