课程概览 · 第 20 章

上一篇:Unsafe:把不安全性封装在最小边界

下一篇:Rust 工程实践:测试、性能与调试的优先级

Cargo 是项目的依赖、构建、测试、文档与发布入口。本章先提供可提前查阅的项目创建与命令速查,再解释工具链、清单、锁文件和构建配置的职责。单包、workspace 和 feature 示例是三个独立项目,不要求读者把课程所有业务模型拼在一起。

工具链职责、依赖范围与锁文件、workspace、features 及构建环境的复现条件
图:锁文件固定依赖选择;工具链、target、feature 和构建环境也必须记录,才能复查构建差异。

前置知识与学习目标

“创建项目与常用命令”可以在第 01 章前查阅;后续内容需要第 14 章的 package/crate 与第 15 章的 cfg。学完本章应能:

  • 创建独立项目,区分 check、run、test 与其他命令提供的证据。
  • 分清工具链、Edition、MSRV、清单范围与锁文件的职责。
  • 读准版本需求,并用 --locked、定点更新管理解析变化。
  • 按真实构建边界组织单包、workspace 和最小 feature 集。

创建项目与常用命令

下面使用显式 +stable,不修改全局默认工具链。stable 是可随 rustup update 更新的渠道,不是锁定具体 rustc 版本;Edition 2024 则是语言兼容规则集。

独立示例(工具命令;在存放新项目的目录执行,创建仅标准库的二进制包;之后的 Cargo 命令在 task_cli 根执行):

1
2
3
4
5
cargo +stable new --bin --edition 2024 task_cli
cd task_cli
cargo +stable check
cargo +stable run
cargo +stable test

new 默认创建 src/main.rs;库项目使用 --lib,入口为 src/lib.rs。check 做编译检查而不执行应用 main,run 执行选定二进制,test 执行选定测试并检查断言。空测试集通过不能证明业务正确;check 也可能执行构建脚本和过程宏,并非“绝不运行任何代码”。

命令(均可加 +stable)用途与边界
cargo check类型与借用检查等,不执行应用 main 或测试断言
cargo run构建并执行二进制;多 bin 时可用 --bin 指定
cargo test执行选定单元/集成测试和支持目标的 doctest
cargo fmt --check需要 rustfmt;检查格式,不改文件
cargo clippy --all-targets -- -D warnings需要 clippy;对选定目标 lint,警告升为错误
cargo doc --no-deps构建本项目文档,不构建依赖文档
cargo add serde修改依赖声明并可能更新解析结果
cargo tree -i serde查看哪些依赖引入了 serde
cargo update -p serde在允许范围内更新指定依赖,相关依赖也可能变化

本地和 CI 应使用相同 feature/target 组合运行格式、lint 与行为测试。命令成功分别提供对应证据,不能概括为全部逻辑正确、安全或跨平台兼容。

工具链与 package/crate

工具或概念职责
rustup安装、更新、选择工具链及 rustfmt/clippy 等组件
rustc编译一个 crate,也可由开发者直接调用
Cargo解析依赖,调度 rustc,编排测试与构建缓存
package一个 Cargo.toml 描述的发布/构建单元
crate一次编译单元,如一个 library 或 binary target
workspace多个 package 的共同工作区,共享 lock 和 target 等

一个 package 可以同时有 src/lib.rs 与 src/main.rs,并通过 src/bin 增加多个二进制 target;这不是同一个 crate 有多个入口。library 是供其他 crate 调用的接口,binary 是带 main 的可执行目标。feature 是编译期能力开关,不是运行期配置。

rustup default stable 会改变全局默认选择,本课程不需要它。cargo +stable 显式选择已安装稳定工具链,而更新 stable 会改变其版本。需要精确复现时可在项目记录具体工具链版本,但还须记录目标平台、配置与依赖。

代码片段(工具链配置文件 rust-toolchain.toml;用于项目根,不是下文项目的必需文件;数字为本次课程验证使用版本,组件须已安装或由 rustup 获取):

1
2
3
[toolchain]
channel = "1.99.0"
components = ["rustfmt", "clippy"]

manifest 范围与 lockfile

Cargo.toml 声明包元数据、依赖版本范围、features 和 targets;Cargo.lock 记录实际解析的版本、来源和适用的校验信息;target 目录是产物与缓存,通常不提交。

binary 和 library 仓库默认都提交 Cargo.lock,供本项目开发和 CI 使用。下游依赖库时不采用该库仓库的 lock,而由下游自己的解析图和锁文件决定版本。提交库锁文件并不约束使用它的应用。参见 Cargo.toml 与 Cargo.lock。

已有且仍满足当前清单的 lock 会尽量保留版本,但清单变化、缺失锁文件、成员元数据变化或显式 update 等都可能要求重新解析、更新 lock。--locked 要求锁文件存在且不需更新,否则报错;它不会替你补齐或修复锁文件。

锁文件不是字节级可复现构建保证。工具链、target、环境变量、构建脚本、系统链接库与 feature 组合仍可能改变产物;在线获取依赖与真正离线构建也不是同一件事。

版本约束与更新

裸版本号默认是 caret 需求,serde = "1.2.3" 不是精确版本。Cargo 按约束求解,并尽量沿用锁定结果,不保证每次构建都选择最新版本。

写法允许区间或含义
1 或 ^1>=1.0.0, <2.0.0
1.2 或 ^1.2>=1.2.0, <2.0.0
1.2.3 或 ^1.2.3>=1.2.3, <2.0.0
~1.2.3>=1.2.3, <1.3.0
~1.2>=1.2.0, <1.3.0
1.2.*>=1.2.0, <1.3.0
*任意普通版本;维护项目通常避免无上界声明
=1.2.3精确版本 1.2.3
>=1.2, <1.5两个条件同时满足(AND)
^0.3>=0.3.0, <0.4.0
^0.0.3>=0.0.3, <0.0.4

Cargo 不支持用多区间 OR 串拼接版本需求。0.x 的 caret 上界是 Cargo 的兼容规则:按最左非零分量确定上界;SemVer 只说明 0.x API 尚不稳定,并未把所有 minor 定义成稳定的大版本。作者仍可能在允许范围内引入不兼容行为,因此更新后需要测试。官方规则见 版本需求语法。

普通需求默认不匹配预发布版本。显式包含某一 major.minor.patch 的预发布号会允许该核心版本的合适预发布版本;同一需求仍可能匹配其兼容范围内的稳定版本。不要把允许 beta 解释为只允许 beta,或认为一个预发布声明会开放全部未来核心版本的预发布。

代码片段(依赖维护命令;在已经声明 serde 的项目根执行,版本仅用于展示语法,须满足实际清单约束):

1
2
3
cargo +stable update -p serde
cargo +stable update -p serde --precise 1.0.210
cargo +stable test --locked

=1.2.3 在声明层限定版本,lock 在解析层记录选择,两者用途不同。update -p 可能连带调整依赖关系,不应把它理解成只可能修改锁文件的一行。更改清单后应审阅 lock 差异及相关行为测试,而不是把所有非 update 引发的变化一概判成异常。

MSRV 与 resolver

rust-version 声明项目的最低支持 Rust 版本(MSRV),需要在该版本上验证代码和依赖才可信;Edition 不是 rustc 版本。Edition 2024 的语言最低版本是 1.85,但使用更新 API 的项目可以有更高 MSRV。2024 的 unsafe_op_in_unsafe_fn 默认是 warning,显式 unsafe 块是本课程规范,不是 Edition 默认硬错误,详见第 19 章。

resolver 2 已引入部分 host/target、开发依赖等 feature 分离;resolver 3 主要增加 MSRV 感知解析的默认 fallback。解析优先考虑包声明的 rust-version,未声明时还可能参考当前工具链;没有合适版本时仍可能选择不兼容依赖,因此不保证自动满足所有下游 MSRV。共享同一依赖的已启用 features 仍可能合并,不能说 resolver 3 消除了全部 feature 合并。参见 resolver 与 rust-version。

workspace 版本与 Edition 可在根声明,成员按需继承,但各成员仍是独立发布单元,不是自动联动发布。

代码片段(标准 Cargo 语法;根 Cargo.toml 的追加节,不是完整文件;依赖下文 workspace 根定义):

1
2
3
4
[workspace.package]
version = "0.1.0"
edition = "2024"
rust-version = "1.85"

代码片段(crates/domain/Cargo.toml 的替换 package 节,沿用下文 workspace 的根与源码;若要使用,先追加上一块根节):

1
2
3
4
5
[package]
name = "domain"
version.workspace = true
edition.workspace = true
rust-version.workspace = true

三个独立项目:单包、workspace、features

标准库单包

多文件示例(项目目录 single_package;仅标准库;将两个完整文件放到对应路径,在根执行 cargo +stable run):

文件 single_package/Cargo.toml:

1
2
3
4
5
6
7
8
9
10
11
[package]
name = "task-cli"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
description = "任务队列 CLI 教学项目"
license = "MIT OR Apache-2.0"

[dependencies]

[dev-dependencies]

多文件示例,文件 single_package/src/main.rs(依赖上块 manifest,仅标准库):

1
2
3
4
5
fn main() {
let title = "task-cli";
assert_eq!(title.len(), 8);
println!("{title}");
}

dev-dependencies 用于测试、示例和基准等开发目标,不作为普通库依赖提供给下游;bench 也是开发目标,不能只说它对测试生效。

三成员 workspace

以下文件共同组成一个独立项目,不省略所谓“配上源码即可”的部分。根是虚拟 workspace,没有自己的 package。成员间的 path 相对各自 manifest 所在目录解析;发布 crates.io 库时还需要合适的版本要求及发布顺序,单独 path 不是公开 registry 的版本协议。

文件布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
workspace_example/
Cargo.toml
crates/
domain/
Cargo.toml
src/lib.rs
api/
Cargo.toml
src/lib.rs
apps/
server/
Cargo.toml
src/main.rs

多文件示例,workspace_example/Cargo.toml(完整根清单;无第三方依赖;所有命令在 workspace_example 根执行):

1
2
3
[workspace]
resolver = "3"
members = ["crates/api", "crates/domain", "apps/server"]

多文件示例,workspace_example/crates/domain/Cargo.toml(完整):

1
2
3
4
5
6
[package]
name = "domain"
version = "0.1.0"
edition = "2024"

[dependencies]

多文件示例,workspace_example/crates/domain/src/lib.rs(完整,依赖 domain 清单):

1
2
3
pub fn version() -> &'static str {
"domain 0.1.0"
}

多文件示例,workspace_example/crates/api/Cargo.toml(完整,path 指向 domain):

1
2
3
4
5
6
7
[package]
name = "api"
version = "0.1.0"
edition = "2024"

[dependencies]
domain = { path = "../../crates/domain" }

多文件示例,workspace_example/crates/api/src/lib.rs(完整,依赖 api 清单及 domain):

1
2
3
pub fn domain_version() -> &'static str {
domain::version()
}

多文件示例,workspace_example/apps/server/Cargo.toml(完整,path 指向两个库成员):

1
2
3
4
5
6
7
8
[package]
name = "server"
version = "0.1.0"
edition = "2024"

[dependencies]
api = { path = "../../crates/api" }
domain = { path = "../../crates/domain" }

多文件示例,workspace_example/apps/server/src/main.rs(完整,依赖 server 清单及两个库):

1
2
3
4
fn main() {
println!("{}", api::domain_version());
assert_eq!(api::domain_version(), domain::version());
}

多文件示例(工具命令;执行目录 workspace_example,依赖以上七个文件):

1
2
cargo +stable check --workspace
cargo +stable run -p server

预期输出(server 的程序输出;不包括 Cargo 构建日志):

1
domain 0.1.0

workspace 根 Cargo.lock 覆盖成员,共享 target 缓存,不表示自动共享所有依赖声明或发布策略。若成员出现循环依赖,Cargo 会拒绝,应修正层次而不是隐藏依赖。

故意失败:编译错误(Cargo 构建前的锁文件一致性错误,不是 Rust 类型错误;仅在上述项目的临时副本中演示):先通过普通 check 生成 lock,再仅把 crates/domain/Cargo.toml 的 package.version 改为 0.1.1,不改 version() 的示例字符串。执行目录仍为 workspace_example:

1
cargo +stable check --workspace --locked

代码片段(恢复命令;沿用上块临时项目与变化,普通 check 可更新根 lock):

1
cargo +stable check --workspace

更新 package 元数据需要更新锁文件;version() 是普通函数,字符串不会自动随 package.version 改变。这里的错误类别是需要更新 lock,具体报错措辞随 Cargo 版本变化。

feature 门控可选依赖

多文件示例(独立 feature_example;唯一第三方依赖为可选 colored 3;在该目录根执行命令):文件 feature_example/Cargo.toml(完整):

1
2
3
4
5
6
7
8
9
10
11
[package]
name = "task-cli"
version = "0.1.0"
edition = "2024"

[features]
default = []
colored = ["dep:colored"]

[dependencies]
colored = { version = "3", optional = true }

多文件示例,文件 feature_example/src/main.rs(完整,依赖上块 manifest):

1
2
3
4
5
6
7
8
9
10
11
fn main() {
#[cfg(feature = "colored")]
{
use colored::Colorize;
println!("{}", "task-cli".bright_green());
}
#[cfg(not(feature = "colored"))]
{
println!("task-cli");
}
}

多文件示例(工具命令;执行目录 feature_example,依赖上面的两个文件):

1
2
cargo +stable run
cargo +stable run --features colored

两条路径的文本内容都是 task-cli,是否显示 ANSI 颜色取决于终端、环境与库策略,不能承诺任何输出目标必然显绿。optional 依赖默认不启用,dep:colored 显式关联 feature 与依赖并避免额外隐式依赖 feature;这里因显式声明了同名 colored feature,用户仍以该名字启用。features 应按能力设计且通常可叠加;运行时可变配置走参数、环境变量或文件。

config 发现与依赖维护

.cargo/config.toml 配置 Cargo 的别名、rustflags、linker/runner、网络、registry 等行为,与描述 package 的 Cargo.toml 不同。它还可以影响 source、patch、resolver 等设置,不能概括为“绝不影响解析或锁文件”。

Cargo 从执行时的当前目录逐级向上查找配置,再读 CARGO_HOME(默认 ~/.cargo)的配置。近处配置优先,多处数组一般拼接;支持的环境变量和命令行 --config 还会覆盖相应配置。workspace 根调用不会自动读取成员子目录中的配置;从根使用 --manifest-path 指向成员也不能改变这个发现起点。

代码片段(团队配置 workspace_example/.cargo/config.toml;可选,不是 workspace 运行的必要文件;完整示意内容,适用于安装 clippy 的环境):

1
2
3
4
5
6
7
8
9
[alias]
ck = "check --workspace"
lint = "clippy --all-targets -- -D warnings"

[build]
rustflags = ["-W", "unused"]

[net]
git-fetch-with-cli = true

代码片段(个人 ~/.cargo/config.toml,不提交;选择镜像的配置语法,使用前自行确认信任及可达性,不是课程运行前置):

1
2
3
4
5
[source.crates-io]
replace-with = "rsproxy"

[source.rsproxy]
registry = "sparse+https://rsproxy.cn/index/"

团队配置只提交跨机器成立的内容;代理、个人镜像和本机绝对路径留在个人环境。CI 建议写完整命令以便审查,但 Cargo alias 并非在 CI 技术上不能使用,前提是存在同一配置。

引入依赖前查看 MSRV、许可证、默认 features、build.rs、过程宏、已知 advisories 与维护状态。构建脚本和过程宏在构建时执行代码,需按供应链风险审查;cargo audit/cargo deny 是另行安装的工具,扫描已知问题不代表没有未知漏洞。新增或升级依赖后审阅清单与 lock 差异,再运行相关测试;不要把 Cargo 的解析规则当作依赖作者必然遵守兼容性的保证。

深入阅读:构建与发布边界

固定工具链、lock、目标平台、features 和环境能减少差异,但仍需检查本机库、链接器、build scripts 与网络输入。--locked 限制解析变化,离线构建还需依赖缓存或 vendoring;二者不可互换。源清单的精确 = 需求也不锁定工具链或环境。

workspace 只在独立发布、构建目标或依赖边界真实存在时引入,不要把每个模块都拆成 crate。多个成员共享解析会增加版本协调成本,公共依赖仍可能统一 feature。更多模块边界见第 14 章,属性和条件编译见第 15 章,基准与实际测量见第 21 章。

自测

  1. check、run、test 各自能证明什么?空测试集通过有什么局限?
  2. 库仓库为何默认提交 lock,下游又为何不采用它?锁文件能保证字节级复现吗?
  3. 1.2.3、=1.2.3、^0.3 与逗号条件分别是什么意思?
  4. resolver 3 对 MSRV 做了什么,为什么不能保证所有下游自动适配?
  5. workspace 根调用为什么可能读不到成员配置?colored feature 为什么不保证任何终端显色?

参考答案

  1. check 编译检查但不执行应用断言;run 执行 main;test 执行选定测试。没有相应测试就没有对应行为证据。
  2. lock 固定本仓库开发/CI 的解析结果;下游用自己的依赖图。工具链、target、环境和构建脚本仍影响产物。
  3. 裸版本为 caret,>=1.2.3 且 <2.0.0;等号为精确版本;^0.3 上限 <0.4.0;逗号为 AND,不支持 OR 拼接。
  4. 默认优先 fallback 到兼容 rust-version 的依赖;无合适版本仍可能选不兼容版本,也无法保证代码与依赖真实支持声明的 MSRV。
  5. 配置从当前目录向上发现而非向成员向下查找;colored 的输出策略还取决于终端和环境,启用的是能力分支。