Cargo:可预测的依赖项管理

2016 年 5 月 5 日 · Yehuda Katz

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

实际上,这个目标意味着能够使用 247 个(还在不断增加的)社区驱动的库来构建像 Servo 这样的新型浏览器引擎。Servo 的构建系统是 Cargo 的一个薄封装层,完成全新签出(fresh checkout)后,只需一条命令即可看到整个依赖图被构建完成。

   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. 如果添加新依赖项(或升级现有依赖项),它会破坏构建吗?会影响不相关的依赖项吗?在什么条件下会发生?

所有这些问题(以及许多类似问题)都有一个共同点:可预测性。在系统领域,一个常见的解决方案是将依赖项 vendoring 化——直接将它们分叉(forking)到应用的仓库中——然后手动管理。但这会带来巨大的项目级成本,因为需要管理和配置的内容更多。它也会带来生态系统级的成本,因为相关工作不能轻易在库之间共享;每个将一组库组合在一起的应用都必须重新完成这些工作。而要确保你始终能回答上述所有问题,确实是一项艰巨的任务。

高级语言的包管理器已经表明,通过将依赖项管理交给一个共享工具,可以实现可预测性、在整个依赖图上易于操作的工作流程,并增加生态系统中的共享和健壮性。当我们开始规划 Rust 1.0 时,就知道希望将这些想法带到系统编程领域,而让 Cargo 成为人们使用 Rust 的核心部分就是其中的重要一环。

Cargo 的支柱

Cargo 构建于三大支柱之上

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

  2. 在可能的情况下,间接依赖项对应用开发者应该是“不可见”的。

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

我们将依次探讨这些支柱。

可预测性

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

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

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

  2. Cargo 管理着整个工作流程,从运行测试和基准测试,到构建发布 artifact,再到运行可执行文件进行调试。这使得 Cargo 能够确保所有依赖项(直接和间接)都已下载并针对这些用例进行了适当配置,用户无需做任何额外的事情。

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

通过示例看可预测性

为了说明这些策略,我们用 Cargo 构建一个示例 crate。为简单起见,我们创建一个小的 datetime 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 <wycats@gmail.com>"]

[dependencies]

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

  [package]
  name = "datetime"
  version = "0.1.0"
  authors = ["Yehuda Katz <wycats@gmail.com>"]

  [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,如果再构建一次会发生什么?

$ 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 不会费心重新编译它知道是“最新”(fresh)的包,就像 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 <wycats@gmail.com>"]

  [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 不会影响不相关的 crate。

保守更新试图显著减少对源代码的意外更改。这与“重建整个世界”(rebuild the world)形成鲜明对比,“重建整个世界”允许对依赖项进行微小更改就会重建整个依赖图,随之而来的是混乱。

一般来说,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 mode),该模式使用最高优化级别。cargo build --release 类似地默认在优化模式下构建。

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

平台与架构

类似地,应用通常为不同的架构、操作系统甚至操作系统版本构建。它们可以为了最大可移植性而编译,或者为了充分利用可用的平台特性而编译。

库可以编译为静态库或动态库。即使是静态库也可能需要进行一些动态链接(例如,链接到系统版本的 openssl)。

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

共享依赖项

到目前为止,我们已经讨论了包及其依赖项。但是,如果你的应用依赖的两个包共享第三个依赖项呢?

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

  [package]
  name = "datetime"
  version = "0.1.0"
  authors = ["Yehuda Katz <wycats@gmail.com>"]

  [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。它现在与 datetime共享libc 的依赖。

如果我的 datetime crate 从 time 获得了 libc 类型并将其传递给 nix,它们最好是同一个 libc,否则我的程序将无法编译(我们也不希望它能编译!)

如今,如果两个 crate 依赖于同一个版本(或 1.0 之前的次版本),Cargo 会自动在它们之间共享依赖项,因为 Rust 使用语义版本控制。这意味着如果 nixdatetime 都依赖于 libc 0.2.x 的某个版本,它们将获得同一个版本。在这种情况下,它们确实如此,程序也成功编译。

虽然这个策略运行良好(实际上与系统包管理器使用的策略相同),但它并非总是完全符合人们的预期,特别是在协调整个生态系统的主版本升级时。(在许多情况下,相比于假设对 0.2.x 的依赖与对 0.3.x 的依赖完全不相关,Cargo 硬错误会更好。)

当同一个包的多个主版本暴露全局符号时(例如使用 #[no_mangle],或包含其他静态链接的 C 库),这个问题尤其具有危害性。

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

您应该会在未来几个月内看到关于此主题的更多信息。

工作流程

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

这使得 Rust 拥有一个丰富的、相互关联的依赖项生态系统,消除了应用手动管理大型依赖图的需要。应用可以受益于一个充满活力的生态系统,其中包含许多只做一件事并做好这件事的小型包,让 Cargo 处理保持一切更新和正确编译的繁重工作。

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

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

这使我们更接近“圣杯”(holy grail)目标,即让这些间接依赖图“不可见”,赋能个人在其业余项目中做更多事情,小型团队在其产品中做更多事情,大型团队对其工作的产出具有高度信心。

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