[译] Go += 包版本

521 阅读24分钟
原文链接: lingchao.xin

本文译自 Go += Package Versioning 版权@归原文所有.

我们需要将包版本控制添加到 Go.

更确切地说, 我们需要将软件包版本的概念添加到 Go 开发人员和我们的工具的工作词汇表中, 以便在彼此交谈时准确地确定应该构建, 运行或分析哪个程序. go 命令需要能够告诉开发人员具体哪些版本的软件包在特定构建中, 反之亦然.

版本控制可以让我们启用可重复构建, 所以如果我告诉你试用我的程序的最新版本, 我知道你将不仅获得我的代码的最新版本, 而且还包括我的代码所依赖包的精确的同一版本, 所以你和我将构建完全等价的二进制.

版本控制还可以让我们确保明天程序的构建方式与今天的完全相同. 即使有更新版本的依赖, go 命令也不应该使用它们除非被要求这样做.

尽管我们必须添加版本控制, 但我们也不能移除当前 go 命令的最佳部分: 简单, 速度以及易懂. 当前, 许多程序员大多不关注版本控制, 而且大部分都工作良好. 如果我们正确地获得了模型和默认值, 我们应该能够以这样的方式添加版本控制, 即程序员仍然大多不关注版本控制, 并且一切都更好, 而且更易于理解. 现有的工作流程应该尽可能少地改变. 发布新版本应该非常简单. 一般来说, 版本管理工作必须从后台淡出, 而不是日常关注.

简而言之, 我们需要添加软件包版本控制, 但我们还不能破坏 go get. 这篇文章草拟了一个做这件事的恰到好处的提案, 以及今天就可以尝试的原型演示, 希望这将成为最终 go 命令集成的基础. 我打算将这篇文章作为有效讨论什么可行以及什么不可行的开始. 基于这一讨论, 我将对提案和原型进行调整, 然后我将提交一份正式的 Go 提案, 作为可选功能集成到 Go 1.11 中.

这个提案保留了 go get 最好的部分, 增加了可重复构建, 采用了语义版本化, 消除了 vendoring, 弃用了 GOPATH 转而采用了基于项目的工作流程, 并且提供了一个从 dep 和其前任们的平滑迁移. 也就是说, 这个提案还处于早期阶段. 如果细节还不正确, 我们会花时间在集成进 Go 主发行版之前修复它们.

背景

在我们看这个提案之前, 让我们看看我们做到了什么. 这可能有点长, 但历史对当下有重要的启示, 并有助于理解提案为什么会作出改变. 如果你不耐烦, 请跳到提案或阅读附带的示例博客文章.

Makefiles, goinstall, 以及 go get

2009 年 11 月, Go 的最初版本是一个编译器, 链接器和一些库. 你必须运行 6g 和 6l 编译和链接你的程序, 并且我们包含了示例 makefile(s). 在大多数情况下, 有一个最小化的 gobuild 封装可以构建单个包并写入一个合适的 makefile. 没有既定的方式与其他人分享代码. 我们知道还需要更多的东西, 但是我们已经发布了我们所有的东西, 并计划在社区中完成剩下的工作.

2010 年 2 月, 我们提出了 goinstall 一个新的零配置命令, 用于从源码控制仓库(如 Bitbucket 和 GitHub )下载软件包. Goinstall 引入了今天开发人员认为理所当然的导入路径约定. 由于当时没有任何代码遵循这些约定, 所以 goinstall 起初只能处理除标准库之外没有任何其他包导入的软件包. 但是开发人员很快从他们自己的各种命名方案转移到了我们今天所知道的统一约定, 并且已发布的 Go 包集合发展成为一个连贯的生态系统.

Goinstall 也消除了 makefile 以及与之相关的用户定义构建变动的复杂性. 尽管对于包作者在每次构建期间不能生成代码来说有时不方便, 但这种简化对于包用户来说非常重要: 用户不必担心首次安装与构建之前使用的包作者相同的一组工具. 简化对于工具来说也是至关重要的. makefile 是编译包的一个必要的步骤, 反向工程如何应用不同的工具, 如 go vet 或代码完成, 到相同的包, 可能会非常困难. 即使正确地获得构建依赖关系, 以便在必要时重新构建包, 并且只在必要时重新构建包, 这对于任意 makefile 非常困难. 虽然有些人在灵活性被剥夺的时候表示反对, 但回想起来, 这些好处远远超过了不便之处.

2011 年 12 月, 作为 Go 1 准备工作的一部分, 我们推出了 go 命令, go get 替换了 gobuild .

总体而言, go get 具有变革意义, 可让 Go 开发人员共享源代码并构建彼此的工作, 并通过在 go 命令内部隔离构建系统的细节来启用工具. 但是 go get 缺少任何版本控制的概念. 很明显在第一次 goinstall 讨论中我们需要做一些关于版本控制的内容. 不幸的是, 至少对于我们在 Go 团队中, 我们不清楚究竟该怎么做. 当 go get 需要一个包时, 它总是获取最新的副本, 将下载和更新操作委托给像 Git 或 Mercurial 这样的版本控制系统. 软件包版本的这种无知导致了至少两个显著的缺陷.

版本和 API 稳定性

go get 的第一个显著缺点是, 如果没有版本控制的概念, 它就不能告诉用户对给定更新期望发生什么样的变化.

2013 年 11 月, Go 1.2 添加了一个关于包版本控制的常见问题解答条目, 提供了基本的建议 (Go 1.10也没有变):

打算供公众使用的软件包应该尽量保持向后兼容性. Go 1 兼容性准则在这里是一个很好的参考: 不要删除导出的名称, 鼓励标记的复合文字等等. 如果需要不同的功能, 请添加新名称而不是更改旧名称. 如果需要完全打破兼容性, 请创建新的导入路径的软件包.

2014 年 3 月, Gustavo Niemeyer 创建了 gopkg.in, 广而告之为 "Go语言的稳定API". 该域名是一个版本感知的 GitHub 重定向器, 允许导入路径, 如 gopkg.in/yaml.v1 和 gopkg.in/yaml.v2 引用单个 Git 仓库的不同提交 (可能位于不同分支上) . 在语义版本化之后, 包作者需要在进行重大更改时引入一个新的主要版本, 以便较早版本的 v1 导入路径可以作为先前版本的替代版本, 而 v2 导入路径可能是完全不同的 API.

2015 年 8 月, Dave Cheney 提出采用语义版本化的提案. 这在接下来的几个月里引发了一场有趣的讨论, 其中每个人似乎都认为, 使用语义版本标记代码似乎是一个好主意, 但没有人知道下一步: 这些版本应该使用什么工具 ?

任何关于语义版本的讨论都不可避免地包含引用海勒姆法则的反驳, 其中指出:

拥有足够数量的 API 用户, 无论你在合同中承诺什么, 都无关紧要. 系统中所有可观察到的行为都将被某人依赖.

虽然海勒姆法则在经验上是真实的, 但是语义版本化仍然是一个有用的方法来构建对发布之间关系的期望. 从 1.2.3 更新到 1.2.4 不应该破坏你的代码, 而从 1.2.3 更新到 2.0.0 是可能的. 如果代码在更新到 1.2.4 后停止工作, 作者很可能会欢迎 bug 报告并在 1.2.5 中发布修复. 如果你的代码在更新到 2.0.0 之后停止工作(甚至编译), 那么这种改变更有可能是故意的, 并且在 2.0.1 中修复你的代码的机会相对较少.

我没有从海勒姆法则中得出结论: 语义版本化是不可能的, 我认为构建应该小心地使用作者所做的每个依赖的完全相同的版本, 除非被迫. 也就是说, 构建应该默认为尽可能重现.

Vendoring 以及可重复性构建

go get 的第二个显著的缺点是, 如果没有版本控制的概念, 它不能确保甚至不能表达可重复构建的想法. 无法确定你的用户正在编译你的代码所依赖的相同版本. 2013 年 11 月, Go 1.2 常见问题解答还添加了以下基本建议:

如果你使用的是外部提供的软件包, 并担心它可能会以意想不到的方式更改, 最简单的解决方案是将其复制到本地仓库. (这是 Google 内部采取的方法.) 将副本存储在新的导入路径下, 以将其标识为本地副本. 例如, 你可能会复制 "original.com/pkg" 到 "you.com/external/original.com/pkg". Keith Rarick 的 goven 是帮助实现这一过程自动化的工具之一.

Goven, Keith Rarick 于 2012 年 3 月开始创建, 它将依赖项复制到你的仓库中, 并更新其中的所有导入路径以反映新位置. 以这种方式修改依赖项的源代码对于构建它而言是必要的, 但也是不幸的. 这些修改使得它难以与使用该依赖关系的其他复制代码进行比较和合并更新的副本和所需的更新.

Keith 在 2013 年 9 月宣布了 godep: "一个冻结软件包依赖关系的新工具". godep 的主要进展是添加我们现在所理解的 Go vendoring - 即将依赖复制到项目中, 而不修改源文件 - 没有直接工具链通过以某种方式设置 GOPATH 来支持.

2014 年 10 月, Keith 建议在 Go 工具链中增加对 "外部软件包" 概念的支持, 以便工具可以更好地理解使用该约定的项目. 届时, 有许多类似于godep的努力. Matt Farina 写了一篇博客文章: "Glide In The Sea of Go Package Managers", 将最新的包管理器特别是 glide 与 godep 进行比较.

2015 年 4 月, Dave Cheney 介绍了 gb 一个 "基于项目的构建工具......允许通过源代码 vendoring 重复构建", 而且无需重新导入. (gb 的另一个动机是避免将代码存储在 GOPATH 中的特定目录中, 这对于许多开发人员的工作流程来说不太适合.)

那年春天, Jason Buberel 对 Go 包管理进行了调查, 以了解可以采取什么措施来统一这些多重努力, 避免重复和浪费工作. 他的调查在 Go 团队中清楚地表明, go 命令需要直接支持 vendoring 而不是重写 import . 与此同时, Daniel Theophanes 开始了一个文件格式规范, 以描述 vendor 目录中确切的出处和代码版本. 2015 年 6 月, 我们接受 Keith 的提案作为 Go 1.5 vendor 实验方式, 在 Go 1.5 中可选, 并在 Go 1.6 中默认启用. 我们鼓励所有 vendoring 工具作者与 Daniel 合作采用单一元数据文件格式.

将 vendoring 概念纳入 Go 工具链允许程序分析工具如 go vet 使用 vendoring 更好地理解项目, 现在有十几个 Go 软件包管理器或 vendoring 工具来管理 vendor 目录. 另一方面, 因为这些工具都使用不同的元数据文件格式, 所以它们不能互操作, 并且不能轻松共享关于依赖需求的信息.

从根本上说, vendoring 是解决软件包版本问题的不完整方案. 它只提供可重复性构建. 它没有帮助理解软件包版本并决定使用哪个版本的软件包. 软件包管理器如 glide 和 dep 将版本控制的概念隐式地添加到 Go 中, 通过以某种方式设置 vendor 目录而无需直接的工具链支持. 因此, Go 生态系统中的许多工具无法正确识别版本. 很明显, Go 需要对软件包版本提供直接的工具链支持.

官方包管理实验

在 2016 年 GopherCon 大会上, 一群有趣的 gophers 在 Hack Day (现在是 Community Day) 聚在一起, 围绕 Go 包管理进行广泛的讨论. 其中一个成果是成立了一个委员会和一个包管理工作咨询小组, 目标是为 Go 包管理创建一个新工具. 愿景是为了统一和取代现有的工具, 但它仍然在直接工具链之外使用 vendor 目录来实现. 由 Peter Bourgon 组织的委员会 Andrew Gerrand, Ed Muller, Jessie Frazelle 和 Sam Boyer 起草了一份规范, 然后由 Sam 领导, 将其实施为 dep. 有关背景信息, 请参阅 Sam 的 2016 年 2 月发布的文章: "所以你想写一个包管理器", 他 2016 年 12 月发布的 "Go 的依赖管理传奇" 和他 2017 年 7 月的 GopherCon 演讲 "Go包管理的新时代".

Dep 有许多用途: 它是对当前可用的实践的重要改进, 它是朝着解决方案迈出的重要一步, 也是一个实验 - 我们称之为 "官方实验" - 帮助我们更多地了解什么是对 Go 开发者友好的以及哪些不是. 但 dep 不是最终集成了包版本控制的 go 命令的直接原型. 它是一种强大的, 几乎任意灵活的方式来探索设计空间, 在构建 Go 程序时扮演像 makefiles 一样的角色. 但是, 一旦我们更好地理解设计空间并将其缩小到必须支持的几个关键特性, 它将帮助 Go 生态系统移除其他特性, 降低表现力, 采用强制性约定, 使 Go 代码库更加均匀并且更容易理解并使工具更易于构建.

这篇文章是 dep 之后下一步的开始: 最终 go 命令集成原型的初稿, 即 goinstall 的包管理的等价物. 原型是我们所称的独立命令 vgo. 它是 go 命令的直接替代品, 但它增加了对软件包版本控制的支持. 这是一个新的实验, 我们将看到我们可以从中学到什么. 就像我们介绍 goinstall 的那样, 当前一些代码和项目已经可以使用 vgo, 其他项目需要进行更改才能兼容. 我们将拿走一些控制和表现力, 就像我们拿走了 makefile 一样, 为简化系统和消除用户复杂性服务. 通常,我们正在寻找早期采用者来帮助我们进行实验 vgo, 以便我们尽可能地从用户那里学习.

开始尝试 vgo 并不意味着结束对 dep 的支持. 我们将保持 dep 可用, 直到完成 go 命令集成的路径被确定, 实施并且大体上可用. 我们也将尽可能顺利地完成从最终过渡 dep 到 go 命令集成的各种形式. 尚未转化到 dep 的项目仍然可以从中获益. (注意, godepglide 已经结束了活跃的开发, 鼓励迁移到 dep.) 其他项目不妨直接迁移到 vgo, 如果它已满足需求.

提案

添加版本到 go 命令的提案有四个步骤. 首先, 采用 Go FAQ 和 gopkg.in 暗示的导入兼容性规则; 也就是说, 建立一个预期, 即具有给定导入路径的软件包的新版本应该与旧版本向后兼容. 其次, 使用简单的新算法(称为最小版本选择)来选择在给定构建中使用哪些版本. 第三, 引入 Go module 的概念, Go module 是一组版本为单个版本的软件包, 并声明了它们的依赖关系必须满足的最低要求. 第四, 定义如何将所有这些改造成现有的 go 命令, 以便基本的工作流程从今天不再发生显著变化. 本节的其余部分将介绍这些步骤中的每一个. 本周其他博客文章将更详细地介绍.

导入 (Import) 兼容性规则

包装管理系统中几乎所有的痛苦都是由于试图驯服不兼容而导致的. 例如, 大多数系统允许程序包 B 声明它需要程序包 D 6 或更高版本, 然后允许程序包 C 声明它需要D 2, 3 或 4, 但不是 5 或更高版本. 如果你正在编写软件包 A, 并且你想同时使用 B 和 C, 那么你运气不好: 没有一个 D 版本可以选择将 B 和 C 一起构建到 A 中. 你什么也做不了: 这些系统说 B 和 C 这样做是可以接受的 - 他们有效地鼓励它 - 所以就这样你被卡住了.

这个提案要求包作者遵循导入兼容性规则, 而不是设计一个不可避免导致大型程序无法构建的系统:

如果旧软件包和新软件包具有相同的导入路径,

则新软件包必须与旧软件包向后兼容.

这条规则是对前面引用的 Go FAQ 中的建议的重申. 常见问题引文最后说到: "如果需要完全的破坏兼容性, 请使用新的导入路径创建一个新包". 今天的开发人员希望使用语义版本来表达这样一个破坏, 所以我们将语义版本集成到了我们的提案中. 具体来说, 主版本 2 和更高版本可以通过在路径中包含版本来使用, 如下所示:

import "github.com/go-yaml/yaml/v2"

创建 v2.0.0, 在语义版本化中表示一个重大破坏, 因此按照导入兼容性的要求, 创建具有新导入路径的新包. 由于每个主要版本都有不同的导入路径, 因此给定的 Go 可执行文件可能包含每个主要版本中的一个. 这是预期的和可取的. 它保持程序的构建, 并允许一个非常大的程序部分独立地从 v1 更新到 v2.

期望包作者遵循导入兼容性规则, 可以避免尝试驯服不兼容性, 使整个系统指数级更简单, 并且软件包生态系统更少碎片化. 当然, 实际上, 尽管作者做出了最大的努力, 但在同一个主要版本中的更新偶尔会破坏用户使用. 因此, 使用升级速度不太快的升级机制很重要. 这将引领我们到下一步.

最小版本选择

今天几乎所有的软件包管理器, 包括 dep 和 cargo, 使用最新允许的版本参与构建包. 我认为这是错误的违约, 有两个重要原因. 首先, "最新允许版本"的含义可能因外部事件而改变, 即新版本正在发布. 也许今晚有人会推出一些新版本的依赖项, 然后明天你再运行今天的相同命令序列会产生不同的结果. 其次,为了覆盖这个默认值, 开发人员花时间告诉包管理器 "不, 不要使用 X", 然后包管理器花时间寻找一种不使用 X 的方法.

这个提案采取了不同的方法, 我称之为最小版本选择. 它默认使用构建中涉及的每个包的最旧允许版本. 这个决定从今天到明天不会改变, 因为不会发布旧版本. 更好的是, 为了覆盖这个默认值,开发人员花时间告诉包管理器, "不, 至少使用 Y", 然后包管理器可以轻松地决定使用哪个版本. 我称之为最小版本选择, 因为所选择的版本是最小的, 也因为整个系统也可能是最小的, 避免了现有系统的几乎所有复杂性.

最小版本选择允许模块仅指定其依赖模块的最低需求. 它为升级和降级操作提供了定义明确的独特答案, 并且这些操作的实施效率很高. 它还允许正在构建的整个模块的作者指定要排除的依赖项版本, 或者指定将特定的依赖项版本替换为本地文件系统中的 forked 副本或作为其自己的模块发布. 当模块被构建为其他模块的依赖项时, 这些排除和替换不适用. 这使用户可以完全控制自己的程序的构建方式, 而不是其他人的程序构建方式.

最小版本选择默认提供可重复的版本, 无需锁定文件.

导入兼容性是简化版本选择的关键. 用户不会说 "不, 这太新了", 他们只会说 "不, 这太旧了". 在这种情况下, 解决方案很明确: 使用(最低限度)更新的版本. 新版本同意成为老版本的可接受替代品.

定义 Go 模块

Go 模块是一组共享公共导入路径前缀的软件包, 称为模块路径. 该模块是版本控制的单元, 模块版本被编写为语义版本字符串. 在开发使用 Git 时, 开发人员将通过向模块的 Git 仓库添加标签(tag)来定义模块的新语义版本. 尽管强烈推荐使用语义版本, 但也支持引用特定的提交(commits).

一个模块在一个称作 go.mod 的新文件中定义了它所依赖的其他模块的最低版本要求. 例如, 这里是一个简单的 go.mod 文件:

// My hello, world.

module "rsc.io/hello"

require (
    "golang.org/x/text" v0.0.0-20180208041248-4e4a3210bb54
    "rsc.io/quote" v1.5.2
)

该文件定义了一个由路径标识的模块, 该模块 rsc.io/hello 本身依赖于另外两个模块: golang.org/x/text 和 rsc.io/quote. 模块本身的构建将始终使用 go.mod 文件中列出的特定版本的必需依赖项. 作为更大构建的一部分, 如果构建中的其他地方需要它, 它将只使用更新的版本.

期望包作者使用语义版本标记(tag)发布, vgo 鼓励使用标记版本, 而不是任意提交. rsc.io/quote 模块服务于 github.com/rsc/quote, 已经标记了版本, 包括 v1.5.2. 但是 golang.org/x/text 模块尚未提供标签版本. 要命名未标记的提交, 伪版本 v0.0.0-yyyymmddhhmmss-commit 标识在给定日期进行的特定提交. 在语义版本控制中, 这个字符串对应于一个 v0.0.0 预发行版, 预发行版标识符为 yyyymmddhhmmss - commit. 语义版本优先规则在 v0.0.0 或更高版本之前排序此类预发布, 并且它们通过字符串比较来排序预发布. 在伪版本语法中放置日期在前可确保字符串比较匹配日期比较.

除了 requirements 之外, go.mod 文件还可以指定上一节中提到的排除 (exclusions) 和替换 (replacements), 但这些仅在直接构建模块时才应用, 而不是在作为较大程序的一部分构建模块时应用. 这些例子演示了所有这些.

Goinstall 和旧的 go get 直接调用版本控制工具, 如 git 和 hg 直接下载代码, 导致许多问题, 其中包括碎片化: 例如, 用户没有 bzr 无法下载存储在 Bazaar 仓库中的代码. 相比之下, 模块始终下载通过 HTTP 提供的 zip 归档文件. 之前, go get 有特殊外壳为流行代码托管站点选择版本控制命令. 现在, vgo 有特殊外壳可以使用这些托管站点的 API 来获取档案.

将模块统一表示为 zip 压缩文件可以实现模块下载代理的简单协议和实现. 公司或个人可以出于任何原因运行代理, 包括安全性, 并希望能够在删除原始文件的情况下从缓存副本进行工作. 通过使用代理来确保可用性, 在 go.mod 中定义要使用的代码, vendor 目录不再需要.

go 命令

go 命令必须更新以使用模块. 一个显著的变化是, 普通的构建命令, 如 go build, go install, go run 和 go test, 将按需解决新依赖, 只需要使用 golang.org/x/text 全新的模块增加导入到 Go 源代码并且构建代码.

但是, 最重要的变化是 GOPATH 作为 Go 代码工作的必需位置的结束. 因为该 go.mod 文件包含完整的模块路径, 并且还定义了正在使用的每个依赖项的版本, 所以带有 go.mod 文件的目录会将目录树的根标记为独立的工作空间, 与其他任何此类目录分开. 现在你只是 git clone, cd, 开始写. 无处不可. 不需要 GOPATH.

接下来

我还发布了 "A Tour of Versioned Go" 来展示 vgo 的使用方式. 看这篇文章, 了解如何下载和实验 vgo. 我会在整个一周发布更多信息, 以添加我在本文中跳过的详细信息. 我鼓励对这个帖子和其他人的评论发表意见, 我也会试着去看看 Go subreddit 和 golang-nuts 邮件列表. 周五, 我将发布 FAQ 作为系列文章的最后一篇博文(至少现在). 下周我会提交一份正式的 Go 提案.

请尝试 vgo. 在存储库中开始标记版本. 创建并签入 go.mod 文件. 请注意, 如果运行的是一个包含空的 go.mod 的仓库, 但是, 它有一个现成的 dep, glide, glock, godep, godeps, govend, govendor 或 gvt 配置文件, vgo 将用它来填充 go.mod 文件.

我为 Go 添加版本到它的工作词汇中这一姗姗来迟的一步感到兴奋. 开发者在使用 Go 时遇到的一些最常见的问题是缺乏可重复的构建, go get 完全忽略发布标签, GOPATH 无法理解包的多个版本以及想要或需要在 GOPATH 之外的源目录中工作. 这里提出的设计消除了所有这些问题, 以及更多.

即便如此, 我确定有些细节是错误的. 我希望我们的用户能够通过尝试新的 vgo 原型并参与富有成效的讨论来帮助我们实现这一设计. 我希望 Go 1.11 为 Go 模块提供初步支持, 作为一种技术预览, 然后我希望 Go 1.12 能够提供官方支持. 在稍后的一些版本中, 我们将删除对旧的无版本的 go get 支持. 不过, 这是一个激进的时间表, 如果获得正确的功能意味着等待以后的发布, 我们会.

我非常关心从旧的 go get 和无数的 vendoring 工具到新的模块系统的过渡. 对于我来说, 这个过程就和获得正确的功能同样重要. 如果成功的转换意味着等待以后的发布, 我们会.

感谢 Peter Bourgon, Jess Frazelle, Andrew Gerrand 和 Ed Mueller 以及 Sam Boyer 在包管理委员会的工作以及去年的许多有益讨论. 还要感谢 Dave Cheney, Gustavo Niemeyer, Keith Rarick 和 Daniel Theophanes 对 Go 和包版本的关键贡献. 再次感谢 Sam Boyer 创造 dep, 并感谢他和 dep 的所有贡献者. 感谢所有曾经在许多早期的 vendoring 工具上创建或工作过的人. 最后, 感谢所有能够帮助我们推进此提案的人, 找到并解决问题, 并尽可能顺利地将软件包版本控制添加到 Go.