跳到主要内容

包版本控制

pub 包管理器帮助你进行版本控制。本指南将解释一些关于版本控制的历史以及 pub 的处理方法。

将此视为高级信息。要了解 为什么 pub 被设计成这样,请继续阅读。如果你想使用 pub,请查阅其他文档

现代软件开发,尤其是 Web 开发,严重依赖于重用大量现有代码。这包括 过去编写的代码,也包括来自第三方的代码,从大型框架到小型实用程序库。一个应用程序依赖于数十个不同的包和库是很常见的。

这种方式有多强大,怎么强调都不过分。当你看到小型 Web 创业公司在几周内构建一个网站,获得数百万用户的案例时,他们能够实现这一目标的唯一原因是开源社区在他们面前铺设了一桌丰盛的软件盛宴。

但这并非没有代价:代码重用存在一个挑战,特别是重用你不维护的代码。当你的应用使用其他人正在开发的代码时,当他们更改代码时会发生什么?他们不想破坏你的应用,你当然也不想。我们通过版本控制来解决这个问题。

名称和数字

#

当你依赖于某些外部代码时,你不仅仅是说“我的应用使用 widgets”。你说,“我的应用使用 widgets 2.0.5”。名称和版本号的这种组合唯一标识了一个不可变的代码块。更新 widgets 的人可以进行他们想要的所有更改,但他们承诺不会触及任何已发布的版本。他们可以发布 2.0.63.0.0,这不会对你产生任何影响,因为你使用的版本是未更改的。

当你确实想要获取这些更改时,你可以随时将你的应用指向更新版本的 widgets,而无需与这些开发人员协调即可完成。然而,这并没有完全解决问题。

本指南中讨论的版本号可能与包文件名中设置的版本号不同。它们可能包含 -0-beta。这些符号不会影响依赖解析。

解决共享依赖

#

当你的依赖实际上只是一个依赖时,依赖特定版本可以很好地工作。如果你的应用依赖于一堆包,而这些包反过来又有自己的依赖项等等,只要这些依赖项没有重叠,一切都会很好地工作。

考虑以下示例

dependency graph

因此,你的应用使用 widgetstemplates,而它们使用 collection。这被称为共享依赖。现在,当 widgets 想要使用 collection 2.3.5,而 templates 想要 collection 2.3.7 时会发生什么?如果它们在版本上意见不一致怎么办?

非共享库(npm 方法)

#

一种选择是让应用使用两个版本的 collection。它将拥有该库的两个不同版本副本,并且 widgetstemplates 将各自获得它们想要的版本。

这就是 npm 为 node.js 所做的事情。它适用于 Dart 吗?考虑以下情景

  1. collection 定义了一些 Dictionary 类。
  2. widgets 从其 collection 副本 (2.3.5) 中获取了它的一个实例。然后将其传递给 my_app
  3. my_app 将字典发送给 templates
  4. 这反过来又将其发送到版本的 collection (2.3.7)。
  5. 接受它的方法具有该对象的 Dictionary 类型注解。

就 Dart 而言,collection 2.3.5collection 2.3.7 是完全不相关的库。如果你从一个库中获取 Dictionary 类的实例并将其传递给另一个库中的方法,那是一个完全不同的 Dictionary 类型。这意味着它将无法匹配接收库中的 Dictionary 类型注解。糟糕。

由于这个原因(以及由于尝试调试一个具有相同名称的事物的多个版本的应用的头痛问题),我们决定 npm 的模型不太适合。

版本锁定(死胡同方法)

#

相反,当你依赖一个包时,你的应用只使用该包的单个副本。当你有一个共享依赖项时,依赖它的所有内容都必须就使用哪个版本达成一致。如果它们不同意,你将收到一个错误。

但这实际上并没有解决你的问题。但是,当你确实收到该错误时,你需要能够解决它。因此,假设你已经陷入了前面示例中的情况。你想使用 widgetstemplates,但它们正在使用不同版本的 collection。你该怎么办?

答案是尝试升级其中一个。templates 想要 collection 2.3.7。是否有更新版本的 widgets 可以升级到与该版本兼容的版本?

在许多情况下,答案将是“否”。从开发 widgets 的人的角度来看。他们想要发布一个新版本,其中包含对其自己代码的新更改,并且他们希望尽可能多的人能够升级到它。如果他们坚持使用当前版本的 collection,那么任何使用当前版本 widgets 的人都将能够也引入这个新版本。

如果他们升级他们collection 的依赖,那么每个升级 widgets 的人都必须这样做,无论他们是否愿意。这很痛苦,因此你最终会失去升级依赖项的动力。这被称为版本锁定:每个人都想向前推进他们的依赖项,但没有人可以迈出第一步,因为它也会迫使其他所有人也这样做。

版本约束(Dart 方法)

#

为了解决版本锁定,我们放宽了包对其依赖项施加的约束。如果 widgetstemplates 都可以指示它们可以使用的 collection 版本的范围,那么这为我们提供了足够的调整空间来将我们的依赖项向前移动到更新的版本。只要它们的范围存在重叠,我们仍然可以找到一个使它们都满意的版本。

这就是 bundler 遵循的模型,也是 pub 的模型。当你在你的 pubspec 中添加依赖项时,你可以指定你可以接受的版本范围。如果 widgets 的 pubspec 看起来像这样

yaml
dependencies:
  collection: '>=2.3.5 <2.4.0'

你可以为 collection 选择版本 2.3.7。单个具体版本将满足 widgetstemplates 包的约束。

语义化版本

#

当你向你的包添加依赖项时,有时你会想要指定一个允许的版本范围。你如何知道选择哪个范围?你需要向前兼容,因此理想情况下,该范围应包含尚未发布的未来版本。但是你如何知道你的包将与尚不存在的新版本一起工作呢?

为了解决这个问题,你需要就版本号的含义达成一致。想象一下,你依赖的包的开发人员说:“如果我们进行任何向后不兼容的更改,那么我们承诺会增加主版本号。” 如果你信任他们,那么如果你知道你的包与他们的 2.3.5 版本一起工作,你可以依靠它一直工作到 3.0.0。你可以像这样设置你的范围

yaml
dependencies:
  collection: ^2.3.5

为了使之有效,我们需要提出这组承诺。幸运的是,其他聪明人已经完成了弄清楚这一切的工作,并将其命名为语义化版本控制

它描述了版本号的格式,以及当你递增到更高版本号时,确切的 API 行为差异。Pub 要求版本以这种方式格式化,并且为了与 pub 社区良好协作,你的包应遵循它指定的语义。你应该假设你依赖的包也遵循它。(如果你发现它们没有遵循,请告知它们的作者!)

虽然语义化版本控制不承诺 1.0.0 之前的版本之间的任何兼容性,但 Dart 社区的惯例是也对这些版本进行语义化处理。每个数字的解释只是向下移动一个位置:从 0.1.20.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

约束上下文

#

选择包版本时考虑到每个依赖它的包这一事实具有重要的意义:为包选择的具体版本是使用该包的应用的全局属性。

以下示例显示了这意味着什么。假设我们有两个应用。以下是它们的 pubspec

yaml
name: my_app
dependencies:
  widgets:
yaml
name: other_app
dependencies:
  widgets:
  collection: '<1.5.0'

它们都依赖于 widgets,其 pubspec 是

yaml
name: widgets
dependencies:
  collection: '>=1.0.0 <2.0.0'

other_app 包直接依赖于 collection 本身。有趣的是,它恰好对它的版本约束与 widgets 不同。

这意味着你不能仅孤立地查看 widgets 包来确定它将使用哪个版本的 collection。这取决于上下文。在 my_app 中,widgets 将使用 collection 1.9.9。但在 other_app 中,由于 otherapp 对其施加的其他约束,widgets 将被 collection 1.4.9 所束缚。

这就是为什么每个应用都有自己的 package_config.json 文件:为每个包选择的具体版本取决于包含应用的整个依赖关系图。

导出依赖的约束求解

#

包作者必须谨慎定义包约束。考虑以下情景

dependency graph

bookshelf 包依赖于 widgetswidgets 包,当前版本为 1.2.0,通过 export 'package:collection/collection.dart' 导出 collection,版本为 2.4.0。pubspec 文件如下所示

yaml
name: bookshelf
dependencies:
  widgets: ^1.2.0
yaml
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 包的范围应缩小

yaml
name: bookshelf
dependencies:
  widgets: '>=1.2.0 <1.3.0'
yaml
name: widgets
dependencies:
  collection: '>=2.4.0 <2.5.0'

这转换为 widgets 的下限为 1.2.0,collection 的下限为 2.4.0。当有人发布 2.5.0 版本的 collection 时,pub 会将 widgets 更新到 1.3.0,并更新相应的约束。

使用此约定可确保用户拥有正确版本的包,即使其中一个不是直接依赖项也是如此。

锁定文件

#

因此,一旦 pub 解决了你的应用的 版本约束,接下来会发生什么?最终结果是你的应用直接或间接依赖的每个包的完整列表,以及适用于你的应用约束的该包的最佳版本。

对于每个包,pub 获取该信息,从中计算内容哈希,并将两者写入你的应用目录中名为 pubspec.lock锁定文件。当 pub 为你的应用构建 .dart_tool/package_config.json 文件时,它使用锁定文件来了解要引用的每个包的版本。(如果你想知道它选择了哪些版本,你可以阅读锁定文件以查找。)

pub 接下来要做的重要事情是它停止触摸锁定文件。一旦你为你的应用获得了锁定文件,pub 在你告诉它之前不会触摸它。这很重要。这意味着你不会在没有意图的情况下自发地开始在你的应用中使用随机包的新版本。一旦你的应用被锁定,它将保持锁定状态,直到你手动告诉它更新锁定文件。

如果你的包用于应用,你将你的锁定文件签入你的源代码控制系统! 这样,你的团队中的每个人在构建你的应用时都将使用完全相同的每个依赖项的版本。你还将在部署你的应用时使用它,以便你可以确保你的生产服务器正在使用与你正在开发的完全相同的包。

当出现问题时

#

当然,所有这些都假定你的依赖关系图是完美的并且没有缺陷。即使有版本范围和 pub 的约束求解以及语义化版本控制,你也永远无法完全避免版本冲突的危险。

你可能会遇到以下问题之一

你可能存在不相交的约束

#

假设你的应用使用 widgetstemplates,并且两者都使用 collection。但是 widgets 要求它的版本在 1.0.02.0.0 之间,而 templates 想要 3.0.04.0.0 之间的版本。这些范围甚至不重叠。没有可能的版本可以工作。

你可能存在不包含已发布版本的范围

#

假设在将共享依赖项的所有约束放在一起之后,你有一个狭窄的范围 >=1.2.4 <1.2.6。它不是一个空范围。如果存在版本 1.2.4 的依赖项,你就会很顺利。但是他们可能从未发布过该版本。相反,他们直接从 1.2.31.3.0。你有一个没有任何内容的范围。

你可能有一个不稳定的图

#

到目前为止,这是 pub 版本求解过程中最具挑战性的部分。该过程被描述为构建依赖关系图,然后解决所有约束并选择版本。但它实际上并不是那样工作的。在你选择任何版本之前,你如何构建整个依赖关系图?pubspec 本身是版本特定的。 同一包的不同版本可能具有不同的依赖项集。

当你选择包的版本时,它们正在改变依赖关系图本身的形状。随着图的变化,这可能会改变约束,这可能会导致你选择不同的版本,然后你又回到了一个循环中。

有时,此过程永远不会稳定下来成为一个稳定的解决方案。凝视深渊

yaml
name: my_app
version: 0.0.0
dependencies:
  yin: '>=1.0.0'
yaml
name: yin
version: 1.0.0
dependencies:
yaml
name: yin
version: 2.0.0
dependencies:
  yang: '1.0.0'
yaml
name: yang
version: 1.0.0
dependencies:
  yin: '1.0.0'

在所有这些情况下,没有一组具体的版本可以为你的应用工作,当这种情况发生时,pub 会报告一个错误并告诉你发生了什么。它绝对不会让你处于某种奇怪的状态,让你认为事情可以工作但实际上不会工作。

总结

#

总结

  • 尽管代码重用具有优势,但包需要独立演化的能力。
  • 版本控制实现了这种独立性。依赖于单个具体版本缺乏灵活性。加上共享依赖项,会导致版本锁定。
  • 为了应对版本锁定,你的包应该依赖于范围的版本。然后,Pub 会遍历你的依赖关系图,并为你选择最佳版本。如果它无法选择合适的版本,pub 会提醒你。
  • 一旦你的应用为其依赖项获得了一组可靠的版本,该集合就会固定在锁定文件中。这确保了每台运行你的应用的机器都使用相同版本的全部依赖项。

要了解有关 pub 的版本求解算法的更多信息,请查阅 Medium 上的 PubGrub 文章。