学习 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 程序员曾经的问题
- 没有官方的依赖模块管理工具。
- 无法做到 reproducible builds。
- 主要工具 go get 无视了用
git tag
的版本号。 - GOPATH 处理不了同一模块的多个版本需求。
- 无法在 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 方案出炉。
- 自动改代码依赖
Goven
。 - 新工具,处理模块依赖
godep
glide
govendor
。 - 新版本(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
思考:如果没有大版本区分的问题
问题解决方法:
- 大版本用于整体接口升级和变更。
- 中间版本可以增加新接口。
- 小版本修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个操作。
- 从构建 build list。
- 升级所有模块。
- 升级指定模块。
- 降级指定模块。
这项特征依赖于「模块的向后兼容性」。如果某个依赖库不遵从该规则,那么无法达到目的。
操作构建 build list
- 找到固定的版本,加入到列表里面,剔掉旧的。
- 用递归的做法来,很慢且在有环的情况下会死循环。
- 图的遍历算法处理。
再来一次 go get
- 用
GOPATH
的情况下,go get
表现和之前一样。 - 用
go mod
的情况下,go get
是用上面提到的方法构建和更新。
思考
- 被本地工程和依赖模块依赖的同一个模块的版本的选择?最终 build 的时候的结果?
- 修改了第三方模块,但是并不想提交到独立的 git 仓库问题。
- 内网开发的问题。
另一篇:What is Software Engineering?
- Go的设计思路和决策都来自于对软件工程的思考,会平衡时间和真正编程中实践。
gofmt
的例子:- 要让代码更干净,统一风格。
- 终止无用的格式相关的讨论。
- 重要:任何地方copy来的代码,最终展示出来的样子都是一致的。
- 重要:因为
3.
,所以代码可以被后续的工具处理修改(goimports
gorename
go fix
)。
总得来说,Go本身就是一门为了工程而打造的语言,他并没有什么类似学术上企图。在最开始的时候,甚至只是为网络服务器程序而设计的。
所以,它一定是让真正的写实际项目工程的人爽的一门语言。
实践
环境变量(GO111MODULE="auto"
)以及「当前目录下是否有 go.mod
文件」决定了 go get
的行为。
对于本地工程直接依赖的版本,go mod tidy
操作,他会去拉取「最新」的版本(按照优先级):
- 最新的稳定 non-prerelease 版本。
- 没有的话,最新的 prerelease 版本。
- 没有的嗯话,最新的 untagged 版本。
在 go.mod
中的标记 indirect
,表示不是本地工程所直接依赖的,通常是由未使用 go.mod
的模块引入静来的(给人一种「编制外」的感觉)的模块。
一个 Go 的模块,有两个标准:
- 是否使用了
semantic verion
- 是否有
go.mod
按理说一个有格调的 Go 模块,都应该按照这两个标准来组织。但是,作为鼎鼎大名的,Google自家人的 etcd-io/etcd
,却一直都没有使用 go mod
:
这里有一篇吐槽 Etcd使用go module的灾难
如果项目里面使用了 etcd 和 go mod
,那么你将面临一大片被拽入到 go.mod
的 github.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/[email protected]
go get: github.com/coreos/[email protected]: 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