学习 go mod 的心得

@Spike


参考

请注意:这篇文章主要是学习 go mod 的使用和对 Go & Versioning 这个系列文章的学习心得。

如果你看完且理解了这个系列的文章,那么接下来的内容应该对你没有任何帮助。


包含内容

  • 什么包管理,有哪些内容?
  • Go 的包管理是怎样的。
  • 怎样在 Go 里面完成最好的实践。

大部分新一点的语言都有自己的包管理系统,例如 CPAN(perl), pip(Python), gem(Ruby)。

一般来说,大部分语言的包管理系统主要都集中在拉取包和依赖。但是本文介绍的 go mod 还有更加丰富的功能和设计目标。这些特性和上面说的那些包管理系统或多或少有一些不同,至少可以叫做「版本化的、有依赖的模块管理」。


Go版本管理的主要目标

  • 提升使用外部 module 的体验。
  • 更加精确的管理我们的代码和模块。
  • 稳定的构建 reproducible builds
  • 标记使用的其他模块(甚至可以记录在最终的二进制中)。

背景:模块和包 module and package

  • 模块(module)是一堆有相同前缀的包(package)的集合。

    • module 有版本号(并且是有逻辑上的意义的)。
    • package 没有版本号。
    • go mod 是针对的 module
  • 模块的命名,一般来说是 organization/modue_name 的方式。

    • 例如 google.golang.org/grpc

背景:Go 程序员曾经的问题

  1. 没有官方的依赖模块管理工具。
  2. 无法做到 reproducible builds
  3. 主要工具 go get 无视了用 git tag 的版本号。
  4. GOPATH 处理不了同一模块的多个版本需求。
  5. 无法在 GOPATH 之外写代码。

背景:go get 的问题

  • 用该工具拉取代码,太新太旧

    • 如果本地没有这个库,会去拉取最新的。
    • 如果本地有这个库,则不会更新,太旧。
  • Go程序员倾向于动不动就 go get -u
  • 每次拉取最新的模块导致的不稳定构建(low-fidelity builds)。
  • 被墙且不方便用全局代理(这个是被 go env proxy 解决的,与 module 无关)。

没有 go mod 时期的操作

Makefile 里面直接写 go get (甚至 go get -u)。

bin:
  go get ...
  go get ...@...
    go build project_path/app_name

这意味着编译的时候需要有网络环境,同时,今天能够编译的代码,明天就有可能失败。

同时,如果修改了某一个外部模块,非得自己 folk 一个,并且放在 git 仓库中。


畏惧依赖模块变化

  • 担心通过 go get 获取的外部模块改动影响到本地工程的构建。
  • 所以我拷贝到自己的目录下了(不需要感到羞耻,因为Google有段时间也是这么干的)。
  • 拷贝代码中的路径怎么处理?

    • 在最久远时期,需要用脚本来处理被拷贝到工程中的外部模块代码。
    • 例如 xx/pkg -> local_dir/xx/pkg

从2012年到2015年,vendor 方案出炉。

  1. 自动改代码依赖 Goven
  2. 新工具,处理模块依赖 godep glide govendor
  3. 新版本(go 1.5)对 vendor 类方案的官方支持 go vender dep

各种 vendor 实现

  • 官方对 GOPATH 打补丁(约定 vendor 作为外部包的文件)。
  • 实践中,都把 vendor 一起放入了代码库中。
  • 需要有一个版本描述文件。

大部分vendor类工具做的事情:
下载指定版本的模块,完成拷贝。


govendor 方案的问题

  • 打补丁和升级不方面。
  • 代码仓库冗余且丑陋。
  • 计算依赖关系的时候,基于 GOPATH 和旧的 go get 算法(对于 +external 的模块)。

注: govendor 一类的工具只会去下载最新的版本。并不会计算和选择特定的版本,仅仅是递归的将
原始工程中的依赖库全部下载下来而已。


问题处理:核心规则

  • 模块版本要做向下兼容。
  • 不兼容的做大版本处理(不同的大版本不是一个模块)
If an old package and a new package have the same import path,
the new package must be backwards-compatible with the old package.

反过来:如果接口不一样,那么就是不同的包。


但是你不需要维护新的 Git 仓库

  • 使用 git tag 来标记和维护。

    import "github.com/go-yaml/yaml/v2"
  • 查看模块的 tags

    $ go list -m -versions github.com/go-yaml/yaml/v2
    github.com/go-yaml/yaml/v2 v2.0.0 v2.1.0 v2.1.1 
    v2.2.0 v2.2.1 v2.2.2 v2.2.3 v2.2.4 v2.2.5 v2.2.6 
    v2.2.7 v2.2.8 v2.3.0 v2.4.0

思考:如果没有大版本区分的问题

问题解决方法:

Semantic Import Versioning

  • 大版本用于整体接口升级和变更。
  • 中间版本可以增加新接口。
  • 小版本修bug。

参考这一篇极为详细的介绍(详细到啰嗦了)。


问题处理:版本选择策略

选择最旧(小)的版本而不是最新的。

  • 兼容性。(稳定构建方面的考虑reproducible builds)
  • Dependency hell. (复杂度和包管理系统的实现上考虑efficient)

需要注意的是:

  • 最小选择的是「版本」,而不是更新日期!如果包代码没有使用这套规则,那么被当成0.0.0版本(pseudo version)+拉取最新的git提交,例如 v0.0.0-20170915032832-14c0d48ead0c
  • 对于本地的工程,默认开始创建 go.mod 的时候,仍然会去拉取所依赖的模块的最新的版本,见后文「实际操作」。

消灭 GOPATH

  • Go的版本环境、代码本体的版本和一个隐含的 GOPATH 下的东西决定了编译出来的二进制。
  • GOPATH 就像是隐藏在Go后面的一个尿袋。
  • 如果代码使用 go mod 来描述所有的依赖模块,那么它位于什么地方就无关紧要了。

两种之前的垃圾版本管理/构建策略

[示意图]

  • 如果当前环境(GOPATH)有这个库,那么不去动他。 go get
  • go get -u 直接升级到最新。

上面两种都是 low-fidelity builds


最小版本依赖 Minimal version selection

这是作者 Russ Cox 反复强调并且引以为傲的一个概念。
首先我们要弄清楚,「最小」的意思,是针对已经写了 go.mod 的模块来说的。

如果本地工程依赖了三个项目 a b c,他们都是用了另外一个模块 libx,分别的版本是 1.1 1.2 和 1.3,假设当前 libx 的最新版本已经是 1.7 了。
如果本地工程不依赖 libx,那么构建时,go mod 会选择 libx 1.3 作为构建的版本。

可是,如果此时本地工程使用了 libx,此时,创建一个新的 go.mod 并且计算出依赖的话,里面的版本将会是 libx 1.7。同时,在这种场景下,a b c 和本地程序最终都将使用 libx 的 1.7 版本(在一个 Go 程序中,一个大版本的模块只能够有唯一的实体)。

所以,这个「最小版本依赖」,针对的是 a(或者 b c 这种已经发布的模块)。

使用命令 go version -m ~/go/bin/gopls 可以查看生成的二进制使用的模块版本号。该特性允许你找到合适的环境来重新 build 二进制(用于验证)。


最小版本依赖算法相关的4个操作。

  1. 从构建 build list。
  2. 升级所有模块。
  3. 升级指定模块。
  4. 降级指定模块。

这项特征依赖于「模块的向后兼容性」。如果某个依赖库不遵从该规则,那么无法达到目的。


操作构建 build list

  • 找到固定的版本,加入到列表里面,剔掉旧的。
  • 用递归的做法来,很慢且在有环的情况下会死循环。
  • 图的遍历算法处理。

build list
build list


再来一次 go get

  • GOPATH的情况下,go get 表现和之前一样。
  • go mod 的情况下,go get 是用上面提到的方法构建和更新。

思考

  • 被本地工程和依赖模块依赖的同一个模块的版本的选择?最终 build 的时候的结果?
  • 修改了第三方模块,但是并不想提交到独立的 git 仓库问题。
  • 内网开发的问题。

另一篇:What is Software Engineering?

  • Go的设计思路和决策都来自于对软件工程的思考,会平衡时间和真正编程中实践。
  • gofmt 的例子:

    1. 要让代码更干净,统一风格。
    2. 终止无用的格式相关的讨论。
    3. 重要:任何地方copy来的代码,最终展示出来的样子都是一致的。
    4. 重要:因为3.,所以代码可以被后续的工具处理修改(goimports gorename go fix)。

总得来说,Go本身就是一门为了工程而打造的语言,他并没有什么类似学术上企图。在最开始的时候,甚至只是为网络服务器程序而设计的。

所以,它一定是让真正的写实际项目工程的人爽的一门语言。


实践

环境变量(GO111MODULE="auto")以及「当前目录下是否有 go.mod 文件」决定了 go get 的行为。

对于本地工程直接依赖的版本,go mod tidy操作,他会去拉取「最新」的版本(按照优先级):

  1. 最新的稳定 non-prerelease 版本。
  2. 没有的话,最新的 prerelease 版本。
  3. 没有的嗯话,最新的 untagged 版本。

go.mod 中的标记 indirect,表示不是本地工程所直接依赖的,通常是由未使用 go.mod 的模块引入静来的(给人一种「编制外」的感觉)的模块。
一个 Go 的模块,有两个标准:

  1. 是否使用了 semantic verion
  2. 是否有 go.mod

按理说一个有格调的 Go 模块,都应该按照这两个标准来组织。但是,作为鼎鼎大名的,Google自家人的 etcd-io/etcd,却一直都没有使用 go mod

这里有一篇吐槽 Etcd使用go module的灾难

如果项目里面使用了 etcd 和 go mod,那么你将面临一大片被拽入到 go.modgithub.com/coreos/xxx // indirect

这是最新的(2021年3月)的alpha版本:

v3.5.0-alpha.0 is an experimental release in order to:

test the "modularized" release process
enable integration testing with the modularized code of 3.5.x.

我试了一下:

go get github.com/coreos/etcd@v3.5.0-alpha.0
go get: github.com/coreos/etcd@v3.5.0-alpha.0: invalid version: module contains a go.mod file, so major version must be compatible: should be v0 or v1, not v3

看来他们还有很远的路要走,在此之前,使用了 etcd 的朋友们,只能够通过在 go.mod 中加入 replace 来给版本依赖关系打补丁。


实践总结

教程中的 cheat sheet:

go mod init creates a new module, initializing the go.mod file that describes it.
go build, go test, and other package-building commands add new dependencies to go.mod as needed.
go list -m all prints the current module’s dependencies.
go get changes the required version of a dependency (or adds a new dependency).
go mod tidy removes unused dependencies.

需要特别注意的是,在 go 1.16 版本里面,go build / go test 等操作并不再去修改 go.mod 文件(上面的第二条)!这个行为和之前的版本差异挺大的,不过坦白说,我认为这个改动不赖,避免了很多自动执行的,让人无语的 go.mod 修改。

In Go 1.16, module-aware commands report an error after discovering a problem in go.mod or go.sum instead of attempting to fix the problem automatically. In most cases, the error message recommends a command to fix the problem.

内网开发实践

如果使用了 go.mod,并且还需要使用一些本地的库,那么可以通过在 go.mod 中使用 replace 来指明:

require hx v0.1.0
replace hx v0.1.0 => /Users/chuanqin/code/go_work/src/hx

同理,我们对于内网 gitlab 的库,也可以使用同样的方法:

require hx v0.1.0
replace hx v0.1.0 => your_repository/hx

秦川
6 声望3 粉丝

September 3rd 2019.