Cargo:可预测的依赖管理

2016年5月5日 · Yehuda Katz

Cargo 的目标是使现代应用程序包管理成为 Rust 编程语言的核心价值。

在实践中,这个目标转化为能够使用 247 个社区驱动的库(并且还在增加)构建像 Servo 这样的新浏览器引擎。Servo 的构建系统是 Cargo 的一个薄封装,在全新检出后,您只需一个命令即可看到构建的整个依赖关系图

   Compiling num-complex v0.1.32
   Compiling bitflags v0.6.0
   Compiling angle v0.1.0 (https://github.com/emilio/angle?branch=servo#eefe3506)
   Compiling backtrace v0.2.1
   Compiling smallvec v0.1.5
   Compiling browserhtml v0.1.4 (https://github.com/browserhtml/browserhtml?branch=gh-pages#0ca50842)
   Compiling unicase v1.4.0
   Compiling fnv v1.0.2
   Compiling heapsize_plugin v0.1.4
   ...

为什么这些细粒度的依赖关系很重要?

具体来说,这意味着 Servo 的 URL 库(以及许多类似组件)不是 Servo 主树中深层嵌套的一部分,而是一个生态系统中任何人都可以使用的外部库。这使得其他 Rust 库(如 Web 框架)可以轻松使用浏览器级的 URL 库,共享维护的成本和收益。并且它是双向的:最近,一个新型的基于 Rust 的文本编辑器发布,它碰巧提供了一个快速的断行库。几天之内,该库取代了 Servo 的旧自定义断行器,减少了 Servo 的维护负担,并增加了 Rust 生态系统中的共享。

依赖管理的核心关注点

为了使这一切在像 Servo 这样的应用程序规模上工作,您需要一种依赖管理方法,它可以很好地回答一些棘手的问题

  1. 向 Servo 添加一个外部库(如新的断行器)有多容易?

  2. 如果我在不同的机器上、不同的架构上、在 CI 中或为了发布而构建 Servo,我是否在从相同的源代码进行构建?

  3. 如果我构建 Servo 用于测试,它的间接依赖项是否会使用调试符号进行编译?如果我构建 Servo 用于发布,它的间接依赖项是否会使用最大优化进行编译?我如何才能确定?

  4. 如果我在我提交 Servo 后,有人发布了 Servo 的某个依赖项的新版本,我的 CI 环境是否会使用与我的机器相同的源代码?我的生产环境呢?

  5. 如果我添加一个新的依赖项(或升级一个),它是否会破坏构建?它是否会影响不相关的依赖项?在什么条件下?

所有这些问题(以及更多类似的问题)都有一个共同点:可预测性。系统领域中针对此问题的一个常见解决方案是供应商依赖项—将它们直接分叉到应用程序的存储库中,然后手动管理它们。但这会带来巨大的每个项目成本,因为有更多需要管理和配置的内容。它还会带来整个生态系统的成本,因为所涉及的工作不能轻易在库之间共享;而是必须为每个将一组库组合在一起的应用程序重新完成。并且确保您可以始终回答上面的所有问题确实是一项艰巨的工作。

更高级语言的包管理器表明,通过将依赖管理交给共享工具,您可以获得可预测性,在整个依赖关系图上运行的简单工作流程,以及整个生态系统中更高的共享性和健壮性。当我们开始计划 Rust 1.0 时,我们知道我们希望将这些想法带入系统设置中,并且使 Cargo 成为人们使用 Rust 的核心部分。

Cargo 的支柱

Cargo 构建在三个主要支柱上

  1. 在不同的环境和时间中,构建、测试和运行项目应该是可预测的。

  2. 在可能的范围内,间接依赖项对于应用程序作者应该是不可见的。

  3. Cargo 应该为 Rust 生态系统提供一个共享的工作流程,以帮助实现前两个目标。

我们将依次查看每个支柱。

可预测性

Cargo 的可预测性目标从一个简单的保证开始:一旦项目在一台机器上成功编译,后续在不同机器和环境上的编译将使用完全相同的源代码

此保证的实现无需将依赖项的源代码直接合并到项目存储库中。相反,Cargo 使用了多种策略

  1. 首次构建成功后,Cargo 会发出一个 Cargo.lock 文件,其中包含构建中使用的精确源代码的清单。(稍后会详细介绍“精确”)

  2. Cargo 管理整个工作流程,从运行测试和基准测试,到构建发布工件,再到运行可执行文件进行调试。这使得 Cargo 可以确保为这些用例下载并正确配置所有依赖项(直接和间接),而无需用户执行任何额外操作。

  3. Cargo 标准化重要的环境配置,如优化级别、静态和动态链接以及架构。结合 Cargo.lock,这使得构建、测试和执行 Cargo 项目的结果具有高度可预测性。

可预测性示例

为了说明这些策略,让我们使用 Cargo 构建一个示例 crate。为了简单起见,我们将创建一个小的 datetime crate,该 crate 公开日期和时间功能。

首先,我们将使用 cargo new 来开始

$ cargo new datetime
$ cd datetime
$ ls
Cargo.toml src
$ cat Cargo.toml
[package]
name = "datetime"
version = "0.1.0"
authors = ["Yehuda Katz <[email protected]>"]

[dependencies]

我们不想从头开始构建日期或时间功能,因此让我们编辑 Cargo.toml 并从 crates.io 添加 time crate

  [package]
  name = "datetime"
  version = "0.1.0"
  authors = ["Yehuda Katz <[email protected]>"]

  [dependencies]
+ time = "0.1.35"

现在我们已经添加了 time crate,让我们看看如果我们要求 Cargo 构建我们的包会发生什么

$ cargo build
   Compiling winapi v0.2.6
   Compiling libc v0.2.10
   Compiling winapi-build v0.1.1
   Compiling kernel32-sys v0.2.2
   Compiling time v0.1.35
   Compiling datetime v0.1.0 (file:///Users/ykatz/Code/datetime)

哇!有很多 crate。Cargo 的最大任务是提供足够的可预测性,以允许像 time crate 这样的功能分解为更小的 crate,这些 crate 做一件事并且做得很好

现在我们已经成功构建了我们的 crate,如果我们尝试再次构建它会发生什么?

$ cargo build

什么也没发生。为什么会这样?我们总是可以通过 --verbose 标志要求 Cargo 提供更多信息,所以让我们这样做

$ cargo build --verbose
       Fresh libc v0.2.10
       Fresh winapi v0.2.6
       Fresh winapi-build v0.1.1
       Fresh kernel32-sys v0.2.2
       Fresh time v0.1.35
       Fresh datetime v0.1.0 (file:///Users/ykatz/Code/datetime)

Cargo 没有费心重新编译它知道是“新鲜”的包,就像 make 一样,但无需编写 Makefile

但是 Cargo 如何知道一切都是新鲜的?当 Cargo 构建一个 crate 时,它会发出一个名为 Cargo.lock 的文件,其中包含其所有已解析依赖项的精确版本

[root]
name = "datetime"
version = "0.1.0"
dependencies = [
 "libc 0.2.10 (registry+https://github.com/rust-lang/crates.io-index)",
 "time 0.1.35 (registry+https://github.com/rust-lang/crates.io-index)",
]

[[package]]
name = "kernel32-sys"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
 "winapi 0.2.6 (registry+https://github.com/rust-lang/crates.io-index)",
 "winapi-build 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)",
]

...

Cargo.lock 包含整个已解析依赖关系图的序列化版本,包括构建中包含的所有源代码的精确版本。在来自 crates.io 的包的情况下,Cargo 会存储依赖项的名称和版本。这足以唯一标识来自 crates.io 的源代码,因为注册表是仅追加的(不允许更改已发布的包)。

此外,注册表的元数据存储在单独的 git 存储库中,并包括相关包的校验和。在 Cargo 解压缩下载的 crate 之前,它会先验证校验和。

协作

现在进行真正的测试。让我们将代码推送到 GitHub,并在另一台机器上进行开发。理想情况下,我们希望能够从上次离开的地方继续,并为我们所有的依赖项使用完全相同的源代码。

为此,我们签入 Cargo.lock 并将存储库克隆到我们的新机器上。然后,我们再次运行 cargo build

$ cargo build
   Compiling libc v0.2.10
   Compiling winapi v0.2.6
   Compiling winapi-build v0.1.1
   Compiling kernel32-sys v0.2.2
   Compiling time v0.1.35
   Compiling datetime v0.1.0 (file:///Users/ykatz/Code/datetime)

正如预期的那样,因为我们签入了 Cargo.lock,所以我们获得了与之前完全相同的依赖项版本。如果我们想开始与 GitHub 上的其他开发人员(或工作中的团队的其他成员)进行协作,我们将继续获得相同级别的可预测性。

常见约定:示例、测试和文档

现在我们已经编写了我们时髦的新 datetime crate,我们很乐意编写一个示例来向其他开发人员展示如何使用它。我们创建一个名为 examples/date.rs 的新文件,如下所示

extern crate datetime;

fn main() {
    println!("{}", datetime::DateTime::now());
}

要运行示例,我们要求 Cargo 构建并运行它

$ cargo run --example date
   Compiling datetime v0.1.0 (file:///Users/ykatz/Code/datetime)
     Running `target/debug/examples/date`
26 Apr 2016 :: 05:03:38

因为我们将代码放在示例的常规位置,所以 Cargo 知道如何做正确的事情,毫不费力。

此外,一旦你开始编写一些测试,cargo test 也会自动构建你的示例,这可以防止它们与你的代码不同步,并确保它们在你通过测试时继续编译。

同样,cargo doc 命令不仅会自动编译你的代码,还会编译你的依赖项的代码。结果是它自动生成的 API 文档包含你所依赖的 crate,因此如果你的 API 提及这些 crate 中的类型,你的客户端可以跟踪这些链接。

这些只是一个一般观点的几个示例:Cargo 定义了一组通用的约定和工作流程,这些约定和工作流程在整个 Rust 生态系统中以完全相同的方式运行

更新

所有这些都意味着,如果你不对依赖项进行任何更改,你的应用程序就不会更改,但是当你需要更改它们时会发生什么?

Cargo 通过保守更新添加了另一层保护。这意味着如果你修改了 Cargo.toml,Cargo 会尝试最小化对 Cargo.lock 所做的更改。保守更新的直觉是:如果你所做的更改与另一个依赖项无关,则不应更改它

假设在开发了一段时间库后,我们决定要添加对时区的支持。首先,让我们将 tz 依赖项添加到我们的包中

  [package]
  name = "datetime"
  version = "0.1.0"
  authors = ["Yehuda Katz <[email protected]>"]

  [dependencies]
  time = "0.1.35"
+ tz = "0.2.1"

在我们的库中使用该 crate 后,让我们再次运行 cargo build

$ cargo build
    Updating registry `https://github.com/rust-lang/crates.io-index`
 Downloading tz v0.2.1
 Downloading byteorder v0.5.1
   Compiling byteorder v0.5.1
   Compiling tz v0.2.1
   Compiling datetime v0.1.0 (file:///Users/ykatz/Code/datetime)

Cargo 下载了 tz(及其依赖项 byteorder)并对其进行了编译,但它没有触及我们已经使用的包(kernel32-syslibctimewinapiwinapi-build)。即使其中一个包作者在此期间发布了更新,你也可以确定添加新的 crate 不会弄乱不相关的包。

保守更新试图显着减少对你的源代码的意外更改。它与“重建世界”形成鲜明对比,后者允许对依赖项进行微小的更改来重建整个图,从而在其后造成严重破坏。

通常,Cargo 会尝试最小化有意更改直接依赖项的影响。

间接依赖项“正常工作”

应用程序包管理器最基本的目标之一是分离直接依赖项(应用程序所需的依赖项)和间接依赖项(这些依赖项正常工作所需的依赖项)。

正如我们在构建的 datetime crate 中所看到的,我们只需要指定对 timetz 的依赖,Cargo 就会自动创建使之正常工作所需的整个依赖关系图。它还会序列化该图,以确保未来的可预测性。

由于 Cargo 为你管理依赖项,它还可以确保针对当前任务正确编译所有依赖项(无论你是否直接知道它们)。

测试、基准测试、发布,我的天哪

从历史上看,人们一直回避我们在这里看到的这种细粒度的依赖关系,因为每个新依赖项都需要配置。

例如,在运行测试或类型检查代码时,你希望尽可能快地编译代码以保持快速反馈循环。另一方面,在基准测试或发布代码时,你愿意花大量时间等待编译器优化你的代码,如果它能产生一个快速的二进制文件。

重要的是,不仅要用相同的配置编译你自己的代码或直接依赖项,还要编译所有间接依赖项。

Cargo 会自动为你管理这个过程。让我们为代码添加一个基准测试

#[bench]
fn bench_date(b: &mut Bencher) {
    b.iter(|| DateTime::now());
}

如果我们然后运行 cargo bench

$ cargo bench
   Compiling winapi v0.2.6
   Compiling libc v0.2.10
   Compiling byteorder v0.5.1
   Compiling winapi-build v0.1.1
   Compiling kernel32-sys v0.2.2
   Compiling tz v0.2.1
   Compiling time v0.1.35
   Compiling datetime v0.1.0 (file:///Users/ykatz/Code/datetime)
     Running target/release/datetime-2602656fcee02e68

running 1 test
test bench_date ... bench:         486 ns/iter (+/- 56)

请注意,我们正在重新编译所有依赖项。这是因为 cargo bench 默认使用 release 模式,该模式使用最大优化。cargo build --release 类似地默认以优化模式构建。

顺便提一下,每个命令的默认行为可以通过 Cargo.toml 中的 profile 进行配置。这允许你配置诸如优化级别、是否包含调试符号等内容。配置文件功能允许你自定义现有工作流程并保持在 Cargo 的流程中,而不是强制你使用自定义工作流程(如果某些内容不能完全满足你的需求)。

平台和架构

类似地,应用程序通常针对不同的架构、操作系统甚至操作系统版本构建。它们可以编译为最大程度的可移植性,或者最大程度地利用可用的平台功能。

库可以编译为静态库或动态库。甚至静态库可能也想进行一些动态链接(例如,针对系统版本的 openssl)。

通过标准化构建和配置包的含义,Cargo 可以将所有这些配置选择应用于你的直接依赖项间接依赖项。.

共享依赖项

到目前为止,我们已经研究了包及其依赖项。但是,如果你的应用程序依赖的两个包共享第三个依赖项怎么办?

例如,假设我决定向我的 datetime 库添加 nix crate 以实现特定于 Unix 的功能。

  [package]
  name = "datetime"
  version = "0.1.0"
  authors = ["Yehuda Katz <[email protected]>"]

  [dependencies]
  time = "0.1.35"
  tz = "0.2.1"
+ nix = "0.5.0"

和以前一样,当我运行 cargo build 时,Cargo 会保守地添加 nix 及其依赖项。

$ cargo build
    Updating registry `https://github.com/rust-lang/crates.io-index`
 Downloading nix v0.5.0
 Downloading bitflags v0.4.0
   Compiling bitflags v0.4.0
   Compiling nix v0.5.0
   Compiling datetime v0.1.0 (file:///Users/ykatz/Code/datetime)

但是,如果我们仔细观察,我们会注意到 nix 依赖于 bitflags libc。它现在与 date共享libc 的依赖。

如果我的 datetime crate 从 time 获取 libc 类型并将其传递给 nix,它们最好是相同的 libc,否则我的程序将无法编译(而且我们也不希望它这样!)

今天,如果 crate 依赖于相同的主要版本(或者 1.0 之前的次要版本),Cargo 会自动在 crate 之间共享依赖项,因为 Rust 使用 语义版本控制。这意味着如果 nixdatetime 都依赖于 libc 0.2.x 的某个版本,它们将获得相同的版本。在这种情况下,它们会这样做,并且程序会编译。

虽然此策略运行良好(实际上与系统包管理器使用的策略相同),但它并不总是完全符合人们的预期,尤其是在协调整个生态系统中的主要版本升级时。(在许多情况下,Cargo 最好抛出硬错误,而不是假设对 0.2.x 的依赖与对 0.3.x 的另一个依赖无关。)

当同一个包的多个主要版本公开全局符号(例如,使用 #[no_mangle],或通过包含其他静态链接的 C 库)时,这个问题尤其棘手。

我们有一些关于如何改进 Cargo 以更好地处理这些情况的想法,包括让包更明确地表达依赖项何时仅在内部使用,而不是通过其公共接口共享。如果需要,这些包可以更容易地被复制,而包的公共接口中使用的依赖项则不能被复制。

你应该期望在未来几个月内看到更多关于这个主题的信息。

工作流程

正如我们所看到的,Cargo 不仅仅是一个依赖项管理器,还是 Rust 的主要工作流程工具

这使 Rust 可以拥有一个丰富的互连依赖项生态系统,从而消除了应用程序手动管理大型依赖关系图的需要。应用程序可以受益于一个充满活力的小型包生态系统,这些包只做一件事并做得很好,并让 Cargo 处理保持所有内容更新并正确编译的繁重工作。

即使像我们构建的 datetime 示例这样的小 crate 也有一些对小型、有针对性的 crate 的依赖,而每个 crate 都有自己的依赖。

通过定义共享的、众所周知的工作流程(如 "build"、"test"、"bench"、"run" 和 "doc"),Cargo 为 Rust 程序员提供了一种在高层次上思考他们试图完成的事情的方式,而不必担心每个工作流程对于间接依赖意味着什么。

这使我们更接近于使这些间接依赖关系图“不可见”的圣杯,使个人能够在他们的业余项目中做更多的事情,小团队能够在他们的产品上做更多的事情,而大团队对他们的工作输出有高度的信心。

有了即使在面对许多间接依赖项时也能提供可预测性的工作流程工具,我们可以共同构建更高层次的东西。