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 构建一个示例板条箱。为了简单起见,我们将创建一个名为 datetime 的小型板条箱,它公开日期和时间功能。

首先,我们将使用 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 板条箱。

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

  [dependencies]
+ time = "0.1.35"

现在我们已经添加了 time 板条箱,让我们看看如果我们要求 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)

哇!有很多板条箱。Cargo 最重要的工作是提供足够的可预测性,以允许像 time 板条箱这样的功能被分解成更小的板条箱,这些板条箱只做一件事,并且做得很好

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

$ cargo build

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

$ 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 构建一个板条箱时,它会生成一个名为 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 解压缩它下载的任何板条箱之前,它首先会验证校验和。

协作

现在进行真正的测试。让我们将我们的代码推送到 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 板条箱,我们很想编写一个示例来向其他开发人员展示它应该如何使用。我们创建一个名为 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 文档包含您依赖的板条箱,因此如果您的 API 提到了来自这些板条箱的类型,您的客户端可以点击这些链接。

这些只是通用观点的一些示例: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"

在我们的库中使用该板条箱之后,让我们再次运行 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)。即使其中一个包作者在此期间发布了更新,您也可以确定添加新的板条箱不会弄乱无关的板条箱。

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

作为一项规则,Cargo 试图将对直接依赖项的故意更改的影响降至最低。

间接依赖项“正常工作”

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

正如我们在构建的 datetime 板条箱中所见,我们只需要指定对 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 默认使用发布模式,该模式使用最大优化。cargo build --release 同样默认以优化模式构建。

顺便说一下,每个命令的默认行为可以通过 Cargo.toml 中的 配置文件 进行配置。这允许您配置诸如优化级别、是否包含调试符号等内容。配置文件功能允许您自定义现有工作流程,而不是在某些内容不完全满足您的需求时强迫您使用自定义工作流程,从而使您能够保持在 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 依赖于 bitflagslibc。它现在共享libc 的依赖关系,与 date 包共享。

如果我的 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 中的每一个都有一些自己的依赖项。

通过定义共享的、众所周知的工作流程,如“构建”、“测试”、“基准测试”、“运行”和“文档”,Cargo 为 Rust 程序员提供了一种思考他们想要在高级别上完成什么的方法,而不必担心这些工作流程对间接依赖项意味着什么。

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

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