本文译自 Minimal Version Selection, Go & Versioning 的第 4 部分, 版权@归原文所有.
版本化的 Go 命令必须决定在每个版本中使用哪个模块版本. 我把指定构建中用到的模块和版本列表称之为构建列表. 为了稳定开发, 今天的构建列表也必须是明天的构建列表. 但是, 开发人员也必须允许更改构建列表: 升级所有模块, 升级一个模块或降级一个模块.
因此版本选择问题是定义其意义并给出算法实现, 构建列表中的 4 个操作为:
- 构造当前的构建列表.
- 将所有模块升级到最新版本.
- 将一个模块升级到特定的较新版本.
- 将一个模块降级到特定的旧版本.
最后两个操作指定一个模块升级或降级, 但这样做可能需要升级, 降级, 添加或删除其他模块, 理想情况下应尽可能少, 以满足依赖性.
这篇文章介绍了最小版本选择, 这是一种新的, 简单的版本选择问题. 最小版本的选择很容易理解和预测, 这应该使其易于使用. 它还可以生成高保真构建, 其中用户构建的依赖关系尽可能的接近包作者开发用的依赖关系. 它的实现效率也很高, 不需要比递归图遍历更复杂, 因此 Go 中的完整的最小版本选择实现只有几百行代码.
最小版本选择假定每个模块声明自己的依赖性需求: 其他模块的最低版本列表. 假设模块遵循导入兼容性规则 - 任何较新版本中的包应该和旧版一样工作 - 所以依赖性需求只给出最低版本, 而不是最高版本或不兼容的更高版本的列表.
那么这四个操作的定义是:
- 构建给定目标的构建列表: 使用目标本身启动列表, 然后追加每个需求的构建列表. 如果一个模块多次出现在列表中, 仅保留最新版本.
- 要将所有模块升级到最新版本: 构造构建列表, 但要读取每个需求(requirement), 就好像它请求了最新的模块版本一样.
- 要将一个模块升级到特定的较新版本: 构造未升级的构建列表, 然后添加新模块的构建列表. 如果一个模块多次出现在列表中, 仅保留最新版本.
- 要将一个模块降级到特定的旧版本: 倒回每个顶级需求的所需版本, 直到该需求的构建列表不再引用降级模块的较新版本.
这些操作简单, 高效且易于实现.
示例
在我们更详细地测试最小版本选择之前, 让我们看看为什么需要新的方法. 在整篇文章中, 我们将使用以下一组模块作为运行示例:
该图显示了具有一个或多个版本的七个模块(虚线框)的模块需求图. 在语义版本控制之后, 给定模块的所有版本都共享一个主版本号. 我们正在开发模块 A 1
, 我们将运行命令来更新其依赖性要求.
该图显示了 A 1
的当前需求和由各个版本的已发布模块 B 1
至 F 1
声明的需求.
因为主版本是模块标识符的一部分, 所以我们必须知道我们正在处理 A 1
而不是 A 2
, 但是 A 的确切版本未指定 - 我们的工作未发布.
同样, 不同的主版本只是不同的模块: 就这些算法而言, B 1
与 B 2
的关系不如 C 1
. 我们可以用 A 2
到 A 7
替代图中的 B1 到 F1, 但明显损失很大, 但对于算法如何处理这个例子没有任何改变.
由于示例中的所有模块都具有主版本 1, 因此从现在开始我们将尽可能省略主版本, 将 A 1
缩短为 A.
我们目前的 A 版本需要 B 1.2
和 C 1.2
. B 1.2
依次要求 D 1.3. 早期版本 B 1.1
需要 D 1.1
. 等等. 请注意, F 1.1
需要 G 1.1
, 但 G 1.1
也需要 F 1.1
.
当单个功能从一个模块移动到另一个时, 声明这种循环可能很重要. 我们的算法不能假定模块需求图是非循环的.
低保真构建
Go 的当前版本选择算法很简单, 提供了两种不同的版本选择算法, 但都是不正确的.
第一种算法是 go get
的默认行为: 如果你有本地版本, 请使用该版本, 否则请下载并使用最新版本.
这种模式可以使用太旧的版本: 如果你已经安装了 B 1.1
并运行 go get
下载 A, 那么 go get
就不会更新到 B 1.2, 从而导致构建失败或有 bug 的构建.
第二种算法是 go get -u
的行为: 下载并使用所有包的最新版本.
此模式由于使用的版本太新而失败: 如果运行 go get -u
下载 A, 它将正确更新到 B 1.2
, 但它也会更新到 C 1.3
和 E 1.3
, 这不是 A 所需要的, 可能没有经过测试, 可能无法正常工作.
我将这些结果称为低保真构建: 被视为试图重现 A 的作者所使用的构建, 这些构建因没有理由而有所不同(译注: 此处不太好翻译). 在我们看到最小版本选择算法的细节后, 我们将看到它们为什么会生成高保真构建.
算法
现在我们来看看更详细的算法.
算法 1: 构造构建列表
有两种有用的(和等价的)方法来定义构建列表的构造: 作为递归过程和图形遍历.
构建列表构造的递归定义如下. 通过启动一个空列表, 添加 M, 然后为 M 的每个需求附加构建列表, 构建 M 的粗略构建列表. 通过仅保留任何列出模块的最新版本, 简化粗略构建列表以生成最终构建列表.
构建列表的递归构造主要用作心理模型. 该定义的字面量实现效率太低, 可能需要非循环模块需求图的大小的时间指数, 并且在循环图上永远运行.
一个等效的, 更高效的构造基于图可达性. M 的粗略构建列表也仅仅是从 M 开始的后续箭头所需的所有模块的列表. 这可以通过对图的简单递归遍历来计算, 注意不要访问已经访问过的节点. 例如, A 的粗略构建列表是从 A 处开始并在高亮箭头后面找到的突出显示的模块版本.
(从粗略构建列表到最终构建列表的简化仍然相同)
注意, 这个算法只访问一次粗略构建列表中的每个模块, 由于只访问那些模块, 因此执行时间与粗略构建列表大小成正比 |B| , 加上必须遍历的箭头数(最多 |B|2).
该算法完全忽略粗略构建列表中的版本: 例如, 它加载关于 D 1.3
, D 1.4
和 E 1.2
的信息, 但它不加载关于 D 1.2
, E 1.1
或 E 1.3
的信息.
在依赖管理设置中, 加载关于每个模块版本的信息可能意味着单独的网络往返, 避免不必要的模块版本是一个重要的优化.
算法2. 升级所有模块
升级所有模块可能是构建列表最常见的修改. 这是今天 get -u
所做的.
我们通过升级模块需求图并应用先前的算法来计算升级的构建列表. 升级后的模块需求图中指向模块的任何版本的每个箭头都被一个指向该模块的最新版本的指针取代. (也可以从图中丢弃所有旧版本, 但构建列表构造无论如何都不会查看它们, 因此不需要清理图.)
例如, 以下是升级后的模块需求图, 原始构建列表仍以黄色标记, 而升级的构建列表现在标记为红色:
虽然这告诉我们升级的构建列表, 但它还没有告诉我们如何使未来的构建使用构建列表而不是旧构建列表(仍以黄色标记). 为了升级图表, 我们改变了所有模块的需求. 但是模块 A 开发过程中的升级必须以某种方式记录在 A 的需求列表中(在 A 的 go.mod 文件中), 使算法 1 生成我们想要的构建列表, 挑选红色模块而不是黄色模块. 为了搞定什么添加到 A 的需求列表中可以达到这种效果, 我们引入了一个助手算法 R.
算法 R. 计算最小需求列表
给定一个与目标下面的模块需求图相兼容的构建列表, 我们想为目标计算一个需求列表, 以便产生该构建列表.
列出构建列表中除目标本身之外的每个模块总是足够的. 例如, 我们上面考虑的升级可以将 C 1.3
(替换 C 1.2
), D 1.4
, E 1.3
, F 1.1
和 G 1.1
添加到 A 的需求列表中.
但总的来说, 并非所有这些添加都是必要的, 我们希望列出尽可能少的附加模块.
例如, F 1.1
意味着 G 1.1
(反之亦然), 所以我们不需要列出两者. 乍一看, 通过添加标记为红色但不是黄色的模块版本(在新列表中, 但从旧列表中缺失)开始似乎很自然.
那种启发法会错误地丢弃 D 1.4
, 这是旧的需求 C 1.2
所暗示的, 而不是新的需求 C 1.3
.
相反, 以反向后序访问模块是正确的, 也就是说, 只有在考虑到所有指向它的模块后才访问模块, 并且只有在模块没有被已访问的模块暗示的情况下才保留模块.
对于非循环图, 结果是唯一的, 最小的一组添加. 对于循环图, 反向后序遍历必须打破循环, 然后对于不参与循环的模块, 添加集是唯一且最小的.
只要结果是正确和稳定的, 我们就会在循环的情况下接受非最小的答案.
在本例中, 升级需要添加 C 1.3
(替换 C 1.2
), D 1.4
和 E 1.3
. 它可以丢弃 F 1.1
(由 C 1.3
暗示) 和 G 1.1
(也由 C 1.3
暗示).
算法 3. 升级一个模块
谨慎的开发人员通常只需升级一个模块, 而不必升级所有模块, 只需尽可能少地更改构建列表. 例如, 我们可能想要升级到 C 1.3
, 并且我们不希望该操作进行不必要的更改, 例如升级到 E 1.3
.
和算法 2 一样, 我们可以升级一个模块, 方法是升级需求图, 从中构造出一个构建列表(算法 1), 然后将该列表还原为顶层模块(算法 R)的一组需求.
为了升级需求图, 我们从顶层模块添加一个新箭头到升级后的模块版本.
例如, 如果我们想更改 A 的构建以升级到 C 1.3
, 则此处为升级后的需求图:
像以前一样, 新的构建列表的模块被标记为红色, 而旧的构建列表是黄色.
升级对构建列表的影响是进行升级的唯一最低限度的方式, 增加了新的模块版本以及任何隐含的要求, 但没有其他要求.
请注意, 在构建升级后的图表时, 我们只能添加新箭头, 而不能替换或删除旧箭头.
例如, 如果从 A 到 C 1.3
的新箭头将旧箭头从 A 更换为 C 1.2
, 则升级后的构建列表将省略 D 1.4
.
也就是说, C 的升级会降级 D, 这是一个意外的, 不需要的和非最小的变化. 一旦我们计算了升级的构建列表, 我们就可以运行(上面的)算法 R 来决定如何更新需求列表.
在这种情况下, 我们最终会用 C 1.3
替换 C 1.2
, 但是也会在 D 1.4
上添加一个新的需求, 以避免 D 的意外降级.
请注意, 该选择性升级只会将其他模块更新为 C 的最低需求: C 的升级不会简单地获取每个 C 的最新依赖项.
算法 4. 降级一个模块
我们也可能会在升级所有模块后发现, 最新的模块版本有问题, 这必须避免. 在这种情况下, 我们需要能够降级到早期版本的模块. 降级一个模块可能需要降级其他模块, 但我们希望降级尽可能少的其他模块. 像升级一样, 降级必须通过修改目标的需求列表对其进行更改. 与升级不同, 降级必须通过删除需求来实现, 而不是添加它们. 这个想法引出了一个非常简单的降级算法, 它可以单独考虑每个目标的需求. 如果需求与建议的降级不兼容(即, 如果需求的构建列表包含现在不允许的模块版本), 则依次尝试较早的版本, 直至找到与降级兼容的版本.
例如, 从原始构建图开始, 假设我们发现 D 1.4
存在问题, 实际上是在 D 1.3
中引入的, 因此我们决定降级到 D 1.2
.
我们的目标模块 A 依赖于 B 1.2
和 C 1.2
. 要从 D 1.4
降级到 D 1.2
, 我们必须找到早期版本的 B 和 C, 它们不需要(直接或间接的)晚于 D 1.2
版本.
虽然我们可以分别考虑每个需求, 但将模块需求图作为一个整体来考虑会更高效. 在我们的例子中, 降级规则相当于删除 D 的不可用版本, 然后从不可用模块向后的箭头查找和删除其他不可用的模块. 最后, 剩下的 A 的需求的最新版本可以记录为新的需求.
在这种情况下, 降级到 D 1.2
意味着降级到 B 1.1
和 C 1.1
. 为了避免不必要的降级到 E 1.1
, 我们还必须在 E 1.2
上添加新的需求.
我们可以应用算法 R 来找到写入 go.mod 的最小需求集合.
请注意, 如果我们先升级到 C 1.3
, 那么降级到 D 1.2
将继续使用 C 1.3
, 它根本不使用任何版本的 D.
但降级仅限于降级软件包, 而不是升级软件包; 如果需要在降级前进行升级, 用户必须明确要求.
原理
最小版本选择非常简单. 它通过消除关于答案是什么的所有灵活性来实现简单性: 构建列表正好是需求中指定的版本. 真正的系统需要更大的灵活性, 例如排除某些模块版本或替换其他模块的能力. 在我们添加之前, 值得研究当前系统简单性的理论基础, 因此我们要理解哪种扩展保留简单性, 哪些不必.
如果您熟悉大多数其他系统处理版本选择的方式, 或者你还记得我一年前发布的 Version SAT 文章,
可能最小版本选择最显著的特征是它不能解决一般的布尔可满足性(Boolean satisfiability
)或 SAT.
正如我在之前的文章中解释的那样, 版本搜索很少用于解决 SAT; 在这些系统中的版本搜索, 本质上是复杂的问题, 我们不知道这些问题是否有高效的解决方案.
如果我们想避免这种命运, 我们需知道边界在哪里, 在我们探索设计空间的时候, 哪里不该走.
方便地是, Schaefer 的二分法定理精确地描述了这些边界.
它确定了六个布尔公式的限制类, 其可满足性可以在多项式时间内确定, 然后证明对于超出这些类的任何一类公式, 可满足性是 NP 完备的.
为了避免 NP 完备性, 我们需要将版本选择问题限制在 Schaefer 的某一限制类中.
事实证明, 最小版本选择位于六个易处理的 SAT 子问题中的三个交集: 2-SAT, Horn-SAT 和 Dual-Horn-SAT. 与最小版本选择构建相对应的公式是一组子句的 AND, 其中每个子句都是单个正文本(此版本必须安装, 例如在升级期间), 单个负文本(此版本不可用, 例如在降级期间), 或者一个负面和一个正面文字的 OR (暗示: 如果安装了此版本, 则还必须安装此其他版本). 该公式是一个 2-CNF 公式, 因为每个子句至多有两个变量. 该公式也是一个 Horn 公式, 因为每个子句最多只有一个正文本. 该公式也是双重 Horn 公式, 因为每个子句最多只有一个负文本. 也就是说, 最小版本选择带来的每个可满足性问题都可以通过选择三种不同的高效算法来解决. 正如我们上面所做的那样, 利用这些问题的非常有限的结构来进一步专业化更加简单和高效.
虽然 2-SAT 是 SAT 子问题最有名的例子, 并且有一个有效的解决方案, 但这些问题都是 Horn 和双重 Horn 公式的事实更有趣. 每个 Horn 公式都有一个独特的令人满意的分配, 最少的变量设置为 true. 这证明了构造构建列表以及每次升级都有唯一的最小答案. 除非绝对必要, 否则独特的最小升级不会使用给定模块的较新版本. 相反, 每个双重 Horn 公式也具有独特的令人满意的分配, 其中最少的变量设置为 false. 这证明每个降级都有一个唯一的最小答案. 除非绝对必要, 否则独特的最低级别降级不会使用给定模块的较旧版本. 如果我们想扩展最小版本选择, 例如排除某些模块的能力, 我们只能通过继续使用可表示为 Horn 和双重 Horn公式的约束来保持唯一性和最小化属性.
(插一句: 最小版本选择解决的问题是 NL 完备问题: 因为 NL 是 2-SAT 的一个子集, 并且它是 NL-hard 问题, 因为 st-connectivity 可以简单地转换为最小版本选择构建列表构造问题. 令人愉快的是我们已经用一个 NL 完备问题取代了一个 NP 完备问题, 但是知道这一点几乎没有什么实际价值: NL 只保证一个多项式时间解, 而我们已经有一个线性时间解.
排除模块
最小版本选择始终选择满足构建总体要求的最小(最旧)模块版本. 如果该版本以某种方式出现问题, 则升级或降级操作可以修改顶级目标的需求列表以强制选择不同的版本.
明确记录该版本有问题也很有用, 以避免在将来的升级或降级操作中重新引入该版本. 但是我们希望以保持前一节的唯一性和最小性的方式来做到这一点, 所以我们必须使用 Horn 和双重 Horn公式的约束条件. 这意味着构建约束只能是无条件的肯定断言(必须安装 X:X), 无条件的否定断言(¬Y:Y 不能安装)和正面含义(X→Z, 等价于 X∨Z: 如果 X 已安装, 那么 Z 必须安装). 否定的含义(X→¬Y, 等同于 ¬X∨¬Y: 如果安装了 X, 那么 Y 不能安装)不能作为约束添加却不破坏表单. 因此模块排除必须是无条件的: 它们必须独立于构建列表构造期间的选择而决定.
我们可以做的是允许一个模块声明它自己的排除模块版本的本地列表.
就本地而言, 我的意思是仅在该模块内部建立该列表才被查阅; 将模块用作依赖的较大构建会忽略排除列表.
在我们的例子中, 如果 A 的构建参考了 D 1.3
的列表, 那么确切的排除集合将取决于构建是选择了 D 1.3
还是 D 1.4
,
从而使排除条件变得有条件并导致 NP-完备 搜索问题. 只有顶层模块保证在构建中, 因此只使用顶层模块的排除列表.
请注意,只要在开始构建之前决定使用列表, 并且列表内容不依赖于哪些模块, 就可以参考其他来源的排除列表, 例如通过网络加载的全局排除列表在构建期间被选中.
尽管所有关注点都是无条件排除, 但似乎我们已经有有条件排除: C 1.2
需要 D 1.4
, 因此暗含排除 D 1.3
. 但是我们的算法不会将其视为排除. 当算法 1 运行时, 它将 D 1.3
(对于 B)和 D 1.4
(对于 C)都添加到粗略构建列表连同它们的最低要求. 只有 D 1.4
存在, 最终的简化过程才会删除 D 1.3
.
这种声明不兼容性和声明最低要求之间的区别至关重要. 声明不得使用 D 1.3
构建 C 1.2
, 只描述如何失败. 声明 C 1.2
必须用 D 1.4
构建, 而不是描述如何成功.
排除必须是无条件的. 知道这个事实很重要, 但它并没有告诉我们如何实施排除.
一个简单的答案是添加排除作为构建约束, 像 "D 1.3 不能安装" 这样的子句.
不幸的是, 单独添加该子句会导致需要 D 1.3
的模块(如 B 1.2
)可以被卸载.
我们需要以某种方式表达 B 1.2
可以选择 D 1.4
. 这样做的简单方法是修改构建约束, 将 "B 1.2 → D 1.3" 更改为 "B 1.2 → D 1.3 ∨ D 1.4", 并且通常允许所有将来的 D 版本.
但是该条款(等同于 B 1.2 ∨ D 1.3 ∨ D 1.4) 有两个正面文字, 使整体构建公式不再是 Horn 公式.
它仍然是一个双重 Horn 公式, 所以我们仍然可以定义一个线性时间构建列表构造, 但是构造 - 也就是如何执行升级的问题 - 将不再保证具有唯一的, 最小的答案.
我们可以通过改变现有的约束来实现它们, 而不是将排除作为新的约束条件来实施. 也就是说, 我们可以修改需求图, 就像我们升级和降级一样. 如果一个特定的模块被排除, 那么我们可以将它从模块需求图中移除, 但也可以改变该模块上的任何现有需求, 以便用下一个更新的版本替代.
例如, 如果我们排除 D 1.3
, 那么我们也会更新 B 1.2
以要求 D 1.4
:
如果删除了最新版本的模块, 则需要删除任何需要该版本的模块, 如降级算法中所述. 例如, 如果 G 1.1
被移除, 那么 C 1.3
也需要被移除.
一旦排除已被应用到模块需求图中, 算法就像以前一样继续.
更换模块
在 A 的开发过程中, 假设我们在 D 1.4
中发现了一个 bug, 我们想测试一个潜在的修复.
我们需要一些方法来将我们的构建中的 D 1.4
替换为未发布的副本 U.
我们可以允许一个模块将其声明为替换: "就像 D 1.4
的源码和需求模块已经被 U 的替换了一样继续前行".
和排除一样, 替换可以通过在预处理步骤中修改模块需求图来实现, 而不是通过增加处理图的算法复杂度来实现.
与排除一样, 替换列表对于一个模块也是本地的.
A 的构建参考 A 的替换列表, 而不是来自 B 1.2
, C 1.2
或构建中的任何其他模块的. 这避免了有条件的替换, 那将难以实现, 并且还避免了替换冲突的可能性:
如果 B 1.2
和 C 1.2
为 E 1.2
指定了不同的替换项, 该怎么办 ? 更一般地说, 保持一个模块的本地排除和替换限制了该模块对其他构建的控制.
谁控制你的构建
顶层模块的依赖关系必须对顶层构建进行一定的控制.
B 1.2
需要能够确保它是用 D 1.3
或更高版本构建的, 而不是 D 1.2
. 否则, 我们会结束当前的 go get
过时依赖失败模式.
同时, 为了使构建保持可预测性和可理解性, 我们不能依赖对顶级构建的任意细粒度控制.
这会导致冲突和意外. 例如, 假设 B 声明它需要 D 的偶数版本, 而 C 声明它需要 D 的素数版本.
D 经常更新并且达到 D 1.99
. 单独使用 B 或 C, 总是可以使用相对较新版本的 D (分别为 D 1.98
或 D 1.97
). 但是当 A 同时使用 B 和 C 时, 构建会默默地选择更老的(并且更缓慢的) D 1.2
.
这是一个极端的例子, 但它提出了一个问题: 为什么 B 和 C 的作者应该对 A 的构建有如此的极端控制 ?
在我写这篇文章时, 有一个开放的 bug 报告, Kubernetes Go 客户端声明了一个需求, 它依赖于 gopkg.in/yaml.v2
两年前的一个特定版本. 当开发人员尝试在已使用 Kubernetes Go 客户端的程序中使用该 YAML 库的新功能时, 即使尝试升级到最新的可能版本后, 使用新功能的代码仍无法编译, 因为 "latest" 受限于 Kubernetes 的需求.
在这种情况下, 使用两年前的 YAML 库版本在 Kubernetes 代码库的背景下可能是完全合理的.
显然 Kubernetes 作者应该完全控制自己的构建, 但是这种控制级别扩展到其他开发人员的构建没有任何意义.
在模块需求, 排除和替换的设计中, 我试图平衡允许依赖足够控制的竞争关注, 以确保成功构建, 而不允许他们进行太多控制, 从而损害构建. 最低需求的结合没有冲突, 所以从所有依赖中收集它们是可行的(甚至容易的). 但排除和替换可以并且会发生冲突, 所以我们只允许它们由顶层模块指定.
因此, 模块作者完全控制了该模块的构建, 因为它是正在构建的主要程序, 但不能完全控制依赖于模块的其他用户的构建. 我相信对于比现有系统更大, 更分散的代码库来说, 这种区别将使版本选择规模最小化.
高保真构建
现在回到高保真构建的问题.
在文章的开头, 我们看到, 使用 go get
构建 A, 可以使用不同于 A 的作者所使用的依赖, 没有一个很好的理由.
我把它称为低保真构建, 因为它是 A 的原始构建的较差再现.
使用最小版本选择, 构建是高保真的.
模块的源代码中包含的模块需求唯一确定了如何直接构建它. 用户构建的 A 将完全匹配作者的构建: 可复制构建. 但高保真意味着更多.
可复制构建通常被理解为整个程序构建的二元属性: 用户的构建与作者完全相同, 或者不相同. 在构建一个库模块作为大型程序的一部分时呢 ? 用户构建一个库与作者的构建尽可能匹配时很有帮助的. 然后用户运行作者开发和测试的相同代码(包括依赖). 当然, 在一个更大的项目中, 用户构建一个库可能无法完全匹配作者的构建. 该构建的另一部分可能会强制使用更新的依赖, 从而使用户的库构建偏离作者的构建. 当只是为了满足构建中其他地方的需求时, 构建偏离了作者自己的构建, 我们将构建称为高保真的构建.
再考虑一下我们最初的例子:
在这个例子中, 虽然 B 的作者使用 D 1.3
, 但 A的内部结合了 B 1.2
和 D 1.4
.
这种改变是必要的, 因为 A 也使用 C 1.2
, 而它需要 D 1.4
.
A 的构建仍然是 B 1.2
的高保真构建: 它通过使用 D 1.4
而偏离, 但仅仅因为那是必须的.
相反, 如果构建使用 E 1.3
, 如 get -u
, Dep 和 Cargo 通常所做的那样, 构建将会是低保真构建: 因为它是不必要地偏离的.
最小版本选择通过使用满足需求的最旧版本来提供高保真构建.
新版本的发布对构建没有影响.
相比之下, 包括 Cargo 和 Dep 在内的大多数其他系统使用可用的最新版本, 以满足 "manifest file" 中列出的需求.
新版本的发布会更改其构建决策.
为了获得可复制的构建, 这些系统添加了第二种机制, 即 "lock file", 它列出了构建应该使用的特定版本.
锁定文件确保了整个程序的可复制构建, 但是对于库模块它却被忽略;
Cargo FAQ 解释说这是 "这正是因为库不应该被它的所有用户进行确定性的重新编译".
确实, 完美的复制并不总是可能的, 但通过完全放弃, Cargo 方案承认不必要的偏离库作者的构建.
也就是说, 它提供了低保真构建.
在我们的例子中, 当 A 首先将 B 1.2
或 C 1.2
添加到其构建中时, Cargo 会看到它们需要 E 1.2
或更高版本, 并且会选择 E 1.3
.
然而, 直到另外指示, 作为 B 和 C 的作者, 继续用 E 1.2
构建它似乎更好.
使用最旧的允许版本还消除了具有两个不同文件(manifest 和 lock)的冗余, 这两个文件都指定要使用哪些模块版本.
自动使用较新的版本也使得最低需求容易出错.
假设我们开始使用当时的最新版本 B 1.1
开发 A, 并且我们记录 A 仅需要 B 1.1
.
但是然后 B 1.2
出来了, 我们开始在我们自己的构建和 lock 文件中使用它, 而不更新 manifest.
这时, 不再有 A 和 B 1.1
的任何开发或测试.
我们可能会开始使用 B 1.2
中的新功能或依赖它的错误修复, 但现在 A 错误地将其最低需求列为 B 1.1
.
如果用户总是选择比最低需求更新的版本, 那么没有太大的伤害: 他们也会使用 B 1.2
.
但是当系统确实尝试使用声明的最小版本时, 它将会出错.
例如, 当用户尝试对 A 进行有限更新时, 系统无法看到对 B 1.2
的更新也是必需的.
更一般地说, 每当最低版本(manifest 中)和构建版本(lock 中)不同时, 为什么我们认为使用最低版本构建将生成可用的库 ?
为了试图检测这个问题, Cargo 开发者已经建议在发布前使用 cargo publish
尝试使用最低版本的所有依赖进行构建.
当 A 开始使用 B 1.2
中的新功能时, 它会检测到使用 B.1.1
构建将失败 - 但它不会检测 A 何时开始依赖于新的错误修复.
根本的问题是, 在版本选择期间选择最新允许版本的模块会产生低保真构建.
针对整个程序构建, Lock 文件是部分解决方案; 像 cargo publish
这样的额外构建检查也是部分解决方案.
更完整的解决方案是使用作者所用的模块版本构建.
这使得用户的构建尽可能接近作者的构建: 高保真构建.
升级速度
鉴于最小版本选择采用了每个依赖项的最小允许版本, 很容易认为这会导致使用很旧的软件包副本, 进而可能导致不必要的错误或安全问题. 不过, 在实践中, 我认为情况正好相反, 因为允许的最小版本是所有约束中最大的一个, 因此, 对构建中的所有模块提供的一个控制杠杆是强制使用较新版本的依赖项的能力, 而不是使用该版本. 我希望最小版本选择的用户最终会得到一些程序, 这些程序几乎和他们的朋友一样, 都是使用像 Cargo 这样更有侵略性的系统的最新版本.
例如, 假设您正在编写一个依赖于少数其他模块的程序, 所有这些模块都依赖于一些非常常见的模块, 比如 gopkg.in/yaml.v2
.
你的程序的构建将使用你的模块所请求的最新的 YAML 版本, 以及少量的依赖项.
即使只有一个依赖项, 也会迫使你的构建更新许多其他依赖项.
这与我前面提到的 Kubernetes Go
客户端问题正好相反.
如果有什么区别的话, 最小版本选择反而会遇到相反的问题, 即这个 "最小值的最大值" 答案就像一个棘轮, 迫使依赖关系向前过快. 但我认为, 在实践中, 依赖性将以正确的速度向前推进, 最终的结果是, 其速度恰好比 Cargo 和它的同伴们的速度要慢一些.
升级时间
最小版本选择的一个关键特性是在开发人员要求升级之前不会发生. 你不会得到一个未经测试的模块版本, 除非你要求升级该模块.
例如, 在 Cargo 中, 如果包 B 依赖于包 C 2.9
, 并且将 B 添加到你的构建中, 那么你就不能获得 C 2.9
.
你在那一刻得到了最新的允许版本, 也许是 C 2.15
.
也许 C 2.15
在几分钟前才发布, 而作者还没有被告知一个重要的错误.
这对你和你的构建来说太糟糕了.
另一方面, 在最小版本选择中, 模块 B 的 go.mod 文件将列出 B 的作者所开发和测试的 C 的确切版本. 你会得到那个版本的. 或者, 你的程序中的其他模块使用较新版本的 C 进行了开发和测试.
然后你会得到那个版本.
但是你永远不会得到程序中的某个模块在 go.mod 文件中没有明确要求的 C 的版本.
这意味着你只能得到一个为别人工作的 C 的版本, 而不是最近的版本, 可能没有为任何人工作过.
明确地说, 我这里的目的不是挑剔 Cargo, 我认为它是一个设计得很好的系统. 我在这里使用 Cargo 作为许多开发人员都熟悉的模型的一个示例, 来尝试传达在最小版本选择中会有什么不同.
最小化
我将此系统最小版本选择称为系统最小版本选择, 因为整个系统看起来是最小的: 我不知道如何删除任何内容而不破坏它. 毫无疑问, 有些人会说, 太多的已经被删除了, 但到目前为止,它似乎完全能够处理我已经检查过的现实世界的案例. 我们将通过对 vgo 原型进行试验来发现更多的问题.
最小版本选择的关键是它对模块的最小允许版本的偏好.
当我将 go get-u
的 "将所有东西升级到最新的" 方法, 应用于能够依赖导入兼容性规则的系统中时, 我意识到 manifest 和 lock 的存在都是为了相同的目的: 解决 "将所有东西升级到最新的" 默认行为.
manifest 描述哪些新版本是不需要的, 而 lock 描述哪些新版本是不想要的. 相反, 为什么不更改默认值呢 ? 使用允许的最小版本, 通常是作者使用的准确版本, 并将升级的时间完全留给用户控制.
这种方法导致在没有 lock 文件的情况下可复制的构建, 更一般的情况下, 产生了只在需要时才偏离作者自己的构建的高保真版本.
最重要的是, 我想找到一个可理解的, 可预测的, 甚至于无聊的版本选择算法. 其他系统似乎为展现原始灵活性和强大进行了优化, 最小版本选择的目标是无形的. 我希望它成功.