包版本控制
Pub 包管理器帮助你处理版本控制。本指南解释了版本控制的历史以及 Pub 的版本控制方法。
这被认为是进阶信息。要了解 Pub 为何被设计成现在这样,请继续阅读。如果你想使用 Pub,请参考其他文档。
现代软件开发,特别是 Web 开发,在很大程度上依赖于大量现有代码的重用。这包括你过去编写的代码,但也包括第三方代码,从大型框架到小型实用库。一个应用程序依赖于几十个不同的包和库是很常见的。
这种力量的强大之处怎么强调都不为过。当你看到一些小型 Web 初创公司在几周内建立了一个拥有数百万用户的网站的故事时,他们能取得如此成就的唯一原因就是开源社区为他们提供了丰富的软件资源。
但这并非没有代价:代码重用带来了挑战,特别是重用你不维护的代码。当你的应用使用了其他人开发的代码时,他们改变代码会发生什么?他们不想破坏你的应用,你当然也不想。我们通过**版本控制**来解决这个问题。
一个名称和一个版本号
#当你依赖于某个外部代码时,你不会仅仅说“我的应用使用了 `widgets`”。你会说,“我的应用使用了 `widgets 2.0.5`。” 这种名称和版本号的组合唯一标识了代码的**不可变**块。`widgets` 的开发者可以进行他们想要的任何更改,但他们承诺不修改任何已经发布的版本。他们可以发布 `2.0.6` 或 `3.0.0`,但这丝毫不会影响你,因为你使用的版本未改变。
当你*确实*想获取这些更改时,你可以随时将你的应用指向 `widgets` 的新版本,并且你无需与那些开发者协调。然而,这并未完全解决问题。
本指南中讨论的版本号可能与包文件名中设置的版本号不同。它们可能包含 ` -0` 或 ` -beta`。这些标记不影响依赖解析。
解决共享依赖
#当你的依赖**图**实际上只是一个依赖**树**时,依赖于特定版本是可行的。如果你的应用依赖于一堆包,而这些包又各自有自己的依赖,依此类推,只要这些依赖项*不重叠*,一切都能正常工作。
考虑下面的示例

所以你的应用使用了 `widgets` 和 `templates`,而这两个包都使用了 `collection`。这称为**共享依赖**。现在,当 `widgets` 想使用 `collection 2.3.5` 而 `templates` 想使用 `collection 2.3.7` 时会发生什么?如果它们在版本上意见不一致怎么办?
不共享的库 (npm 方法)
#一个选择是让应用同时使用两个版本的 `collection`。它将拥有不同版本的库的两个副本,`widgets` 和 `templates` 各自获得它们想要的那个。
这就是 npm 对 node.js 所做的事情。这对 Dart 有效吗?考虑以下场景:
- `collection` 定义了一个 `Dictionary` 类。
- `widgets` 从它的 `collection` 副本 (`2.3.5`) 中获取了一个实例,然后将其传递给 `my_app`。
- `my_app` 将该 dictionary 发送给 `templates`。
- `templates` 又将其发送给*它*的 `collection` 版本 (`2.3.7`)。
- 接收它的方法为该对象有一个 `Dictionary` 类型注解。
在 Dart 看来,`collection 2.3.5` 和 `collection 2.3.7` 是完全不相关的库。如果你从其中一个库中取出一个 `Dictionary` 类的实例并将其传递给另一个库中的方法,那么这是完全不同的 `Dictionary` 类型。这意味着它将无法匹配接收库中的 `Dictionary` 类型注解。糟糕。
由于这个原因(以及调试一个包含同名但不同版本的应用程序所带来的麻烦),我们认为 npm 的模型不适合。
版本锁定 (死胡同方法)
#相反,当你依赖一个包时,你的应用只使用该包的一个副本。当你有一个共享依赖项时,所有依赖于它的包都必须同意使用哪个版本。如果它们不同意,你就会收到一个错误。
但这并没有真正解决你的问题。当你遇到错误时,你需要能够解决它。所以,假设你在前面的例子中遇到了这种情况。你想使用 `widgets` 和 `templates`,但它们正在使用不同版本的 `collection`。你该怎么办?
答案是尝试升级其中一个。`templates` 需要 `collection 2.3.7`。是否有你可以升级到的 `widgets` 的更新版本可以与该版本兼容?
在许多情况下,答案是“否”。从开发 `widgets` 的人的角度来看。他们想发布一个包含*他们的*代码新变更的版本,他们希望尽可能多的人能够升级到这个版本。如果他们坚持使用*当前*版本的 `collection`,那么任何使用当前版本 `widgets` 的人都能直接使用新版本。
如果他们升级*他们*对 `collection` 的依赖,那么每个升级 `widgets` 的人都必须跟着升级,*无论他们愿不愿意*。这很痛苦,所以就形成了对升级依赖项的抑制。这称为**版本锁定**:每个人都想推动他们的依赖项向前发展,但没有人能迈出第一步,因为它会迫使其他人也这么做。
版本约束 (Dart 方法)
#为了解决版本锁定问题,我们放宽了包对其依赖项施加的约束。如果 `widgets` 和 `templates` 都可以指定它们兼容的 `collection` 版本**范围**,那么这就给我们足够的灵活性将我们的依赖项向前推进到新版本。只要它们的范围有重叠,我们仍然可以找到一个单一版本来满足两者的要求。
这就是 Bundler 遵循的模型,也是 pub 的模型。当你向你的 pubspec 添加依赖项时,你可以指定一个你可以接受的**版本范围**。如果 `widgets` 的 pubspec 像这样:
dependencies:
collection: '>=2.3.5 <2.4.0'
您可以为 `collection` 选择版本 `2.3.7`。一个具体的单一版本将满足 `widgets` 和 `templates` 包的约束。
语义版本控制
#当你将依赖项添加到你的包中时,有时会想要指定允许的版本范围。你怎么知道选择哪个范围?你需要向前兼容,所以理想情况下,范围应该包含尚未发布的未来版本。但是你怎么知道你的包会与一些尚未存在的新版本兼容呢?
为了解决这个问题,你需要就版本号的*含义*达成一致。想象一下,你所依赖的包的开发者说:“如果我们进行任何不向后兼容的更改,我们承诺增加主版本号。” 如果你信任他们,那么如果你知道你的包与他们的 `2.3.5` 版本兼容,你可以依赖它一直工作到 `3.0.0` 版本。你可以设置你的范围如下:
dependencies:
collection: ^2.3.5
为了使其工作,我们需要制定一套承诺。幸运的是,其他聪明人已经解决了这个问题,并将其命名为*语义版本控制*。
这描述了版本号的格式,以及当你增加到更高版本号时,API 行为的具体差异。Pub 要求版本采用该格式,并且为了与 Pub 社区良好协作,你的包应该遵循它指定的语义。你应该假定你依赖的包也遵循它。(如果你发现它们不遵循,请告知它们的作者!)
尽管语义版本控制不保证在 1.0.0
之前的版本之间存在任何兼容性,但 Dart 社区的约定是也将这些版本视为语义版本。对每个数字的解释只是向下移一位:从 0.1.2
到 0.2.0
表示重大更改,到 0.1.3
表示新增功能,到 0.1.2+1
表示不影响公共 API 的更改。为简单起见,在版本达到 1.0.0
后避免使用 +
。
我们现在已经拥有处理版本控制和 API 演进所需的所有组件。让我们看看它们如何协同工作以及 Pub 如何处理。
约束求解
#当你定义你的包时,你会列出它的直接依赖。这些是你的包使用的包。对于这些包中的每一个,你都指定了你的包允许的版本范围。这些依赖包可能再有自己的依赖项。这些称为传递依赖。Pub 遍历这些依赖项并为你的应用构建完整的依赖图。
对于图中的每个包,pub 会查看所有依赖于它的包。它收集所有包的版本约束,并尝试同时解决它们。本质上,它就是求它们的范围交集。然后 pub 查看该包已发布的实际版本,并选择符合所有这些约束的最新版本。
例如,假设我们的依赖图包含 `collection`,并且有三个包依赖于它。它们的版本约束是:
>=1.7.0
^1.4.0
<1.9.0
`collection` 的开发者已经发布了这些版本:
1.7.0
1.7.1
1.8.0
1.8.1
1.8.2
1.9.0
符合所有这些范围的最高版本号是 `1.8.2`,所以 pub 选择它。这意味着你的应用*以及你的应用使用的每个包*都将使用 `collection 1.8.2`。
约束上下文
#选择包版本会考虑*所有*依赖于它的包这一事实具有重要意义:*为包选择的具体版本是使用该包的应用的全局属性。*
下面的示例展示了这意味着什么。假设我们有两个应用。这是它们的 pubspecs:
name: my_app
dependencies:
widgets:
name: other_app
dependencies:
widgets:
collection: '<1.5.0'
它们都依赖于 `widgets`,其 pubspec 如下:
name: widgets
dependencies:
collection: '>=1.0.0 <2.0.0'
`other_app` 包直接依赖于 `collection` 本身。有趣的是,它对 `collection` 的版本约束与 `widgets` 不同。
这意味着你不能仅仅孤立地查看 `widgets` 包来确定它将使用哪个版本的 `collection`。这取决于上下文。在 `my_app` 中,`widgets` 将使用 `collection 1.9.9`。但在 `other_app` 中,由于 `other_app` 对它施加的*另一个*约束,`widgets` 将不得不使用 `collection 1.4.9`。
这就是为什么每个应用都有自己的 `package_config.json` 文件:为每个包选择的具体版本取决于包含该应用的整个依赖图。
导出依赖的约束求解
#包作者必须谨慎地定义包约束。考虑以下场景:

`bookshelf` 包依赖于 `widgets`。`widgets` 包,当前版本为 1.2.0,通过 `export 'package:collection/collection.dart'` 导出了 `collection`,并且 `collection` 的版本为 2.4.0。pubspec 文件如下:
name: bookshelf
dependencies:
widgets: ^1.2.0
name: widgets
dependencies:
collection: ^2.4.0
然后将 `collection` 包更新到 2.5.0 版本。2.5.0 版本的 `collection` 包含一个名为 `sortBackwards()` 的新方法。`bookshelf` 可能调用 `sortBackwards()`,因为它属于 `widgets` 暴露的 API 的一部分,尽管 `bookshelf` 对 `collection` 只有传递依赖。
由于 `widgets` 有一个未在其版本号中反映的 API,使用 `bookshelf` 包并调用 `sortBackwards()` 的应用可能会崩溃。
导出 API 会导致该 API 被视为在包本身中定义,但它无法在 API 添加功能时增加版本号。这意味着 `bookshelf` 无法声明它需要支持 `sortBackwards()` 的 `widgets` 版本。
因此,在处理导出的包时,建议包的作者对依赖项的上限和下限保持更严格的限制。在这种情况下,`widgets` 包的范围应该缩小:
name: bookshelf
dependencies:
widgets: '>=1.2.0 <1.3.0'
name: widgets
dependencies:
collection: '>=2.4.0 <2.5.0'
这转换为 `widgets` 的下限为 1.2.0,`collection` 的下限为 2.4.0。当有人发布 `collection` 的 2.5.0 版本时,pub 会将 `widgets` 更新到 1.3.0 并相应地更新约束。
使用此约定可确保用户拥有两个包的正确版本,即使其中一个不是直接依赖项。
锁文件
#那么一旦 pub 解决了你的应用的版本约束,接下来会发生什么?最终结果是一个完整的列表,列出了你的应用直接或间接依赖的每个包,以及最适合你的应用约束的该包的最佳版本。
对于每个包,pub 获取这些信息,计算其内容哈希,并将两者写入你的应用目录中的一个**锁文件**,名为 `pubspec.lock`。当 pub 为你的应用构建 `.dart_tool/package_config.json` 文件时,它使用锁文件来知道引用每个包的哪个版本。(如果你想知道它选择了哪些版本,你可以阅读锁文件来找出答案。)
Pub 做的下一件重要事情是它**停止修改锁文件**。一旦你的应用有了锁文件,除非你指示它,否则 Pub 不会去动它。这很重要。这意味着你的应用不会在你没有意图的情况下自动开始使用任意包的新版本。一旦你的应用被锁定,它就会保持锁定状态,直到你手动指示它更新锁文件。
如果你的包是用于应用程序的,请**将你的锁文件提交到你的源代码控制系统!** 这样,你的团队中的每个人在构建你的应用程序时都将使用完全相同的依赖项版本。当你部署你的应用程序时,你也将使用它,以确保你的生产服务器使用的包与你开发时使用的包完全相同。
出问题时
#当然,这一切都假定你的依赖图完美无瑕。即使有版本范围、Pub 的约束求解和语义版本控制,你也永远无法完全避免版本问题的危险。
你可能会遇到以下问题之一:
你可以有不相交的约束
#假设你的应用使用了 `widgets` 和 `templates`,并且两者都使用了 `collection`。但 `widgets` 请求的是版本在 `1.0.0` 到 `2.0.0` 之间的版本,而 `templates` 需要的是 `3.0.0` 到 `4.0.0` 之间的版本。这些范围甚至没有重叠。没有可能有效的版本。
你可以有不包含已发布版本的范围
#假设将所有共享依赖项的约束放在一起后,你得到一个狭窄的范围 `> =1.2.4 <1.2.6`。这并不是一个空的范围。如果依赖项有一个版本 `1.2.4`,你就会非常顺利。但也许它们从未发布过该版本。相反,它们直接从 `1.2.3` 跳到了 `1.3.0`。你的范围里没有任何东西。
你可以有一个不稳定的图
#这是目前为止 Pub 版本求解过程中最具挑战性的部分。过程描述为*构建依赖图,然后解决所有约束并选择版本*。但实际上并不是这样工作的。在你选择*任何*版本之前,你怎么能构建*整个*依赖图呢?*pubspec 文件本身是版本特定的。*同一个包的不同版本可能有不同的依赖集。
当你选择包版本时,它们正在改变依赖图本身的形状。随着图的变化,可能会改变约束,这可能导致你选择不同的版本,然后你又回到原点循环。
有时这个过程无法稳定下来,无法找到一个稳定的解决方案。凝视深渊:
name: my_app
version: 0.0.0
dependencies:
yin: '>=1.0.0'
name: yin
version: 1.0.0
dependencies:
name: yin
version: 2.0.0
dependencies:
yang: '1.0.0'
name: yang
version: 1.0.0
dependencies:
yin: '1.0.0'
在所有这些情况下,都没有一套具体版本适用于你的应用,发生这种情况时 Pub 会报告错误并告诉你发生了什么。它肯定不会把你置于某种你认为可行但实际上行不通的奇怪状态。
总结
#总结一下
- 虽然代码重用有优势,但包需要能够独立演进。
- 版本控制实现了这种独立性。依赖于单一的具体版本缺乏灵活性。再加上共享依赖,会导致版本锁定。
- 为了应对版本锁定,你的包应该依赖于一个**版本范围**。Pub 然后遍历你的依赖图,为你选择最佳版本。如果无法选择合适的版本,Pub 会提醒你。
- 一旦你的应用为它的依赖项确定了一套稳定的版本,这套版本就会被固定在一个**锁文件**中。这确保了运行你的应用的每台机器都使用所有依赖项的相同版本。
要了解更多关于 Pub 版本求解算法的信息,请参阅 Medium 上的 PubGrub 文章。