当项目变大时,通常的做法是将代码重构为更小、更易于管理的单元,即模块或程序库。你还需要工具来为项目撰写文档,并说明它应该如何构建,以及相关的依赖库是什么。此外,为了支持开发人员可以与社区共享程序代码库的语言生态系统,采用某类在线注册服务是比较流行的做法。

Cargo是能够帮助你处理上述所有事情的工具,crates.io是托管程序库的主要位置。用Rust编写的程序库被称为cratecrates.io托管它们供开发人员使用。通常。crate有3个来源:本地目录、GitHub之类的在线Git代码库,或者像crates.io这样的托管crate注册服务。Cargo支持上述所有来源的软件包。


Cargo 和程序库 - 图1

它显示了一些我们可以使用的常用命令,以及附加标记的参数。我们使用子命令new创建一个新的Cargo项目

新建一个Cargo项目

使用 cargo new <name>命令将会新建一个项目,并将name用作项目目录名。我们可以在cargo和任何子命令之间添加help标签来获得与之有关的更多上下文信息,可以通过运行cargo help new命令查看子命令new的帮助文档,如下所示:

Cargo 和程序库 - 图2

默认情况下,运行cargo new命令会创建一个二进制项目。而创建程序库项目时必须使用—lib参数。让我们执行cargo new imgtool命令,然后介绍一下它创建的目录结构:

  1. $ tree
  2. .
  3. ├── .git
  4. ├── .gitignore
  5. ├── Cargo.toml
  6. └── src
  7. └── main.rs

Cargo 创建了一些基础文件,Cargo.tomlsrc/main.rs,其中的函数 main 主要用于输出 “Hello World!”。对于二进制 crate(可执行文件),Cargo 创建了一个文件 src/main.rs;对于程序库 crateCargo 会在 src 目录下创建文件 src/lib.rs

Cargo 还可以使用默认值为新项目初始化 Git 版本库,例如阻止将.gitignore 文件签入目 标目录,并在 Cargo.lock 文件中检查二进制 crate,同时在程序库 crate 中忽略它。使用的默 认版本控制系统(Version Control System,VCS)是 Git,可以通过将--vcs 标记参数传递给Cargo(--vcs hg for mercurial)来更改它。目前 Cargo 支持的版本控制系统包括 Githg (mercurial)、pijul(用 Rust 编写的版本控制系统)和 fossil。如果我们希望修改默认行为,可以传递--vcs none 来只让 Cargo 在创建项目时不配置任何版本控制系统。

让我们看一下之前创建的 imgtool 项目对应的 Cargo.toml 文件。该文件定义了项目的元数据和依赖项,它也被称为项目的清单文件:

  1. workspace = { members = ["imgtool"] }
  2. [package]
  3. name = "variables"
  4. version = "0.1.0"
  5. edition = "2021"
  6. # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
  7. [dependencies]

这就是新项目最基本的 Cargo.toml 清单文件。它使用 TOML(Tom's Obvious Minimal Language)配置文件格式,TOML 是由 Tom Preston-Werner 创建的配置文件格式。TOML 让人联想到标准的.ini 文件,但被添加了一种数据类型,这使它成为理想的配置文件格式, 并且比 YAMLJSON 格式更简单。我们暂时保留此文件的最少配置信息,并在后续添加 相关的内容。

Cargo 与依赖项

对于依赖其他程序库的项目,软件包管理器必须找到项目中所有直接依赖项和任何间接依赖项,然后编译,并将它们链接到项目。软件包管理器不仅是帮助用户解决依赖性的工具,还应该确保项目可预测和可重复地构建。

Cargo 是通过两个文件来管理 Rust 项目的:Cargo.toml 文件(之前介绍过)由开发人 员使用 semver(如 v1.3.*)编写依赖管理及其所需版本,以及一个名为 Cargo.lock 的锁文 件,它由 Cargo 在构建项目时生成,包含所有直接依赖项和任何间接依赖项的绝对版本(如 1.3.15)。此锁文件确保在二进制项目中能够重复构建。Cargo 通过引用此锁文件来最小化 它必须完成的工作,以便对项目进行任何进一步的更改。因此,建议使二进制项目在其版 本库中包含.lock 文件,而程序库项目是无状态的,不需要包含它。

可以使用 cargo update 命令更新依赖关系,这会更新项目的所有依赖项。为了更新单个 依赖,我们可以使用命令 cargo update -p <crate-name>。如果希望更新单个软件包的某个版 本,Cargo 会确保更新 Cargo.lock 文件中与该软件包相关的部分,并保持其他软件包的版本不变。

Cargo遵循语义版本控制系统,其中你的程序库将以major.minor.patch格式指定。它们的含义如下。

  • Major:只有在对项目进行新的重大更改时(包括错误修复)才会添加。
  • Minor:仅在以后兼容的方式添加新功能时才会添加。
  • Patch:仅在以后向后兼容的方式修复错误,并且未添加任何功能时才会添加。

:::info 例如:你可能希望在项目中引用序列化库 serde。在编写本书时,serde 的最新版本是 1.0.85,你可能只关心主版本号,因此在 Cargo.toml中指定 serde=”1“作为依赖关系(这将 转换为 semver 格式的 1.xx),Cargo 将为你解决并在锁文件中将其修复为 1.0.85。下次使用 cargo update 命令更新 Cargo.lock 时,此版本可能会升级到 1.xx 匹配的最新版本。如果你对 此并不在意,并且只想要最新版本的 crate,那么可以使用“*”指代版本,但这并不是推 荐的做法,因为它会影响构建的可重复性,例如你可能会引入一个与主版本有冲突的变更。 发布项目时使用“*”声明依赖项版本号的做法也是被禁止的。

:::

  1. 为此,我们将了解`cargo`的构建命令,它主要用于编译、链接及构建我们的项目。此命令为你的项目执行以下操作。
  • 如果你还没有Cargo.lock文件,将为你运行cargo updte命令进行更新,并根据Cargo.toml将确切的版本放入锁文件。
  • 下载已在Cargo.lock中解析的依赖项。
  • 构建这些依赖项。
  • 构建项目并将其与依赖项链接。

默认情况下,Cargo会在target/debug/目录创建项目的调试版本,可以传递 --release标记参数,在target/release目录下为正式上线代码创建优化后的构建。调试版本提供了更短的构建时间,缩短了反馈循环,而正式版本的稍慢,因为编译器对源代码运行了更多的优化步骤。在开发过程中,你需要缩短修复-编译-检查的反馈时间。为此,可以使用cargo check命令缩短编译时间。它基本上跳过了编译器的代码生成部分,只通过前端阶段运行代码,即 编译器的解析和语义分析。另一个命令是 cargo run,它会执行双重任务。执行 Cargo 构建, 然后运行 target/debug/目录下的程序。为了构建/运行正式发布的版本,你可以使用 cargo run --release 命令。在我们的 imgtool/目录下运行 cargo run 命令后,可得到以下输出结果:

Cargo 和程序库 - 图3

使用Cargo执行测试

Cargo 还支持运行测试和基准评估。Cargo 运行测试。接下来将为一个程序库编写测试。为此, 让我们运行 cargo new myexponent --lib 命令来创建一个程序库:

  1. % cargo new myexponent --lib
  2. Created library `myexponent` package
  3. % cat src/lib.rs
  4. pub fn add(left: usize, right: usize) -> usize {
  5. left + right
  6. }
  7. #[cfg(test)]
  8. mod tests {
  9. use super::*;
  10. #[test]
  11. fn it_works() {
  12. let result = add(2, 2);
  13. assert_eq!(result, 4);
  14. }
  15. }

一个程序库类似于一个二进制项目,两者不同之处在于,我们得到的不是 src/main.rs 并将其中的 main 函数作为入口点,而是 src/lib.rs,其中有一个简单的测试函数 it_works, 并附有#[test]注释。我们可以使用 cargo test 命令立即运行 it_works 函数,并查看它的结果:

Cargo 和程序库 - 图4

现在,让我们尝试一下 Cargo 的测试驱动开发(Test Driven Development,TDD)。我 们将通过添加一个指数函数(pow 函数)来扩展此程序库,程序库的用户可以使用该函 数计算给定数字的指数。我们将为这个函数编写一个最初不够完善的测试,然后逐步对 它进行优化,直到能够正常运作。这是新的 src/lib.rs 文件,其中包含没有任何实现的 pow 函数:

  1. fn pow(base: i64, exponent: usize) > i64 {
  2. unimplemented!();
  3. }
  4. #[cfg(test)]
  5. mod tests {
  6. use super::*;
  7. #[test]
  8. fn minus_two_raised_three_is_minus_eight() {
  9. assert_eq!(pow(-2, 3), -8);
  10. }
  11. }

现在不必担心细节,我们已经实现了一个 pow 函数,它将 i64 作为基数,将正指数的 类型指定为 usize,并返回了一个已经转化为指数的数字。在“mod tests”中,我们有一个 名为 minus_two_raised_three_is_minus_eight 的测试函数,它会执行单个断言。宏 assert_eq! 将会检查传递给它的两个值的相等性。如果左边的参数等于右边的参数,则断言通过;否 则抛出一个错误,编译器会提示测试失败。如果我们执行 cargo testpow 函数调用的单元 测试显然是失败的,因为我们有一个 unimplemented!()宏会被调用:

Cargo 和程序库 - 图5

简而言之,unimplemented!()只是一个方便的宏,用来标记未完成的代码或者你希望稍 后实现的代码,但是在希望编译器不出现类型错误的情况下无论如何都要编译它。在编译 器内部,这会调用宏 panic!并伴随提示信息“not yet implemented”。它可以在你希望实现某 个特征的多种方法的情况下使用。例如,你开始实现某个方法,但是还没有打算完成该实 现的其他方法。在编译时,如果你只是提供一个空的函数体,那么将会得到未提供其他方 法实现的错误提示。对于这些方法,我们可以在其中放置一个unimplemented!()宏,使其通 过类型检查器的校验从而顺利编译,并在运行时避免这些错误。我们将在后面介绍一些 具有类似功能的、更简便的宏。

现在,让我们快速地实现 pow 函数的一个有缺陷的版本来解决此问题,然后再试一次:

  1. pub fn pow(base: i64, exponent: usize) -> i64 {
  2. let mut res = 1;
  3. if exponent == 0 {
  4. return 1; }
  5. for _ in 0..exponent {
  6. res *= base as i64;
  7. }
  8. res }

运行cargo test命令之后得到如下输出结果:

Cargo 和程序库 - 图6

使用Cargo运行示例

为了让用户能够快速地使用你开发的软件包,最好提供能够引导用户使用它的代码示 例。Cargo 标准化了这种方式,这意味着你可以在项目根目录中添加一个包含一个或多个.rs 文件的 examples/目录,其中的 main 函数展示了软件包的用法。

可以使用 cargo run --examples<file_name>命令运行 examples/目录下的代码,其中的文 件名不带.rs 扩展名。为了证实这一点,我们为 myexponent 库添加一个 examples/目录,其 中包含一个名为 basic.rs 的文件:

  1. use myexponent::pow;
  2. fn main() {
  3. println!("8 raised to 2 is {}", pow(8, 2));
  4. }

examples/目录下,我们从 myexponent 库导入了 pow 函数。以下是运行 cargo run --example basic 命令后的输出结果:

Cargo 和程序库 - 图7

Cargo 工作区

随着时间的推移,你的项目可能会变得非常庞大,现在,你需要考虑是否将代码的通 用部分拆分成单独的程序库,以便管理复杂性。Cargo 的工作区(workspace)可以帮你做 到这一点。工作区的概念是,它们允许你在可以共享相同的 Cargo.lock 文件和公共目录, 或者输出目录的目录下创建本地程序库。为了证明这一点,我将创建一个包含 Cargo 工作 区的新项目。工作区只是一个包含 Cargo.toml 文件的目录。它不包含任何[package]部分, 但是其中有一个[workspace]项。让我们新建一个名为 workspace_demo 的新目录,并按照如 下步骤添加一个 Cargo.toml 文件:

% mkdir workspace_demo

% cd workspace_demo && touch Cargo.toml

然后我们将[workspace]项添加到Cargo.toml文件中:

  1. vim Cargo.toml
  2. [workspace]
  3. members = ["my_crate", "app"]

[workspace]项下,members 属性表示工作区目录中的程序库列表。在 workspace_demo 目录中,我们将创建两个程序库:一个是程序库 my_crate,一个是调用 my_crate 库的二进 制程序 app

为了保持简洁,my_crate 中只包含一个公有的 API,用于输出一条问候消息:

  1. pub fn greet() {
  2. println!("Hi from my_crate");
  3. }

在我们的 app 程序中有 main 函数,它会调用 my_crate 程序库中的 greet 函数:

  1. fn main() {
  2. my_crate::greet();
  3. }

不过,我们需要让 Cargo 识别 my_crate 中的依赖关系。由于 my_crate 是一个本地程序 库,我们需要在 appCargo.toml 文件中将其指定为路径依赖,如下所示:

  1. [package]
  2. name = "app"
  3. version = "0.1.0"
  4. authors = ["creativcoder"]
  5. edition = "2021"
  6. [dependencies]
  7. my_crate = { path = "../my_crate" }

现在,当我们运行 cargo build 命令时,二进制文件将在 workspace_demo 目录下的 target 目录中生成。此外,我们可以在 workspace_demo 目录中添加多个本地程序库。现在,如果 我们想要通过 crates.io 添加第三方的依赖项,那么需要将它们添加到所有会调用它们的程 序中。不过,在 Cargo 构建过程中,Cargo 会确保在 Cargo.lock 文件中只有该依赖项的单一 版本。这可以确保不会重新构建或者重复出现第三方依赖项。