Skip to content

Latest commit

 

History

History
522 lines (292 loc) · 49.2 KB

vgo_principles.md

File metadata and controls

522 lines (292 loc) · 49.2 KB

The Principles of Versioning in Go

原文:https://research.swtch.com/vgo-principles

PDF:https://research.swtch.com/vgo-principles.pdf

作者:Russ Cox

翻译时间:2020-02-09

Go 语言的模块版本管理原则

Go 与版本管理,第 11 部分)

发表时间:2019-12-03 周二

目录

正文

本文主要介绍:

  • 我们如何在 Go 语言中增加模块版本管理功能
  • 我们这样决策的原因

本文内容主要来源于 我 2018 年在新加坡 GopherCon 会议上的演讲(视频)。

为什么需要版本?

首先,我们对齐一下,先来看看基于 GOPATH 的 go get 存在的问题。

假设我们新装了一个 Go 语言环境,写了一个 Go 程序,里面 import 了 D,然后运行 go get D。注意此时我们运行的是基于 GOPATH 的 go get,而非基于 Go 模块。

$ go get D

上述命令会寻找并下载最新版的 D,目前最新版是 D 1.0。我们很高兴。

假设几个月过去了,我们需要用到 C。我们运行 go get C。会寻找并下载最新版的 C,目前最新版是 C 1.8。我们也很高兴。

$ go get C

C import 了 D,但 go get 发现本地曾下载过一份 D,所以复用本地的 D。不幸的是,本地的 D 依然是 1.0 版本。而最新版的 C 是基于 D 1.4 写成,相对于 D 1.0,D 1.4 中包含 C 所需的一些新功能和 bug 修复。所以 C 构建失败,因为本地的 D 太旧了。

由于构建失败,我们再次尝试 go get -u C

$ go get -u C

不幸的是,一小时前 D 的作者发布了 D 1.6。由于 go get -u 会获取每个依赖的最新版本(包含 D),结果 C 依然无法工作。C 的作者使用了 D 1.4,没什么问题,但 D 1.6 引入了一些 bug,导致 C 不能正常工作。之前 C 因为 D 太旧而不能工作,现在又因 D 太新而不能工作。

这是基于 GOPATH 的 go get 的 2 种失败情况。有时依赖太旧了,有时依赖又太新了。我们真正想要的其实是 C 的作者所使用的 D 的版本。但基于 GOPATH 的 go get 做不到这一点,因为它完全没有感知包版本的机制。

早在我们刚发布 goinstall 工具时(go get 的前身),Go 的开发者就已开始要求提供更好处理包版本的工具。这么多年来,为了更容易安装指定版本的包,出现了很多管理版本的工具,这些工具都不包含在 Go 发行版中。由于这些工具没有统一的版本处理方式,所以难以基于它们创建更多能识别版本的上层工具,如能识别版本的 godoc,或漏洞检测工具。

我们添加版本管理功能到 Go 语言中有很多原因,但最重要的原因是让 go get 不再使用太旧或太新的依赖包代码。并且,我们希望统一全体开发者和相关工具对版本二字的理解,从而使得整个 Go 生态都能识别模块版本。为了让整个生态都能理解版本是什么,我们创建了以下内容:

软件工程中的版本

过去 2 年里,我们以 Go 模块(module)的形式为 Go 语言增加了版本管理功能,并集成到 go 命令中。Go 模块引入了一种新的 import 路径语法:基于语义的 import 版本管理;并使用了一种新的版本选择算法:最小版本选择算法。

你可能会觉得奇怪:为什么不直接参考其他语言的做法?Java 有 Maven,Node 有 NPM,Ruby 有 Bundler,Rust 有 Cargo。它们有什么解决不了的问题吗?

你可能还会觉得奇怪:早在 2018 年,我们已引入了新的实验 Go 工具 Dep,此工具实现了 Bundler 和 Cargo 开创的一些通用方法。为什么 Go 模块不直接复用 Dep 的设计?

答案就是:我们从 Dep 中发现, Bundler/Cargo/Dep 等的通用方法和某些决策会使软件工程变得更复杂、更有困难。感谢从 Dep 的已知设计问题得到的经验,Go 模块做出了一个截然不同的决策,目的在于让软件工程更简单、更容易。

但什么是软件工程?软件工程与编程有什么区别?我喜欢 以下定义

软件工程是指当你增加更多时间、开发者时,编程中发生的事情。

编程表示让程序可以运行。当你有一个问题需要解决时,你可以写一些 Go 语言代码,运行,得到结果,问题得到解决。这就是编程,编程本身不是容易的事。

但如果代码每天都必须持续开发,该怎么办?如果需要 5 个开发者协作写一份代码怎么办?如果代码需求发生变化,如何才能更平滑适配?这时你可能会考虑以下办法:

  • 使用版本控制系统(version control systems)(译注:如 Git)来跟踪代码随时间的变化情况,以及协调开发者之间的工作
  • 添加单元测试来保证曾修复的 bug 不再复现,保证即使是 6 个月后或不熟悉该代码的新人也不会重新引入该 bug
  • 利用模块化、设计模式来将程序划分为多个部分,这样团队成员可以互不干扰地开发
  • 使用一些工具来简化找 bug 的过程
  • 找一些其他办法让开发者更清晰地编程,从而少犯一些错误,减少 bug
  • 保证在大型项目中即使是微细的修改也能被快速测试

你之所以做这些,是因为你的编程正转变为软件工程问题。

上述对软件工程的定义是我对 Google 同事 Titus Winters 观点的理解,他喜欢的定义是: “软件工程是随着时间变化而整合的编程”。这是他在 CppCon 2017 上的其中 7 分钟(08:17 - 15:00)的 演讲

Go 语言几乎所有与众不同的设计都是为了使软件工程变得更简单、更容易。举例来说,可能大部分人会认为我们推出 gofmt 工具的目的是为了让代码更美观,并终止团队间的格式之战。在某种程度上我们做到了。但实际上,gofmt最重要动机 是:若有一个算法能规范好 Go 语言的源代码的格式化问题,那其他的程序如 goimportsgorenamego fix 等就可以更容易分析、修改代码,并在回写代码时不会引入奇奇怪怪的格式问题。随着时间推移,你的代码依然很容易维护。

还有另一个例子,Go 的 import 路径实质是 URL。若一份代码使用了 import "uuid",这时你可能会疑惑这个 uuid 到底对应哪个库,因为在 pkg.go.dev 搜索 uuid 可得到一大堆结果。若使用 import "github.com/pborman/uuid",你就很清晰知道用了哪个库。使用 URL 的形式可以避免歧义,还可利用现有的命名机制,使开发者之间的协作更方便。

继续上面这个例子,Go 的 import 路径是是直接写在 Go 的源码中的,而不是一个独立的配置文件中。这样做的好处是 Go 的源码自包含了对引用的描述,理解、修改、复制这些源码都会变得更简单。这些决策设计,都是源于对简化软件工程的追求。

原则

从 Dep 方案到改为 Go 模块机制,背后有三大原则,这些原则都是为了简化软件工程。这三个原则分别是:兼容性原则、可复现性原则、协作原则。本文剩下部分将详细介绍每个原则,介绍这些原则如何让我们决定从 Dep 切换到 Go 模块机制。然后,会尽我所能,对反对此切换的意见进行一一解答。

原则 #1:兼容性

程序中的名称对应的含义应保持一致,不应随时间而变。

第 1 个原则就是兼容性原则。兼容性或说稳定性,是指:一个程序中,其中的名称(译注:如公开的函数名等)不应随时间而变。若一个名称在去年是这个含义,则今年、明年也应依然是这个含义。

例如,有时开发者会对 strings.Split 函数的使用细节感到疑惑。我们希望将字符串 hello world 拆分为两个字符串 helloworld。但若输入字符串的开头或结尾包含空格符,则结果也会包含空格。

例如:strings.Split(x, " ")

"hello world"  => {"hello", "world"}
"hello  world" => {"hello", "", "world"}
" hello world" => {"", "hello", "world"}
"hello world " => {"hello", "world", ""}

假设我们想让此函数变得更好,修改 strings.Split 的功能,去掉那些前缀或后缀空格。我们可以这么做吗?

不可以。

strings.Split 已有一个含义了。所有的文档、代码实现都基于该含义,一些程序也依赖该含义。若修改其含义,会导致依赖此函数的程序不能运行,违反兼容性原则。

当然,我们依然可以实现新的含义:创建新的函数名即可。实际上,几年前,为了解决此问题,我们已引入了 strings.Fields。这个新函数会删除前后的空格,绝不返回带空格的字符串:

例如:strings.Fields(x)

"hello world"  => {"hello", "world"}
"hello  world" => {"hello", "world"}
" hello world" => {"hello", "world"}
"hello world " => {"hello", "world"}

我们不重定义 strings.Split,因为遵循兼容性原则。

遵循兼容性原则可简化软件工程,因为它可以让你在理解程序代码时无需担心因时间而导致不一样的含义。人们无需这样顾虑:在 2015 年写的程序,当时的 strings.Split 函数会返回空格符,但上周又写了一个其他程序,这时还得去了解 strings.Split 是否不再返回空格了。不止是人,工具也无需担心时间变化导致含义发生变化。例如,重构工具可放心地将调用过 strings.Split 的代码改为调用新函数,不用担心时间带来的不一致。

实际上,Go 1 最重要的功能不是语言特性的变化或新库新功能,而是对兼容性原则的遵循:

以下引自 golang.org/doc/go1compat

遵循 Go 1 规范的程序,在此规范的整个生命周期中,可以一直编译成功、运行正确,兼容性保持不变。今天能跑起来的 Go 程序,在未来更多 Go 1 的 点(.) 发布版中(如 Go 1.1、Go 1.2 等)也能跑起来。

我们已尽量不修改标准库中的名字,所以基于 Go 1.1 写成的程序,在 Go 1.2 或更新版本中也能正常工作。如此类推。这种持续的承诺可让用户写代码更轻松,保持代码正常工作,即使他们升级到新的 Go 版本管理功能,也能快速用上这些新功能(译注:无需顾虑代码不兼容)。

添加版本管理功能时,该如何遵循兼容性原则?很有必要认真思考下兼容性原则,因为目前最流行的版本管理方法 语义版本管理(semver) 实际是鼓励不兼容性的。语义版本管理的一个不好的副作用是很容易允许破坏兼容性原则的修改。

每个语义版本的形式都是 v主版本号.次要版本号.补丁版本号vMAJOR.MINOR.PATCH)。

  • 若两个版本的主版本号相同,则更新(更大)的次要或补丁版本应兼容更旧(更小)的版本
  • 若两个版本的主版本号不同,则没有兼容性的约束

语义版本管理貌似是这么建议的:若想引入不兼容性修改,你只需通过增大主版本号即可告诉你的用户,一切都没问题。

但这是一个空洞的承诺,只增加主版本号是不够的,还有很多问题。若 strings.Split 今天是这个含义,到明天变成了其他含义,则即使阅读你的代码,也会变成软件工程,而非仅仅是编程那么简单。因为你还得考虑时间不同,函数的含义也不同。

还有更坏的情况。

假设 B 依赖了 v1 版的 strings.Split,而 C 依赖了 v2 版的 strings.Split。若对 B、C 分别进行构建,都不会有问题:

但你的包 A 同时依赖了 B 和 C,会发生什么事?在 A 中只能有 1 种 strings.Split 含义,则无法成功构建 A。

对于 Go 模块的设计,我们意识到兼容性原则对于简化软件工程极其重要。兼容性原则应被支持、鼓励、遵循。从 2013 年 11 月的 Go 1.2 开始,Go 的 FAQ 已开始鼓励遵循兼容性原则:

发布到公共使用的包,在演进时应尽量保持向后兼容性(译注:如 v1.5 应兼容 v1.4)。Go 1 的兼容性指南 就是一个很好的参考:不要移除已暴露的命名,鼓励使用组合单词来命名,等等。若需不同的功能,请创建一个新的命名,而非直接修改旧名字下的功能。若必须引入不兼容的修改,请创建一个新的包,并使用新的 import 路径。

对于 Go 模块,我们依然是同样的建议:创建一个新的名称。import 兼容性规则:

若一个包的旧版本和新版本拥有同样的 import 路径,则新版本的包必须向后兼容旧版本的包。

但是,我们该如何处理语义版本管理呢?若我们想继续使用大部分用户都期望的语义版本管理,则 import 兼容性规则要求:不同的主版本号(没有兼容性关系)必须使用不同的 import 路径。在 Go 模块中的实现形式是,将主版本号添加到 import 路径中。我们将此称之为:基于语义的 import 版本管理(semantic import versioning)。

观察上图例子,my/thing/v2 代表一个模块的语义版本号 2my/thing 则表示版本号 1,无需在 import 路径中添加额外的版本号。但当你发布版本 2 或更高版本时,必须在模块名后添加主版本号,以示区分。所以版本 2 对应 my/thing/v2,版本 3 对应 my/thing/v3,如此类推。

strings 包就是一个模块,并且出于某些原因我们需重新定义 Split 函数,而非创建新的函数 Fields,则我们可创建不同 import 路径的 Split 函数:strings 表示主版本号 1strings/v2 表示主版本号 2。然后,之前构建不了的程序,现在可以这样构建了:B 可 import strings,C 可 import strings/v2。这是不同的包,所以可并存于同一个程序中。现在 B 和 C 都可分别拥有各自想要的 Split 函数了。

由于 stringsstrings/v2 拥有不同的 import 路径,人们和相关工具均可立即理解这是不同的包,就像 crypto/randmath/rand 是不同的包一个道理。任何人都无需额外学习消除歧义的新规则。

我们再回头看看之前那个构建失败的程序,假设此时没使用基于语义的 import 版本管理。若我们将此例子中的 strings 替换为任意其他包(称之为 D),则会碰到经典的 钻石依赖问题。B、C 分别依赖 D 互相冲突的不同版本,可各自构建成功。但若同时在 A 中进行构建,则没有任何一个版本的 D 可使 A 构建成功。

基于语义的 import 版本管理可避免钻石依赖。D 不再有冲突问题。D 1.3 必须向后兼容 D 1.2,而 D 2.0 则拥有了不同的 import 路径 D/v2

一个程序若使用了依赖包的不同主版本,则会使用其不同 import 路径的包,构建也就能成功。

反对的声音:审美

对应基于语义的 import 版本管理,最常见的反对声音是:人们不喜欢将主版本号添加到 import 路径中。简而言之就是:这样不好看。当然,这其实说明人们还没习惯在 import 路径中看到主版本号。

我想到了 2 个在 Go 代码中发生较大美观变化的例子。这些例子在当时看起来很丑陋,但却被采用了。而因为它们简化了软件工程,现在看起来已经很自然。

第 1 个例子:如何表达 导出 的含义。早在 2009 年初,Go 使用了 export 关键字来标记一个函数为导出的。当时,我们知道需要引入一些轻量级的方式来标记私有的结构体字段,考虑的候选方式包括:

  • 下划线 _ 开头表示不导出
  • 加号 + 开头表示导出

而最终我们选择了 大写字母开头表示导出 的方法。使用大写字母开头来表示导出的含义,我们大家都觉得很奇怪,但除了觉得奇怪,也想不到有想什么其他缺点。若想不到其他缺点,则这种方法是正确的,它满足我们的需求,而且比其他方法更优。因此我们采用了此方法。我记得当年从 fmt.printf 改为 fmt.Printf 时,我也觉得很别扭,或说至少很讨厌:对我来说,fmt.Printf 一点都不像 Go 的风格,至少不像我写过 Go 代码。但我也想不到什么好的反对意见,于是我采纳并实现了此修改。几星期后,我已习惯了这种方式,并且对我来说,fmt.printf 反而变得不像 Go 了。此外,阅读代码时,我开始欣赏这种清晰的表示导出或不导出的方式。而当我回头去看 C++ 或 Java 代码时,我发现已很难一眼看出 x.dangerous() 到底是不是 public 方法。

第 2 个例子:如何表达我在前面提到过的 import 路径。在 Go 的早期,比 goinstallgo get 都要早时,当时的 import 路径不是完整的 URL。开发者必须手动下载一个名为 uuid 的包,然后这样导入 import "uuid"。修改为 URL 表示 import 路径后(import "github.com/google/uuid"),消除了歧义,也促成了 go get 的实现。人们当时也是抱怨这种方法不好,但现在这种更长的 import 路径已让大家觉得理所当然了。我们依赖也喜欢这种精确的表示方式,因为它可让我们的软件工程变得更简单。

不管使用大写字母开头来表示导出,还是使用完整 URL 来表示 import 路径,都是出于对软件工程的考虑,而唯一的反对声音只剩下视觉美学了。我希望 import 路径中引入主版本号也能走同样的路。我们将会适应这种机制,也将能享受到其带来的精确性与简单性。

反对的声音:新的 import 路径

另一个反对的声音是,若将某个模块从 v2 升级到 v3,则需修改所有引用该模块的 import 路径。即使客户端代码本身无需做任何其他修改,也要改动。

没错,升级主版本号确实需要修改 import 路径。但是,其实同时也方便了工具去做全局搜索、替换。这种升级可通过类似 go fix 之类的命令进行,不过我们目前未实现此命令。

上一个反对声音,以及本反对声音,其实都暗示建议将主版本号信息放到一个独立的版本元数据文件中。若我们这样做,import 路径则不能精确地区分不同版本的语义,就像之前的 import "uuid",可能会对应很多很多不同的包。所有的开发者、工具都需依赖这个额外的元数据文件才能知道:

  • 是哪个主版本号?
  • 正在使用哪个 strings.Split 函数?
  • 若复制一个源码文件到其他模块,且忘记检查元数据文件中的版本,会发生什么事情?

若将精确的语义主版本号放到 import 路径中,则开发者、工具都无需用额外的方法来保持主版本号一致。

将语义主版本号放到 import 路径中的另一个好处是:当你将一个包从 v2 升级到 v3 时,你可 渐进地升级你的程序,譬如先只升级 1 个包。这样可清晰知道哪些代码已转换,哪些未转换。

反对的声音:一个构建中包含多个主版本号

另一个反对的声音是,在同一个构建中,应完全禁止 D v1 和 D v2 并存。这样一来,D 的作者就可完全不用考虑多个主版本并存时的复杂情况。例如,可能 D 定义了一个命令行 flag,或注册了一个 HTTP handler。若无明确限制,则 D v1 与 D v2 并存时会导致程序不能运行。

Dep 强制遵循此约束,也有人认为这样更简单。但这种简单性只对 D 的作者有效,而对于 D 的用户来说就不简单了,而且通常用户数远比作者数多。若 D v1 和 v2 不能在同一个构建中并存,则钻石依赖问题就又回来了。你不能像我刚所说那样,在一个大型程序中渐进地将 D v1 升级到 D v2。在互联网规模的项目中,若多个主版本号不能并存,将会导致 Go 包生态系统的割裂,形成破碎的区域:只使用 D v1 的包,或只使用 D v2 的包。详细例子,可见我 2018 年的博客文章 基于语义的 import 版本管理(Semantic Import Versioning)

Dep 之所以在一个构建中强制禁止一个包同时存在多个主版本号,是因为 Go 构建系统要求每个 import 路径来命名唯一的包(Dep 并没有使用基于语义的 import 版本管理)。相对的,Cargo 和其他系统都允许在一个构建中存在一个包的多个不同主版本号。Go 模块之所以允许一个构建中并存同一个包的多个不同主版本号,原因与其他包管理器一样:若不允许多版本并存,大型程序将很难正常运行。

反对的声音:不容易开发实验阶段的包

关于兼容性原则的最后一个反对的声音是:当你刚开始开发一个包时,你没有任何用户,还会经常进行不向后兼容的修改,此时没必要总是在 import 路径中加入主版本号。确实,这种说法没错。

但语义版本管理有个特例,对于主版本号为 0 的版本,不需考虑任何兼容性的预期,所以可快速迭代而不用担心兼容性问题。例如,v0.3.4 无需考虑任何向后兼容性,v0.3.3、v0.0.1、v1.0.0 等也都无需。

基于语义的 import 版本管理有一个简单的例外:主版本号为 0 时无需在 import 路径中体现出来。

上述 2 种场景中,其根本原因是因为时间还未进场,此时你仅仅是在编程,你还没进入到软件工程领域。当然,当你使用别人 v0 版本的包时,应做好随时会有不兼容性 API 修改的情况,且此时 import 路径不会有变化。此时你有责任自行随之更新你的代码。

原则 #2:可复现性

包的任一版本的构建结果都不应随时间而变。

第 2 个原则是程序构建的可复现性。我所说的可复现性是指:一个包的任一版本,其构建应可通过某种方式精确使用指定版本的依赖包,并且不会随时间而变。你今天的构建、明天的构建、甚至明年任何其他开发者的构建,都应是一致的。然而,大部分包管理器都不能保证上述可复现性。

我们已知基于 GOPATH 的 go get 不能保证可复现性。首先,go get 会使用太旧版本的 D:

然后,go get -u 则会使用太新的 D:

你可能会想到:当然啦,go get 连版本的概念都没有,肯定会犯此错误。然而,大部分其他包管理器也有同样的问题。下面我将以 Dep 作为例子,Bundler、Cargo 也有类似的机制。

Dep 会在每个包中寻找 manifest(译注:即 Gopkg.toml)元数据文件,这个文件中包含了每个依赖包的名称及其版本。当 Dep 下载 C 时,它会读取 C 里的 manifest 文件,从而知道 C 依赖了 D 1.4 或更新的版本。然后,Dep 按照约束下载最新版本的 D。当时是昨天,当时 D 最新版是 D 1.5:

而今天,D 的最新版已是 D 1.6:

这种选择与时间有关系,它会随时间而变,所以这种构建是不可复现的。

Dep(Bundler、Cargo 等)的开发者也知道可复现性的重要性,所以引入了第 2 个元数据文件,即 lock 文件。若 C 是可执行的主程序(在 Go 中对应 package main),则 lock 文件中会详细记录 C 的每个依赖包的精确版本号,并且 Dep 会用 lock 覆盖 manifest 中选择的版本。lock 文件这种机制可保证构建时所使用的依赖包版本不会随时间而变化。

但 lock 文件只对主程序有效,如 package main。但若 C 是一个库程序,并且是更大型程序的一个依赖包时,该怎么办?能满足 C 的 lock 文件,不一定可满足更大型程序的额外约束。所以 Dep 以及其他包管理器必须忽略库程序的 lock 文件,并回退到依赖于时间的选择。当你在一个更大型的构建中添加了 C 1.8,则你所得到的依赖包的版本可能会随时间而变化。

总的来说,Dep 依赖于时间来选择 D 的版本。然后,为了可复现性,添加了一个 lock 文件来覆盖依赖于时间的选择。但是,lock 文件只对完整的程序有效,对库无效。

相对的,在 Go 模块中,go 命令会以不随时间变化的方式决定使用 D 的哪个版本。这样一来,构建就可在任何时候复现,无需添加 lock 文件覆盖来增加复杂度,并且也适用于库程序,而不仅仅是主程序。

尽管被称为 最小版本选择算法,但 Go 模块所使用的算法是很简单的。此算法是这样的,每个包会指定所依赖的每个包的最小版本。例如,假设 B 1.3 依赖了 D 1.3 或更新版本,C 1.8 依赖了 D 1.4 或更新版本。在 Go 模块中,go 命令会选择所指定的精确版本,而非最新版本。若单独构建 B,则会使用 D 1.3;若单独构建 C,则会使用 D 1.4。这些库的构建都是可复现的。

如上图所示,若不同的构建部分选择了不同的最小版本,go 命令会选择其中较新的版本。A 的构建依赖了 D 1.3 和 D 1.4,而 1.4 新于 1.3,所以构建会选择 D 1.4。此选择不会因为 D 1.5、D 1.6 的存在而发生变化,所以不会随时间而变。

我将此称为最小版本选择算法有 2 个原因;

  1. 每个包都选择满足请求的最低版本(等效于请求的最大值)
  2. 这似乎是可能可行的最简单方法

最小版本选择为主程序、库程序提供了可复现的构建,并且无需 lock 文件。它摆脱了时间对构建的影响。每个最终选择的版本始终是构建曾选中过的版本之一(译注:即若依赖一个包的不同版本,则选其较新版本)。

反对的声音:使用最新版本才是未来的趋势

优先考虑可复现性时,第 1 个反对的声音是:优先选择最新版本的依赖包是一种功能,而非错误。他们声称,开发者既不想,也懒于定期去更新他们的依赖,所以类似 Dep 的工具会自动选择最新版本的依赖。其中主要的理由是:选择最新版本的好处,大于,缺失可复现性带来的坏处。

但是,这种理由并没有经过检查。类似 Dep 的工具提供了 lock 文件,需开发者自行更新其中的依赖版本,因为可复现性比使用最新版本更重要。当你修复 bug 时只修改了 1 行代码,你会希望仅这一行被修改,而不希望你的依赖变成了不同的更新版本。

你希望延迟更新,除非你主动要求更新,以便在升级依赖版本之前,可以先运行所有单元测试、所有集成测试,甚至灰度测试。所有人都同意这个观点。lock 文件之所以存在,正是因为大家都同意:可复现性比自动升级更重要。

反对的声音:构建库程序时使用最新版本是一种功能

对于最小版本选择,可能还有一些争议。那就是可复现性对于主程序很重要,但对于库程序不那么重要。即对于库程序而言,拥有最新版本的依赖比可复现的构建更重要。

我不同意这种观点。随着程序的变大,需要连接更多的大型库,而这些大型库也是由一堆更小的库组成,由于整个主程序依赖这些库,所以保证库程序的可复现性同样重要。

这种趋势的极限是,最近的云计算转向 无服务器 托管,如 Amazon Lambda、Google Cloud Functions、Microsoft Azure Functions。我们上传到这些系统的代码通常是一个库程序,而不是主程序。我们当然也希望这些服务器上的生产构建,能使用与我们本地开发机相同版本的依赖包。

当然,让开发者更易于定期更新依赖版本也同样重要。我们也需要让工具能输出:

  • 构建或二进制文件中所使用依赖包的版本
  • 依赖包有哪些可用更新版本
  • 正在使用的依赖包版本有没有已知的安全问题

原则 #3:协作

为了维护 Go 的软件包生态系统,我们必须一起协作。工具不能解决缺乏协作的问题。

第 3 个原则是协作。我们经常讨论 Go 的社区Go 的开源生态系统,这里的词 社区(community)生态系统(ecosystem) 强调的是我们的工作是互相连接的:我们的构建总是依赖于别人的贡献。我们的目标是一个统一的系统,是一个连贯的整体。相对的,我们想尽力避免生态系统碎片化,避免被分割为多个不能互相协作的区域。

协作原则认为,大家共同协作是保持生态系统健康、繁荣的唯一途径。若不协作,无论我们工具的技术如何厉害,Go 开源代码生态都必然分裂,最终失败。也就是说,若要解决不兼容性问题,需要互相协作才行。不管怎样,我们都避免不了要共同协作。

举个例子,再来一次,我们有 C 1.8,依赖了 D 1.4 或更新版本。由于可复现性,C 1.8 本身的构建会使用 D 1.4。若 C 作为更大的程序的一部分进行构建,且此更大的程序其他部分依赖了 D 1.5,则最终会选择 D 1.5,这样也没问题。

之后,D 1.6 发布了,并有一些更大的程序可能进行了持续集成测试,发现 C 1.8 不兼容 D 1.6。

不管怎样,最终方法都是让 C 的作者与 D 的作者协作并发布一个修复后的版本。具体修复什么,要看具体哪里出了错。

也许 C 依赖了 D 1.6 中已修复的 bug,也许 C 依赖了 D 1.6 中不明确的修改。则解决办法是,C 的作者应发布一个新的版本 C 1.9,从而能与 D 的演进协作。

又或许只是 D 1.6 有 bug,则解决办法是,让 D 的作者修改并发布为新的 D 1.7。并且 D 1.7 应遵循兼容性原则,使得 C 的作者可依赖 D 1.7 来发布出 C 1.9。

花一分钟时间看看刚刚发生了什么,当时最新版的 C 并没有与最新版的 D 一起运行,这在 Go 生态系统中造成了一点断裂。于是,C 的作者与 D 的作者协作把 bug 修复了,然后生态系统的其他人修复剩下的断裂。这种协作对于保持生态系统的健康至关重要,并且目前技术手段不能自动替代此过程。

Go 模块中的可复现构建意味着,若用户没明确要求,则不会自动选择有 bug 的 D 1.6。这为 C 的作者和 D 的作者协作得出解决方案创造了时间。Go 模块系统不会自动尝试去解决这些暂时不兼容的问题。

反对的声音:使用可声明的不兼容性和 SAT 求解器

对于依赖协作的方法,最常见的反对的声音是:不应期望开发者互相协作。开发者需一些方法来独立修复问题。争议点在于:开发者真正能依赖只有他们自己,而非别人。一些包管理器如 Bundler、Cargo、Dep 给出的解决方案是:允许开发者声明包与包之间的不兼容性,并利用 SAT 求解器 寻找不超出约束的包组合。

有几个原因可说明这种观点不成立。

第一,Go 模块使用的算法 可让开发者完全控制为该模块选择哪个版本,比 SAT 约束拥有更多的控制权。开发者可强制要求使用指定依赖包的任意版本:不管别人怎么说,我就要使用这个版本。但这种控制权也仅限于当前模块的构建(译注:若此模块被更大的程序依赖,则在更大的程序中,本模块的约束无效),以免其他开发者控制你的程序的构建。

第二,如前面小节所说的,Go 模块中库程序构建的可复现性意味着:即使依赖包发布了新的不兼容版本,也不会立即影响你的构建。这种破坏性修改只会在用户添加此新版本到其构建中时才会造成影响,而此时他们还可以选择回退版本。

第三,若利用 SAT 求解器来解决版本选择问题,则通常会出现很多可能满足的选择组合:STA 选择器必须从这些组合中选择一个,但却没有明确的优先级。然而,如前所见,基于 SAT 求解器的包管理器通常会优先从多个可选的有效组合中选择最新的版本。这种情况下,很明显,尽量选择满足约束的最新版本是所谓的 "最佳答案"。但再想一下这种情况,最新的 B + 较旧的 C较旧的 B + 最新的 C,这 2 种该选择哪一种?开发者如何才能预判选择结果?这会导致系统的结果难以理解。

第四,SAT 求解器的输出最多只能与输入一样好:如果已去掉所有不兼容的版本,则 SAT 求解器依然可能会选择出有问题的组合,只是这种组合没明确知道是有问题而已。对于出现时间不同的依赖组合,其中的不兼容性可能尤其明显,这些依赖甚至从未曾组合在一起过。确实,一份关于 Rust 的 Cargo 生态系统的分析发现,Cargo 关于最新版本的清单文件配置中 漏掉了很多约束。若最新版本不能跑通,那尝试更旧的版本的话,似乎能得到 尚未知跑不通 的组合,就像得到了可跑通的配置一样(译注:说明这样不可靠)。

总之,一旦你不再优先选择最新版本的依赖包,基于 SAT 求解器的包管理器就不会比 Go 模块更可能选择出一种有效组合方案。如果真有有效组合方案的话,SAT 求解器找出有效的组合方案的可能性更低。

实例:Go 模块方案 vs 基于 SAT 的求解方案

上一节的反对声音可能有些抽象,这一节将继续利用上面例子来说明具体问题,看看使用 SAT 求解器(如 Dep)的话会发生什么事情。我将使用 Dep 作为具体工具,因为 Dep 是 Go 模块机制的前任方案。这里我不是只说 Dep,而是指类似 Dep 的所有包管理器。就本例子而言,Dep 与很多其他包管理器的工作机制类似,并且它们都有同样的问题。

首先,请记住 C 1.8 兼容 D 1.4 和 D 1.5,但不兼容 D 1.6。

持续集成测试可能会发现这个问题,但关键在于后面会发生什么事。

当 C 的作者发现 C 1.8 不兼容 D 1.6,Dep 允许并鼓励他发布新版本 C 1.9。C 1.9 已标注说明只兼容 D 1.4(不含)之后且 D 1.6(不含)之前的版本。这种可声明不兼容性的机制可帮助 Dep 在日后的构建中避开这些不兼容版本。

对于 Dep 来说,避开不兼容版本是很重要的,甚至是十分迫切的。对于库程序,由于缺少可复现性,这就意味着一旦 D 1.6 发布了,C 的所有使用 D 1.6 的新构建都会失败。这是很严重的构建问题:所有新的 C 用户都会构建失败。若一时找不到 D 的作者,或者 C 的作者暂时没时间去修复这个 bug,争议在于,必须得让 C 的作者寻求一种方法来让用户避免构建失败。这种方法就是发布 C 1.9,并明确指明与 D 1.6 不兼容,这样才能避免 C 的用户使用不兼容的 D 1.6。

若使用 Go 模块机制,由于最小版本选择以及可复现性机制,可保证构建不会出现这种问题。使用 Go 模块机制时,D 1.6 的发布不会影响到 C 的用户,因为此时依然没有任何 C 依赖 D 1.6。C 的用户依然继续使用旧版本的 D。这就无需明确去声明不兼容性,因为没有构建失败。而对于这个不兼容问题,只需留些时间去修改就行(译注:即用户的构建从不会受到不兼容性的影响)。

再来看看 Dep 标注不兼容性的方法,发布 C 1.9 并不是最佳的解决方案。首先,前提是 D 的作者发布 D 1.6 后造成了构建失败情况,然后 D 的作者没空去发布新的修复版本,此时,很有必要让 C 的作者通过某种方式修复此问题,并发布为 C 1.9。但问题是,如果 D 的作者没空怎么办?更严重的是,若连 C 的作者也没空怎么办?由自动升级依赖包导致的构建失败情况将会持续,并且所有 C 的用户的构建都将失败。Go 模块机制中的可复现构建可完全避免这种问题。

还有,假设是 D 有 bug,D 的作者修复了 bug 并发布为 D 1.7。而 C 1.9 要求 D 的版本低于 1.6,所以 C 不会使用已修复 bug 的 D 1.7。为了能用上 D 1.7,C 的作者必须做些修改并发布为 C 1.10。

相对的,若我们使用 Go 模块机制,C 的作者无需发布 C 1.9,也无需发布 C 1.10 以使用更高版本。

在这个简单的例子中,Go 模块机制可比 Dep 更能让用户工作得更平滑。此机制可避免构建突然自动失败,为协作修复问题创造了时间空隙。理想情况下,在 C 的用户注意到之前,C 或 D 已修复问题。

但若有更复杂的情况,怎么办?Dep 那种标注不兼容性的方式在更复杂的情况下可能更好,又或者,需很长时间才能修复问题。

我们来看看更复杂的例子。让时间回退一点,回退到 有 bug 的 D 1.6 发布之前,我们来对比一下此时 Dep 与 Go 模块机制的不同之处。下图展示了所有包版本的依赖关系,以及,Dep 和 Go 模块机制分别如何构建最新版的 C 和最新版的 A。

Dep 使用了 D 1.5,而 Go 模块系统使用了 D 1.4(译注:注意上图左侧的 Requirements,Dep 和 Go 模块都是以此为基础),但都能得到可运行的构建结果。

但是,假设现在有 bug 的 D 1.6 发布了。

Dep 构建时会自动选择 D 1.6,并且不能正常工作。Go 模块构建时依然使用 D 1.4,并能继续工作。这正是我们刚才看到的简单场景。

不过,在继续之前,我们先修复一下 Dep 的构建。我们发布了 C 1.9,标注不兼容 D 1.6。

现在 Dep 构建时会自动选择 C 1.9,并又可正常工作了。Go 模块无需如此标注不兼容性,但 Go 模块的构建依然正常工作,所以无需任何修复。

现在我们来创建一个复杂的场景来让 Go 模块不能工作。我们可以做以下步骤:

  1. 发布新的 B,并依赖了 D 1.6
  2. 发布新的 A,并依赖了 B。即此时 A 的构建将使用 C,以及 D 1.6,而且不能正常工作

我们首先需发布依赖 D 1.6 的 B 1.4。

得益于可复现性,Go 模块的构建到目前依然不受影响。但请注意!Dep 对 A 的构建会自动选择 B 1.4,并再次不能正常工作了。这其中到底发生了什么?

构建 A 时,Dep 优先选择最新版的 B 和最新版的 C,但实际却不可能:最新版的 B 依赖了 D 1.6,但最新版的 C 却依赖低于 1.6 的 D。那此时 Dep 放弃了吗?并没有。Dep 会继续寻找 B、C 的其他版本,以找到 D 的合适版本。

此时,Dep 选择继续使用最新版的 B,即使用 D 1.6,即表示不使用 C 1.9。由于 Dep 不能使用最新版的 C,所以会尝试寻找较旧版本的 C。C 1.8 看似不错:它声明依赖 D 1.4 或更新版本,也即包括 D 1.6。于是,Dep 使用了 C 1.8,于是,又不能正常工作了。

我们知道 C 1.8 与 D 1.6 不兼容,但 Dep 却不知道。Dep 之所以不知道,是因为 C 1.8 在 D 1.6 之前就已发布了:C 的作者无法预知 D 1.6 会有问题。所有包管理器都一致认为已发布的版本不应再被修改,所以 C 的作者无法再将已发布的 C 1.8 修改为标记不兼容 D 1.6(而若允许修改已发布的 C 1.8,则又会打破可复现性)。所以只能发布新的 C 1.9 来修复。

由于 Dep 优先选择最新版本的 C,所以 Dep 大部分时间都会使用最新版的 C 1.9,知道应避开 D 1.6。但一旦 Dep 不能使用任何最新版本(译注:可能最新版有 bug 而不能用),则会尝试更早的版本,也就可能会包括 C 1.8。即使我们已知道还有更好的方案,但 C 1.8 依然认为 D 1.6 没问题,于是构建失败了。

又或者可能构建没失败。准确来说,Dep 没有做这样的选择。当 Dep 发现不能同时使用最新版的 B 和最新版的 C 时,Dep 还有很多可能的处理方式。之前是假设 Dep 使用最新版本的 B,但这次 Dep 改为使用最新版本的 C,则会使用更旧版本的 D 和更旧版本的 B,得到成功的构建,如上图第 3 列所示。

所以,Dep 的构建可能会失败,也可能会成功,这有点随机,取决于 基于 SAT 求解器的版本选择

最近我试了一下,当遇到需要从两个包的最新版本中选择其中一个的情况时,Dep 会优先选择 import 路径字母顺序靠前的那个。

这个例子演示了 Dep 及类似包管理器(不含 Go 模块机制)所得到的奇怪结果:当最优先的方案(所有都使用最新版本)不成功时,其他候选方案都没有明确的优先级。最终的具体方案依赖于 SAT 求解器的算法、启发法,甚至包的输入顺序等的内部细节。SAT 求解器的这种描述不足和不确定性,正是这些包管理器需要 lock 的另一个原因。

无论如何,为了 Dep 用户的方便,让我们假设 Dep 碰巧选择了可正确构建的方案。但毕竟我们仍可继续尝试破坏 Go 模块用户的构建。

为了破坏 Go 模块的构建,我们发布一个新版本的 A,假设为 A 1.21。A 1.21 依赖最新版本的 B,也即间接依赖了最新版本的 D。现在,go 命令构建最新版本的 A 时,将强制使用最新版的 B 和最新版的 D。在 Go 模块中,没有 C 1.9,所以使用 C 1.8。而 C 1.8 不兼容 D 1.6,终于,我们让 Go 模块构建失败了。

但请注意,Dep 的构建也使用了 C 1.8 和 D 1.6,所以构建也失败了。此前,Dep 需在最新版的 B 和最新版的 C 之间做一个选择。若选择了最新版的 B,则构建失败;若选择最新版的 C,则构建成功。A 的新需求清单强制要求 Dep 同时使用最新版的 B 和最新版的 D,从而无法选择最新版的 C。所以 Dep 最终使用较旧的 C 1.8,构建如之前那样失败了。

从这些场景来看,我们可得出怎样的结论?首先,Dep 标记不兼容版本的方法,并不能保证完全避免不兼容问题。其次,类似 Go 模块的的可复现构建也不能保证避免不兼容问题。这两种工具最终都在构建不兼容的包。但正如我们所见,需经过一些特意的步骤才能让 Go 模块的构建失败,同样的步骤也能让 Dep 的构建失败。对于同样的几个场景,Dep 的构建方法多失败了 2 次,而 Go 模块的构建则成功。

我之所以在这些例子中使用 Dep,是因为 Dep 是 Go 模块机制的前任方案,但我不是只说 Dep。在这方面,Dep 与很多其他包管理器的工作机制类似,并且它们都有同样的问题。这些包管理器实际使用时,并没有太多构建失败。Dep 的设计虽然不太合理,但也很少经常失误。这些包管理器旨在解决各种软件包维护者之间缺乏协作的问题,但实际上工具并不能代替协作。

对于 C 与 D 的不兼容问题,唯一的实际解决办法是发布一个 C 或 D 的新修复版本。尽量避免不兼容问题,都只是为了给 C 的作者或 D 的作者多争取一些协作修复问题的时间(译注:修改过程中用户的构建不受影响)。

Dep 的机制:

  • 优先选择最新版本的依赖包
  • 允许标记不兼容版本

Go 模块的可复现构建机制:

  • 基于最小版本选择来选择依赖包的版本
  • 无需标记不兼容版本
  • 自动为协作创造时间空隙
  • 不会产生构建紧急问题
  • 无需用户做什么额外工作

所以,我们可以依赖协作来真正修复问题。

总结

Go 的模块机制有 3 个区别于其他包管理器(Dep、Cargo、Bundler 等)的原则:

  • 兼容性原则
    • 程序中的名称代表的功能不应随时间而变化
  • 可复现性原则
    • 一个包的任何版本的构建结果不应随时间而变化
  • 协作原则
    • 为维护好 Go 包的生态系统,我们都必须互相协作。单靠工具不靠协作,是不能解决问题的。

之所以有这些原则,都是出于对软件工程的考虑。编程时,当你增加了时间、其他开发者这些维度时,编程就变成了软件工程:

  • 兼容性原则,用于消除时间维度对程序含义的影响
  • 可复现性原则,用于消除时间维度对构建结果的影响
  • 协作原则,表明一种明确的共识,无论我们的工具如何先进,我们都依然必须与其他开发者协作

这 3 个原则是一个相辅相成的良性循环:

  • 兼容性原则,促成了新的版本选择算法,提供可复现性
  • 可复现性原则,保证了若非明确请求,会自动忽略新发布的版本(不管有没有 bug),为协作修复问题创造了时间空隙
  • 协作原则,反过来重新建立了兼容性

而且,上述循环会如此不断。

从 Go 1.13 开始,Go 模块机制已可用于生产环境,并且很多公司包括 Google 都已接受了此机制。Go 1.14 和 Go 1.15 将带来更多人类工程学的相关改进,最终将废弃、移除 GOPATH 机制。若想了解更多模块机制的信息,可见 Go 官方博客中的系列文章,从这篇开始:Using Go Modules