Go 博客

Go 语言包版本控制提案

Russ Cox
2018 年 3 月 26 日

引言

八年前,Go 团队引入了 goinstall(后来发展为 go get),以及 Go 开发者今天熟悉的去中心化、类似 URL 的导入路径。在发布 goinstall 之后,人们提出的第一个问题之一是如何整合版本信息。我们承认我们不知道。很长一段时间,我们认为包版本控制问题最好由一个附加工具来解决,我们鼓励人们创建这样的工具。Go 社区创建了许多采用不同方法的工具。每个工具都帮助我们更好地理解了这个问题,但到 2016 年年中,很明显解决方案太多了。我们需要采用一个单一的、官方的工具。

在 2016 年 7 月的 GopherCon 上开始,并持续到秋天的社区讨论之后,我们都认为答案应该是遵循 Rust 的 Cargo 中体现的包版本控制方法,包括带标签的语义版本、清单文件、锁文件以及一个 SAT 求解器 来决定使用哪个版本。Sam Boyer 带领一个团队创建了 Dep,它遵循了这个大致的计划,我们打算将其作为 go 命令集成的模型。但是,随着我们更多地了解 Cargo/Dep 方法的影响,我越来越清楚,Go 将受益于更改某些细节,尤其是在向后兼容性方面。

兼容性的影响

Go 1 最重要的新特性不是语言特性。它是 Go 1 对向后兼容性的重视。在那之前,我们大约每月发布一次稳定的发行版快照,每个版本都有重大的不兼容更改。我们观察到在 Go 1 发布后,人们的兴趣和采用率立即显著加速。我们相信,兼容性承诺 使开发人员对将 Go 用于生产环境更有信心,并且是 Go 今天流行的关键原因。自 2013 年以来,Go 常见问题解答 一直鼓励包开发者为自己的用户提供类似的兼容性期望。我们称之为导入兼容性规则:“如果一个旧包和一个新包具有相同的导入路径,则新包必须与旧包向后兼容。”

独立地,语义版本控制 已经成为许多语言社区(包括 Go 社区)中描述软件版本的事实标准。使用语义版本控制,后来的版本预计与早期的版本向后兼容,但仅限于单个主版本:v1.2.3 必须与 v1.2.1 和 v1.1.5 兼容,但 v2.3.4 无需与任何这些版本兼容。

如果我们像大多数 Go 开发者预期的那样,为 Go 包采用语义版本控制,那么导入兼容性规则要求不同的主版本必须使用不同的导入路径。这一观察结果导致了语义导入版本控制,其中从 v2.0.0 开始的版本在导入路径中包含主版本:my/thing/v2/sub/pkg

一年前,我坚信是否在导入路径中包含版本号很大程度上是个人喜好问题,并且我怀疑包含版本号是否特别优雅。但事实证明,这个决定不是个人喜好问题,而是逻辑问题:导入兼容性和语义版本控制一起要求语义导入版本控制。当我意识到这一点时,逻辑上的必然性让我感到惊讶。

我还惊讶地意识到,存在另一条独立的逻辑途径可以实现语义导入版本控制:逐步代码修复 或部分代码升级。在一个大型程序中,期望程序中的所有包都同时从某个依赖项的 v1 升级到 v2 是不现实的。相反,必须允许程序的某些部分继续使用 v1,而其他部分已升级到 v2。但随后,程序的构建以及程序的最终二进制文件必须同时包含依赖项的 v1 和 v2。为它们提供相同的导入路径会导致混淆,违反了我们可能称之为的导入唯一性规则:不同的包必须具有不同的导入路径。实现部分代码升级、导入唯一性以及语义版本控制的唯一方法是也采用语义导入版本控制。

当然,可以构建使用语义版本控制而无需语义导入版本控制的系统,但前提是必须放弃部分代码升级或导入唯一性。Cargo 通过放弃导入唯一性来允许部分代码升级:给定的导入路径在大型构建的不同部分可以具有不同的含义。Dep 通过放弃部分代码升级来确保导入唯一性:参与大型构建的所有包必须找到给定依赖项的单个商定版本,从而增加了大型程序可能无法构建的可能性。Cargo 正确地坚持部分代码升级,这对于大型软件开发至关重要。Dep 同样正确地坚持导入唯一性。Go 当前供应商支持的复杂用法可能会违反导入唯一性。当它们违反时,由此产生的问题对开发人员和工具来说都非常难以理解。在部分代码升级和导入唯一性之间做出选择需要预测放弃哪一个会造成更大的伤害。语义导入版本控制使我们能够避免这种选择,并保留两者。

我还惊讶地发现,导入兼容性在多大程度上简化了版本选择,版本选择是为给定构建决定使用哪些包版本的问题。Cargo 和 Dep 的约束条件使得版本选择等同于 求解布尔可满足性问题,这意味着确定是否存在有效的版本配置可能非常昂贵。然后可能存在许多有效的配置,并且没有明确的标准来选择“最佳”配置。相反,依赖导入兼容性可以让 Go 使用一个简单的线性时间算法来查找唯一的最佳配置,该配置始终存在。这个我称为 最小版本选择 的算法,反过来消除了对单独的锁文件和清单文件的需求。它用一个简短的配置文件替换它们,该文件由开发人员和工具直接编辑,并且仍然支持可重复构建。

我们使用 Dep 的经验证明了兼容性的影响。在 Cargo 和早期系统的引导下,我们设计 Dep 在采用语义版本控制的同时放弃导入兼容性。我不认为我们故意这样决定;我们只是遵循了其他系统。使用 Dep 的第一手经验帮助我们更好地理解了允许不兼容导入路径会产生多少复杂性。通过引入语义导入版本控制来恢复导入兼容性规则消除了这种复杂性,从而导致了一个更简单的系统。

进展、原型和提案

Dep 于 2017 年 1 月发布。其基本模型——用语义版本标记的代码,以及指定依赖项需求的配置文件——与大多数 Go 供应商工具相比,是向前迈出的明确一步,并且向 Dep 本身靠拢也是向前迈出的明确一步。我衷心鼓励采用它,特别是帮助开发人员习惯于考虑 Go 包的版本,包括他们自己的代码和它们的依赖项。虽然 Dep 显然正在将我们引向正确的方向,但我对细节中的复杂性问题仍有一些疑虑。我特别担心 Dep 缺乏对大型程序中逐步代码升级的支持。在 2017 年期间,我与许多人进行了交谈,包括 Sam Boyer 和包管理工作组的其他成员,但我们都没有看到任何减少复杂性的明确方法。(我确实发现了很多增加了复杂性的方法。)接近年底时,SAT 求解器和不可满足的构建似乎仍然是我们所能做到的最好结果。

11 月中旬,我再次尝试解决 Dep 如何支持逐步代码升级的问题时,意识到我们关于导入兼容性的旧建议暗示了语义导入版本控制。这似乎是一个真正的突破。我写了第一份草稿,后来成为我的 语义导入版本控制 博客文章,并在文章结尾建议 Dep 采用此约定。我将草稿发送给了我一直在与之交谈的人,它引发了非常强烈的反应:每个人都喜欢它或讨厌它。我意识到在进一步传播这个想法之前,我需要弄清楚语义导入版本控制的更多含义,我着手去做。

12 月中旬,我发现导入兼容性和语义导入版本控制一起允许将版本选择简化为 最小版本选择。我编写了一个基本的实现以确保我理解它,我花了一些时间学习它如此简单的理论基础,并写了一份描述它的文章草稿。即便如此,我仍然不确定这种方法在像 Dep 这样的真实工具中是否实用。很明显需要一个原型。

1 月份,我开始着手开发一个简单的 go 命令包装器,它实现了语义导入版本控制和最小版本选择。简单的测试运行良好。接近月底时,我的简单包装器可以构建 Dep,这是一个真正使用了许多版本化包的程序。包装器仍然没有命令行界面——它构建 Dep 的事实硬编码在几个字符串常量中——但这种方法显然是可行的。

我在二月份的前三周将包装器变成了一个完整的版本化的go命令,即vgo;撰写了介绍vgo博文系列草稿;并与Sam Boyer、包管理工作组和Go团队进行了讨论。然后,我在二月的最后一周终于与整个Go社区分享了vgo及其背后的理念。

除了导入兼容性、语义导入版本控制和最小版本选择等核心思想之外,vgo原型还引入了一些较小但意义重大的更改,这些更改源于八年来使用goinstallgo get的经验:新的Go模块概念,它是一个作为单元进行版本控制的包集合;可验证和已验证的构建;以及贯穿go命令的版本感知,从而能够在$GOPATH之外工作并消除(大多数)vendor目录。

所有这些工作的成果就是Go官方提案,我上周提交了该提案。即使它看起来像一个完整的实现,它仍然只是一个原型,我们需要共同努力才能完成它。您可以从golang.org/x/vgo下载并尝试vgo原型,并且可以阅读版本化Go之旅以了解使用vgo的感受。

前进的道路

我上周提交的提案正是如此:一个初步提案。我知道其中存在Go团队和我无法看到的某些问题,因为Go开发者以我们不知道的许多巧妙方式使用Go。提案反馈流程的目标是让我们共同努力,找出并解决当前提案中的问题,确保在未来Go版本中发布的最终实现能够尽可能多地满足开发者的需求。请在提案讨论问题上指出问题。随着反馈的到来,我将持续更新讨论摘要常见问题解答

为了使该提案获得成功,整个Go生态系统——特别是当今主要的Go项目——将需要采用导入兼容性规则和语义导入版本控制。为了确保顺利进行,我们还将通过视频会议与对如何将新的版本控制提案纳入其代码库或对他们的体验有反馈的项目进行用户反馈会议。如果您有兴趣参加此类会议,请发送电子邮件至Steve Francia,地址为[email protected]

我们期待(最终!)为Go社区提供一个关于如何将包版本控制纳入go get的唯一官方答案。感谢所有帮助我们走到这一步的人,以及所有将在未来帮助我们的人。我们希望,在你们的帮助下,我们可以发布Go开发者会喜欢的东西。

下一篇文章:Go的新品牌
上一篇文章:Go 2017年调查结果
博客索引