Go Modules 必知必会
Contents
备注:本来只是想研究一下 Go Modules 的相关机制,结果居然花了好几个周末把 Go 版本管理的设计和相关历史都研究了一下。关于 Go Modules 的设计,最好的文档莫过于 Russ Cox(下称 rsc) 的博客。读 rsc 的博客是一种非常享受的体验:细致严谨且高屋建瓴。如果读者有兴趣,强烈推荐大家去读一读 rsc 的博客。
混乱与秩序
Go 最初的版本管理
Go 语言最开始发布的时候并没有引入版本管理机制,但是有两个与其他语言相对不一样的地方:
-
GOPATH 的设计
即所有的代码(非标准库代码,标准库代码位于 GOROOT 下)都必须位于这个 GOPATH 环境变量所指定的路径下,并遵照一定的目录规则(
src/
、pkg/
和bin/
)来存放(据说这种做法是源于 Google 单一代码库管理的风格)。 -
go get 的设计
这是一个去中心化的采用 URL 引用路径的包管理。当工程中需要引用其他包时,则必须:
- 引用路径必须是一个 URL 格式,比如:
github.com/foo/bar
; - 使用
go get
命令将其源码 clone 到$GOPATH/src/github.com/foo/bar
路径下,后续编译器将自动从这个路径寻找依赖;
- 引用路径必须是一个 URL 格式,比如:
由此可见,最初的 Go 的包管理没有版本的概念,每个 go get 默认都是拉取最新版本。将全局的所有依赖都放在单一路径 GOPATH 下,这将很难管理不同项目对于不同版本依赖的需求。比如,当项目 A 和项目 B 都分别依赖于项目 C 的两个不兼容版本时, 单一路径下只有一个版本的 C 将无法同时满足 A 和 B 的依赖需求。解决这个问题最简单直接的做法就是为每个项目单独维护一份对应版本的依赖的拷贝。事实上,这也是当时社区大多数版本管理工具(比如 Godep)所采用的思路,只不过为了实现这一目的用了很多不同的 Hack。
Go 官方最终也看不下去,在 Go 1.5 的时候提出了实验性质的 vendor 机制:每个项目都可以有一个 vendor/
目录来存放项目所需版本依赖的拷贝。至于这份依赖的拷贝是怎么来的,环状依赖如何解决等问题,vendor 机制并未涉及。于是,社区中不同的版本管理工具基于 vendor 机制实现了各种不同的版本管理方式(这段历史可以参考 Go 包管理的前世今生)。
随着时间的推移,社区中版本管理的问题越来越严重:
- 不同的版本管理工具彼此不兼容,协作困难;
- 每一种不同的版本管理工具都有一定的学习成本,Go 的用户总是因开发不同的项目而去花时间学习另外一个版本管理工具的使用方式;
到这时候,Go 社区中的每个人其实都在期待着有一统江湖且好用的解决方案。
实验性质的 dep 项目
dep 的到来似乎给痛苦的 Go 用户带来的一丝曙光。
dep 项目最初是在 Go 官方社区的支持下成立的,用以解决当前 Go 存在的版本管理问题。正如 README 所说的,这是一个 “official experiment”。dep 采用了一种与 Rust 的包管理工具 Cargo 非常类似的版本管理模式:
- 采用
Gopkg.lock
文件来冻结版本(Cargo 也有与之对应的Cargo.lock
); - 采用与 Cargo 相同的版本选择算法;
- 同样采用 TOML 格式来配置依赖;
等等。一时间,很多开源项目都纷纷转向使用 dep,大家都认为在不久的未来,Go 官方的工具可能会集成 dep。
但是,好景不长。突然有一天,Go 社区的核心人物 rsc 突然提出了 vgo 方案。一时间竟然出现了两个所谓的 Go 官方的版本管理方案 !?那到底谁才是真命天子呢 ?答案不言而喻,当然是 rsc(毕竟 Go 社区的话语权最终还是掌握在 Google 手上)。这中间的八卦大家可以参考 关于 Go Module 的争吵。
秩序的到来
为什么 Go 一直没有一个根正苗红的版本管理方案呢 ?原来 Go 社区起初并不知道怎么去做一个好的版本管理方案,并认为包的版本管理应该交给其他工具去做,于是鼓励大家去更多地创造这类工具。
于是,正如上文所述,Go 的版本管理工具百花齐放,出现了许多相互不兼容版本管理机制,导致 Go 项目在依赖版本管理上出现了很大的分裂性,这其实违背 Go 提倡的简单原则。后来,Go 社区的领导者终于看不下去,决定做一个官方的版本管理工具。
最初的实验性质的版本管理工具叫做 vgo,本质上就是将原来的 go 工具集成了版本管理的能力(go+=version
)。vgo 的设计很多层面也是基于 Dep 的探索,但是由于 rsc 的影响力,vgo 做得比 Dep 更为彻底:
-
直接在 go 工具中集成版本管理;
-
使用 Minimal Version Selection 算法来决定到底使用哪一个依赖版本;
-
废弃 GOPATH 的概念;
-
进一步强化 Semantic Import ;
随着 vgo 的逐渐成熟,Go 1.11 就直接集成并正式发布了这个实验功能,即 Go Modules,并决定在后续的版本演进中将其变成一个默认选项。虽然 Dep 在 Go 版本管理上作出了极为重要的尝试,但是最终社区还是选择了 Go Modules(内置的当然比 Add On 方式好用)。目前社区中绝大多数项目的版本管理不是已经迁移成了 Go Modules,就是在迁移到 Go Modules 的路上。Dep 项目也基本停止开发和维护。而最早的几个版本管理工具就更不用说,在 Dep 出来之后也纷纷停止维护。所以,在可预见的未来,Go 的用户必然将选择 Go Modules。
Go Modules 的工作流
Go Modules 机制跟着 Go 1.11 的发布一起被集成到了 Go 的基础工具中,并用环境变量 GO111MODULE
来决定开启条件。在后续的版本中,Go Modules 会逐渐成为一个默认选项。接下来让我们来看看采用 Go Modules 如何构建我们的日常工作流。
Hello World
我们先从 Go Modules 的官方文档的最简单例子开始。
-
先确保你当前所安装的 Go 版本大于或等于 1.11,并进行如下设置
1
$ export GO111MODULE=on
-
离开
GOPATH
,选择一个独立于GOPATH
的目录来开始我们的 Quick Start:1 2 3
$ mkdir -p /tmp/scratchpad/repo $ cd /tmp/scratchpad/repo $ git init -q
这样我们就在
/tmp/scratchpad/repo
创建了一个新的 git 仓库; -
创建
go.mod
这一步相当关键,以后将是我们开启一个新项目必不可少的初始化动作:
1 2 3 4 5 6 7
$ go mod init github.com/my/repo go: creating new go.mod: module github.com/my/repo $ cat go.mod module github.com/my/repo go 1.13
其中
go mod init
后面所添加路径github.com/my/repo
有两个作用:-
作为 Module 的标识(identity);
-
作为 Module 的 import path,当其他项目引用这个 Module 下的 package 时都会以该 import path 作为共同的前缀,比如:
1
import "github.com/my/repo/mypkg"
-
-
编写我们的代码并引用一个外部的依赖:
1 2 3 4 5 6 7 8 9 10 11 12
$ cat <<EOF > hello.go package main import ( "fmt" "rsc.io/quote" ) func main() { fmt.Println(quote.Hello()) } EOF
然后直接编译运行:
1 2 3 4
$ go build -o hello $ ./hello Hello, world.
至此,一个简单的基于 Go Modules 的 Hello World 就完成了 !是不是非常简单 !我们在编译运行的时候,也同时注意到:
-
当前目录下不仅出现了可执行目标文件,还出现了
go.sum
,这是什么文件呢 ? -
编译过程中会出现下载依赖的过程:
1 2 3 4 5 6 7 8 9 10
$ go build -o hello hello.go go: finding rsc.io/quote v1.5.2 go: downloading rsc.io/quote v1.5.2 go: extracting rsc.io/quote v1.5.2 go: downloading rsc.io/sampler v1.3.0 go: extracting rsc.io/sampler v1.3.0 go: downloading golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c go: extracting golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c go: finding rsc.io/sampler v1.3.0 go: finding golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c
但是依赖实际并没有下载到当前目录中,也没有所谓的 vendor 目录,那么项目依赖最终位于何处呢 ?
想要知道这些答案,我们先来了解关于 Go Modules 必须要了解的几个新概念。
新的概念
-
Modules
经过了上面的例子,Modules 这个概念应该相对比较容易理解:用版本管理起来的 Go package 的集合。在大多数场景下,Modules 其实就是一个 Go 项目,每一个项目都其相应的版本,比如 v1.0.0,v1.0.1 等等。一个 Module 可以依赖于其他 Module,Module 也可认为是我们对依赖的一种抽象;
-
go.mod
文件每一个 Module 在其根目录下都有且仅有一个
go.mod
文件。这个文件描述了当前 Module 所需要的所有对应版本的依赖。例如:1 2 3 4 5 6
module github.com/my/thing require ( github.com/some/dependency v1.2.3 github.com/another/dependency/v4 v4.0.0 )
此时 Module 有两个依赖,且版本分别是
v1.2.3
和v4.0.0
。go.mod
目前有 4 个有效的 directives:module
、require
、replace
和exclude
。其中大多数场景只会用到module
和require
。module
的作用如上所示:提供标识和 import path。require
的作用非常显而易见:说明 Module 需要什么版本的依赖。至于
replace
和exclude
的作用,由于不算是一个高频动作(某些场景下replace
用到的地方可能会相对多一些),所以下次再介绍。 -
go.sum
文件go.sum
文件记录了 Module 中每一个特定版本依赖的加密后的 checksum。go.sum
的作用在于验证。比如go mod verify
可以验证本地依赖的缓存是否与 checksum 吻合。go.sum
并非 lock file(比如 Cargo 或者 Dep 采用 lock file)。理论上,就算没有 lock file,Go Modules 所采用的 Minimal Version Selection 也能保证 reproducible build。 -
版本选择算法
Go Modules 选择了一种叫做 Minimal Version Selection 的算法(下称 mvs 算法)。这个算法简单来说就是:在所有列出的Module 集合中,总是选择满足全部依赖条件的最高版本的 Module。让我们来举例说明:
- 新增一个依赖 M,且此时 M 最新的版本是
v1.2.3
,则此时 mvs 算法将使用v1.2.3
的 M。如果 M 同时又依赖于v1.0.0
的 D,mvs 算法将选择精确版本的v1.0.0
; - 一个 Module 中存在两个依赖:Module A 依赖于
v1.0.0
的 D,Module B 依赖于v1.1.1
的 D。此时 mvs 算法将选择v1.1.1
的 D(因为v1.1.1
的版本要高于v1.0.0
);
如何理解所谓的 Minimal ?这必须要和其他包管理工具对比着来看:
假如使用 Rust 的 Cargo 或者 Go 的 Dep,它们将总是使用当前新引入依赖的最新版本,并将其确定的依赖版本写入 lock file 中,下次编译直接使用 lock file 中所指定的版本即可。假如 Module A 依赖 Module B,且 Module B 依赖 Module C。此时 Cargo 或 Dep 将直接使用最新版的 B 和 C。但是 mvs 算法则会:使用最新版的 B 和 B 所指定版本的 C。假如此时有另外一个 Module D 需要使用更新版本的 C,mvs 算法才会使用更新版本的 C。
所以此处的 Minimal 指的就是:总是选择满足需求的最小依赖版本,而非每次都选择最新版本(除非用户显式指定),同时 mvs 算法也无需使用 lock file 来保证 reproducible build。mvs 算法是一种相对保守稳妥的策略,这也是 Go Modules 在设计之初与 Dep 的分歧之一。Russ Cox 觉得 Dep 所使用版本选择算法并不可靠,容易收到外部事件的干扰(比如又发布了新的版本),并非一个可以随着时间稳定的算法。
- 新增一个依赖 M,且此时 M 最新的版本是
-
语义化版本管理
所谓语义化版本管理,即 semver ,是一个被广泛采纳但是并不默认遵守的版本管理策略。Go 一直孜孜不倦地建议用户要使用语义化版本管理,其方式可用下图说明:
- 除非是主版本发生了变化,否则主版本下的所有接口都应该保证兼容性;
- 如果主版本发生了变化,应该通过增加新的 import path 来保证旧版本的 import path 也能使用。版本为 v0 或者 v1 的 Module 将无需采用这条规则,只有发布 v2 或者更高版本的 Module 时才需要增加新的 import path;
- 如果新旧 package 都拥有一样的 import path,那么新的 package 必须向后兼容;
举个例子,如果目前项目使用
example.com/my/mod/mypkg
,若此时mypkg
发布了 v2 版本,那么理论上使用 v2 版本应使用example.com/my/mod/v2/mypkg
的 import path。目前 Go Modules 并没有对 v2+ 新版本的 Release 行为有自动化的操作(预期会支持),所以通常这种增加新的 import path 依赖于项目的维护者。事实上,大多数项目都并不遵守这类规则。此时,Go Modules 会在
go.mod
中为这类版本加上一个+incompatible
的标记,例如:1
require foo v2.2.2+incompatible
总而言之,最符合 Go 的方式就是:增加新的接口优于修改已有接口。一旦有接口已经 Export,就不要轻易更改其原始逻辑,就算接口废弃,也不要删除接口。如果希望用户使用新的不兼容逻辑,增加携带版本信息的新接口。
-
不再有 GOPATH
Go Modules 机制其中一个比较重要的变化就是:不再有 GOPATH。对于大多数用户来说,GOPATH 其实是一个不好理解且诡异的存在。我们必须要在 GOPATH 下创建自己的项目,项目中所使用的 import path 也必须符合 GOPATH 的设定。Go Modules 打破了这一限制。使用 Go Modules,我们可以在任意的路径下创建项目,不再受限于 GOPATH。Go Modules 也不依赖和使用 vendor 目录(虽然仍然可以使用 vendor 目录,但这并不是推荐做法),这使得项目体积将大大降低,无需维护庞大的 vendor 目录,只需维护好
go.mod
即可。
日常工作流
下载依赖
由于一般情况下,使用 Go Modules 的项目不再使用 vendor 目录。所以当我们下载一个新的项目后,我们也需要下载其对应的依赖:
|
|
增加新的 Module
增加一个新的依赖同样可以使用 go get
命令:
|
|
如果想指定某一个版本或分支(Go 鼓励用户使用 tag 来做日常的 release),可以使用:
|
|
如果新引入的 Module 没有指定版本,且项目中其他 Module 也没有为其指定版本,那么直接使用 go get
将采用最高版本。
如果引入的 Module 没有使用 tag,那么默认将使用最新的 commit 并为其生成一个 pseudo_versions,类似于 v0.0.0-20170915032832-14c0d48ead0c
。
更新 Module
直接用 go get
更新,比如:
|
|
如果要选择合适的版本,可使用 @<tag>
来指定更新到某一个高版本。
列出当前使用的依赖
可用如下命令来列出当前所使用的所有对应版本的依赖:
|
|
列出某一个远程 repo 的版本:
|
|
移除依赖
就算我们把对应依赖从代码中移除,编译之后仍不会将其对应依赖去除(比如还是可以 list
到),因为编译阶段不会对所有 Module 做依赖检查。
可使用如下命令来清除无用的依赖:
|
|
Go Modules 的默认下载位置
默认在 $GOPATH/pkg/mod/
下。
使用 goproxy 功能
设置 GOPROXY
环境变量,如:
|
|
更多的 goproxy 站点可参考 Go Modules Wiki。
如果设置:
GOPROXY=off
:Go Modules 将不允许从任何源下载依赖;GOPROXY=direct
:Go Modules 将直接从依赖的源地址进行下载操作;
启用对 vendor 目录的支持
如果因为某些原因,仍然需要使用 vendor,可以使用:
|
|
将在当前项目中创建 vendor 目录。
默认 go build
将忽视 vendor 目录,若要在编译中使用 vendor 目录,可以:
|
|
启用对 Go Modules 的支持
当安装完 Go 1.11 或更高版本(预计 1.13 版本后稳定成默认选项)之后,有如下两种方式启用 Go Modules:
- 在
$GOPATH/src
目录树之外创建目录,且当前项目的根目录中带有有效的go.mod
,编译时将自动启用 Go Modules。此时无需设置GO11MODULE
(或者显式设置为auto
); - 如果是在
$GOPATH/src
路径下,显式设置GO111MODULE=on
触发go
命令使用 Go Modules;
在 Preliminary module support 中,文档描述了 go
命令在依赖管理上的 3 种模式:
-
设置
GO111MODULE=off
(GOPATH mode)此时
go
将使用 GOPATH 和沿用老的 vendor 机制; -
设置
GO111MODULE=on
(module-aware mode)此时
go
将不使用 GOPATH; -
设置
GO111MODULE=auto
或者不设置如果在
GOPATH/src
之外,此时将自动使用 Go Modules 机制否则还是用老的机制;
如何发布 v2 及更高版本 Module
参考 v2-go-modules 的做法,v2+ 版本的 Module 意味着存在可能有 breaking change,所以最推荐的做法是将新 Module 放在以版本为后缀的目录中,例如:
|
|
这样新版的 Module 也拥有了一个新的 sematic import path。
假如我们在 v1 版本的 gax-go
中开发 v2,可以:
-
创建 v2 目录
1 2
$ cd gax-go $ mkdir v2
-
开发 v2 功能
1
$ cp *.go v2
这里我们假设就是将 v1 的代码直接复制到 v2 目录中。
-
增加新的
go.mod
一个
go.mod
表示一个 Module,我们需要为新的 v2 版本增加一个新的go.mod
:1 2
$ cp go.mod v2/go.mod $ go mod edit -module github.com/googleapis/gax-go/v2 v2/go.mod
我们直接将 v1 版本的
go.mod
拷贝过来,并修改其module
字段。 -
发布
1 2
$ git tag v2.0.0-alpha1 $ git push origin v2.0.0-alpha1
Go Modules 的生态服务
rsc 在 Go Modules in 2019 这篇博客中介绍了 Go Team 在 2019 年对 Go Modules 做的工作。其中最重要的还是 Google 为 Go Modules 生态开发了一系列的周边服务:
由于 go get
是一个去中心化的动作,所以很难去对 Go Packages 做可用性验证和安全检验等机制。于是,Go Team 开发了以下几个服务:
-
index.golang.org:Index 服务可以用来快速查询 Module 的一些基本信息。Index 服务维护着一个 Feed,Feed 中的元素都是一个 Module,如下所示(我们可以通过访问
https://index.golang.org/index
来查看):1
{"Path":"golang.org/x/text","Version":"v0.3.0","Timestamp":"2019-04-10T19:08:52.997264Z"}
通过 Index 服务,我们可以很快知道这个 Module 是否存在以及一些基本信息。比如
goimports
可以利用这种能力去检查项目中所引入的 Module 是否有效。 -
sum.golang.org:Sum 服务维护着一个 checksum 数据库。相关设计可以参考 Proposal。这个服务最大的作用是用以安全,防止中间人攻击。对应版本的 Module 都被有效签名并生成加密的 checksum 保存这个数据库中。
-
proxy.golang.org:Proxy 服务维护的是 Go Modules 的 Mirror,用以加速 Module 的下载。默认地,从 Go 1.13 开始,Go Modules 使用
GOPROXY=https://proxy.golang.org
,如果不想用这个官方的 Proxy,可以直接设置为GOPROXY=direct
。中国很多云服务公司也提供了对应的 Proxy 服务,日常开发在特殊国情的条件下可以选择使用。
展望未来
Go Modules 机制作为 Go 发展历程中一个比较重大的变动目前也逐渐被社区大部分项目接纳,毫无疑问,未来大部分项目肯定都会使用 Go Modules。Go 的版本管理之所以一波三折,风波不断,感觉还是因为几位核心的设计者偷懒 : ) 。假如 Go 一开始就设计一个可用的版本管理机制,也许就没有这些有趣的故事了。
祝大家读得愉快 !