Cargo: 创建项目、管理依赖和组织工作区

1. 概述

Cargo 是 Rust 官方的构建工具和包管理器。它创建项目、加载依赖、编译、运行测试,以及在同一个代码库中管理多个 crate。

本文介绍你每天用 Cargo 做的三件事。首先是创建二进制和库项目。其次是添加和配置依赖。第三是组织一个包含多个 crate 的工作区,共享配置。读完本文,你可以构建一个多模块的 Rust 项目,而无需到处复制依赖的版本号。

2. 创建新项目

2.1. 二进制和库

cargo new 命令创建一个新的 crate。默认情况下,它是一个二进制 crate,即具有 main 函数的程序:

cargo new order-service
cd order-service

生成的目录结构:

order-service/
├── Cargo.toml
├── .gitignore
└── src/
    └── main.rs

如果想为其他 crate 编写可用的库,你需要添加 --lib 标志:

cargo new pricing --lib

此时 Cargo 会创建 src/lib.rs 而不是 src/main.rs。一个 crate 可以同时有两个文件。此时 main.rs 通过 crate 名称调用 lib.rs 中的逻辑。

2.2. Cargo.toml 文件

Cargo.toml 是 crate 的清单:

[package]
name = "order-service"
version = "0.1.0"
edition = "2024"

[dependencies]
  • name:crate 的名称。名称中的 - 在代码中会变成 _,例如 order_service。
  • version:根据语义化版本号。
  • edition:语言版本。Edition 2024 从 Rust 1.85 开始提供,cargo new 在当前工具链上默认使用它。
  • [dependencies]:外部 crate 的列表,当前为空。

2.3. 基本构建命令

cargo check        # 检查编译错误,不生成可执行文件
cargo build        # 构建 debug 版本到 target/debug
cargo run          # 构建并运行
cargo build --release   # 构建优化版本到 target/release

当编写代码时,你应该使用 cargo check。这个命令比 cargo build 快得多,因为它跳过了代码生成步骤。只有在性能测量或为部署构建时才运行 --release。debug 版本可能比 release 版本慢几十倍。

3. 添加依赖

3.1. 使用 cargo add

假设你需要将 Order 序列化为 JSON。用于此的两个常见 crate 是 serde 和 serde_json:

cargo add serde --features derive
cargo add serde_json

Cargo 自动修改 Cargo.toml:

[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

你可以手动编辑此文件。cargo add 只是帮助你避免输入错误,并自动选择最新兼容版本。

3.2. 版本要求是什么意思

字符串 "1.0" 不 意味着恰好是版本 1.0.0。Cargo 将其理解为 ^1.0,即任何版本 >= 1.0.0 且 < 2.0.0。一些其他写法:

[dependencies]
regex = "1.10"          # >= 1.10.0, < 2.0.0
chrono = "=0.4.38"      # 恰好 0.4.38
rand = "~0.8.5"         # >= 0.8.5, < 0.9.0
uuid = ">=1.4, <1.9"    # 自定义范围

对于版本 0.x,Cargo 将第二个数字视为“主”部分。因此 "0.4" 仅允许 0.4.* 并不会升级到 0.5。

实际使用的版本记录在 Cargo.lock 中。Cargo 在第一次构建时写入此文件。后续构建将使用这些确切的版本,直到你运行:

cargo update              # 更新允许范围内的所有依赖
cargo update -p serde     # 仅更新 serde

3.3. 在代码中使用依赖

修改 src/main.rs:

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
struct Order {
    id: u64,
    customer: String,
    total_cents: u64,
}

fn main() {
    let order = Order {
        id: 42,
        customer: "An".to_string(),
        total_cents: 150_000,
    };

    let json = serde_json::to_string(&order).expect("序列化 Order");
    println!("{json}");

    let parsed: Order = serde_json::from_str(&json).expect("反序列化 Order");
    println!("{parsed:?}");
}
  • #[derive(Serialize, Deserialize)] 仅当启用 serde 的 derive 特性时才可用。缺少此特性,代码会报错找不到宏。
  • serde_json::to_string 将结构体转换为 JSON 字符串。
  • serde_json::from_str 反向操作。目标类型从 Order 注解推导。

运行 cargo run:

{"id":42,"customer":"An","total_cents":150000}
Order { id: 42, customer: "An", total_cents: 150000 }

3.4. 其他类型的依赖

除了 [dependencies],Cargo 还有两个常用的组:

[dev-dependencies]
pretty_assertions = "1.4"   # 仅在运行测试、示例、基准时使用

[build-dependencies]
cc = "1.0"                  # 仅用于 build.rs

[dev-dependencies] 中的 crate 不会被拉入最终的二进制文件中,也不会影响你的库的用户。请将仅用于测试的库放在这里。

你也可以将依赖指向本地目录或 Git:

[dependencies]
pricing = { path = "../pricing" }
payment-sdk = { git = "https://example.com/payment-sdk.git", tag = "v2.1.0" }

4. 组织工作区

当项目逐渐扩大时,你会希望将其拆分为多个 crate。例如:一个 domain crate 包含业务逻辑,一个 api crate 是运行程序。工作区允许这些 crate 共享 Cargo.lock、共享 target/ 目录和共享依赖版本。

4.1. 创建根工作区

mkdir shop && cd shop

创建 shop/Cargo.toml:

[workspace]
resolver = "3"
members = ["crates/*"]

[workspace.package]
version = "0.1.0"
edition = "2024"

[workspace.dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
  • 此文件没有 [package],所以它是一个虚拟清单。它仅用于聚合子 crate。
  • resolver = "3" 必须在虚拟清单中明确声明。没有 [package] 时,Cargo 无法从 edition 推导 resolver,如果你省略这一行会发出警告。
  • members = ["crates/*"] 聚合 crates/ 目录中的所有 crate。
  • [workspace.package] 和 [workspace.dependencies] 是子 crate 继承的共享值。

创建两个子 crate:

cargo new crates/domain --lib
cargo new crates/api

生成的结构:

shop/
├── Cargo.toml
├── Cargo.lock
├── target/
└── crates/
    ├── domain/
    │   ├── Cargo.toml
    │   └── src/lib.rs
    └── api/
        ├── Cargo.toml
        └── src/main.rs

4.2. 从工作区继承配置

crates/domain/Cargo.toml:

[package]
name = "domain"
version.workspace = true
edition.workspace = true

[dependencies]
serde.workspace = true

crates/api/Cargo.toml:

[package]
name = "api"
version.workspace = true
edition.workspace = true

[dependencies]
domain = { path = "../domain" }
serde_json.workspace = true

serde.workspace = true 意味着此 crate 从 [workspace.dependencies] 获取版本和特性。需要升级 serde 时,你只需修改根文件的一个位置。

子 crate 仍然可以启用额外的特性:

[dependencies]
serde = { workspace = true, features = ["rc"] }

这里的特性被添加到工作区声明的特性中,不是替换它们。

4.3. domain crate 中的代码

crates/domain/src/lib.rs:

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OrderLine {
    pub product: String,
    pub unit_price_cents: u64,
    pub quantity: u32,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Order {
    pub id: u64,
    pub customer: String,
    pub lines: Vec<OrderLine>,
}

impl Order {
    pub fn total_cents(&self) -> u64 {
        self.lines
            .iter()
            .map(|line| line.unit_price_cents * u64::from(line.quantity))
            .sum()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn line(product: &str, price: u64, qty: u32) -> OrderLine {
        OrderLine {
            product: product.to_string(),
            unit_price_cents: price,
            quantity: qty,
        }
    }

    #[test]
    fn total_sums_all_lines() {
        let order = Order {
            id: 1,
            customer: "Binh".to_string(),
            lines: vec![line("Coffee", 35_000, 2), line("Cake", 25_000, 1)],
        };

        assert_eq!(order.total_cents(), 95_000);
    }

    #[test]
    fn empty_order_has_zero_total() {
        let order = Order { id: 2, customer: "Chi".to_string(), lines: vec![] };

        assert_eq!(order.total_cents(), 0);
    }
}
  • struct 和 field 上的 pub 允许 api crate 访问它们。
  • u64::from(line.quantity) 将 u32 转换为 u64 而不丢失数据。
  • #[cfg(test)] 确保测试模块仅在运行 cargo test 时编译。

为 domain crate 单独运行测试:

cargo test -p domain
running 2 tests
test tests::empty_order_has_zero_total ... ok
test tests::total_sums_all_lines ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

在根目录运行 cargo test 会运行工作区中所有 crate 的测试。

4.4. 从 api 使用 domain

crates/api/src/main.rs:

use domain::{Order, OrderLine};

fn main() {
    let order = Order {
        id: 7,
        customer: "Dung".to_string(),
        lines: vec![OrderLine {
            product: "Tea".to_string(),
            unit_price_cents: 20_000,
            quantity: 3,
        }],
    };

    println!("Total: {}", order.total_cents());
    println!("{}", serde_json::to_string_pretty(&order).expect("序列化"));
}

从根目录:

cargo run -p api
Total: 60000
{
  "id": 7,
  "customer": "Dung",
  "lines": [
    {
      "product": "Tea",
      "unit_price_cents": 20000,
      "quantity": 3
    }
  ]
}

api crate 没有声明 serde,但仍然可以序列化 Order。原因是 Serialize trait 已在 domain crate 内部实现。api 只需要 serde_json 来调用转换函数。

4.5. 工作区中的有用命令

cargo build --workspace     # 构建所有 crate
cargo check -p api          # 仅检查 api crate 及其依赖
cargo tree -p api           # 查看依赖树
cargo tree -d               # 列出多个版本的 crate

当二进制文件异常膨胀时,cargo tree -d 非常有用。结果通常显示同一个 crate 的两个版本被拉入。

5. 常见错误

注意: Cargo 中的特性被统一(feature unification)。假设 crate A 启用了 tokio/full 而 crate B 只需要 tokio/rt。在工作区中一起构建时,B 也会获得完整的 full。在工作区中运行的代码可能在单独构建 B 时出错。在 CI 中为每个 crate 运行 cargo check -p <crate-name>。

  • 忘记启用特性。 “cannot find derive macro Serialize” 这样的错误几乎总是由缺少 features = ["derive"] 导致的。
  • 不提交 Cargo.lock。 Cargo 的当前指南建议对二进制和库都提交此文件。这样 CI 中的构建可以在运行之间重现。
  • 发布时的路径依赖。 domain = { path = "../domain" } 如果缺少 version 就无法发布到 crates.io。要发布,需要声明两者:{ path = "../domain", version = "0.1.0" }。
  • 嵌套工作区。 一个 crate 只能属于一个工作区。如果在另一个工作区的子目录内运行 cargo new,Cargo 会报错或自动将 crate 添加到 members。创建后请检查根文件。
  • 滥用工作区。 对于小程序,你只需在一个 crate 内分割模块(mod order;)。拆分 crate 会增加清单数量和配置时间。只有当各部分有明确边界、需要单独构建或测试,或需要重用时才使用工作区。

6. 总结

本文介绍了使用 Cargo 的三个核心任务:

  • 使用 cargo new 创建二进制或库项目,并使用 cargo check 获得快速反馈。
  • 使用 cargo add 添加依赖,理解版本要求和 Cargo.lock 如何共同确定实际版本,以及将 dev-dependencies 与主依赖分离。
  • 将多个 crate 组织到一个工作区中,在 [workspace.dependencies] 中声明共享版本,然后使用 workspace = true 继承。

随着项目增长,你应该遵循一个简单原则:所有依赖版本都在根文件中,每个 crate 只声明它实际使用的内容。这样可以更容易地升级和控制二进制大小。

rustcargodependencyworkspacebuild-tool