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允许apicrate 访问它们。 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 只声明它实际使用的内容。这样可以更容易地升级和控制二进制大小。