Cargo 与依赖:建立可重复的 Rust 开发闭环
课程概览 · 第 20 章
Cargo 不只是构建命令,而是 Rust 项目的依赖、测试、文档和发布入口。理解 rustup、rustc、cargo 与三份配置文件(Cargo.toml/Cargo.lock/.cargo/config.toml)各自管什么,才能让"我这能跑"变成"谁拉下来都能跑"。本章把每个概念的职责和生命周期讲清楚,并给出三个可直接复制的 manifest 与两份 .cargo/config.toml 示例:单包、workspace、feature 门控。
学习目标与默认选择
学完本章你应当能够:
- 准确说出 rustup、rustc、cargo、
Cargo.toml、Cargo.lock、.cargo/config.toml、target/各自管理什么、何时变化; - 读准并写出版本需求(caret/tilde/0.x 语义、pre-release 规则),并用
--precise、--locked、workspace 版本继承控制升级时机; - 区分 package、crate、binary、library、feature、workspace 六个粒度;
- 写出 Edition 2024 单包 manifest、三成员 workspace manifest(
resolver = "3")、feature 门控可选依赖 manifest; - 判断自己的仓库该不该提交
Cargo.lock; - 熟练运用
cargo add、cargo update -p、cargo tree、cargo check、cargo test、cargo fmt --check、cargo clippy --all-targets -- -D warnings、cargo doc --no-deps。
默认选择:单包起步;成员间出现真实的构建/发布边界差异时才升级成 workspace。feature 默认集保持最小(空集优先);可选依赖用 dep: 显式挂接。应用提交 Cargo.lock,这是行业共识。
概念讲解:每个概念的职责与生命周期
工具链三层:rustup -> rustc -> cargo
| 概念 | 管什么 | 何时变化 |
|---|---|---|
| rustup | 工具链版本(stable/nightly/特定版本)、组件(rustfmt/clippy/miri) | rustup update、切换工具链时 |
| rustc | 单个 crate 的编译:类型检查、借用检查、代码生成 | 每次编译被 cargo 调用,你不直接碰它 |
| cargo | 项目级编排:解析依赖、调 rustc、跑测试、管缓存 | 每次 cargo <命令> |
rustup default stable 设定默认工具链;cargo +stable / cargo +nightly 显式选择。本课程统一 +stable + Edition 2024。
配置两层:约束与解析结果
| 文件 | 内容 | 谁写它 | 是否提交 |
|---|---|---|---|
Cargo.toml | 你声明的依赖版本范围、features、元数据 | 你手写 / cargo add | 总是提交 |
Cargo.lock | 解析后实际选中的精确版本 + 校验和 | cargo 自动生成 | 应用提交;库不提交 |
target/ | 构建产物与增量缓存 | cargo 自动管理 | 不提交(gitignore) |
Cargo.toml 说"我要 serde 1.x";Cargo.lock 记录"本次解析选了 1.0.219"。锁文件是可重复构建的来源:CI 和部署拉到的依赖图与开发机完全一致。只有 cargo update 才会在范围内改写它。
提交规则:应用(bin、服务、最终产物)提交锁文件;发布的库不提交(库被应用依赖时,应用的锁文件说了算),但要保证 MSRV 声明真实。工具型 crate(用 CI 发布 binary)按应用对待。
Cargo.toml 的版本管理:需求记法与升级时机
serde = "1" 中的 "1" 不是"精确等于 1.0.0",而是一条版本需求(version requirement)–一个允许的版本区间。解析时 cargo 从区间里挑一个满足全部约束的版本写进 Cargo.lock。区间宽窄由记法决定:
| 写法 | 等价区间 | 允许的更新 |
|---|---|---|
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 | minor 内任意 |
1.2.* / * | 通配符 | * 会让每次解析都可能漂移,别用 |
=1.2.3 | 精确 1.2.3 | 在 manifest 层锁死(比锁文件还硬) |
>=1.2, <1.5 | 显式区间 | 合法但少见 |
1.0 || 2.0 | 多区间 OR | 移植期过渡用 |
裸写版本号默认就是 caret 需求(^ 可省略)。真正的"精确"有两层:=1.2.3 写在 manifest(声明层,连 cargo update 都不会动);Cargo.lock 记在解析层(cargo update -p serde 就会改)。选择在哪层锁,就是选择谁有权变动版本。
0.x 陷阱:SemVer 把 0.x 的 minor 当作"不稳定的大版本",caret 对 0.x 自动收紧:
| 写法 | 实际区间 | 含义 |
|---|---|---|
^0.3 | >=0.3.0, <0.4.0 | 0.4 被视为破坏性,不会自动升 |
^0.0.3 | >=0.0.3, <0.0.4 | 0.0.x 连补丁号都当破坏性 |
所以 foo = "0.3" 在 0.4 发布时不会自动跟上–必须手动改需求字符串。这是 crate 作者刻意设计的信号,不是 cargo 的 bug。
pre-release 永不意外匹配:"1.1" 这样的普通需求永远不会选中 1.1.0-beta.1。预发布版本只有当需求里显式写了同一 major.minor.patch 的预发布号(如 "1.1.0-beta" 可匹配 1.1.0-beta.3)才会进入解析。想要 beta 就得明说,防止不稳定版本被顺手带进来。
需求与锁文件的分工(回答"版本什么时候会变"):
cargo add serde写入最小 caret 需求(如serde = "1"),并把当时最新满足版本写进锁文件;- 日常
cargo build/check/test严格按锁文件安装,需求只是"续期资格"; - 重新解析只发生在两种时刻:改了需求字符串,或显式
cargo update;cargo update -p serde只动这一个包,加--precise 1.0.210可指定版本; - CI 用
cargo test --locked强制执行:锁文件缺失或过期直接报错而不是悄悄重解析–"锁文件说了算"要有这个 flag 才有牙齿。
MSRV 感知解析:resolver = "3"(Edition 2024 的默认)在 feature 解析规则之外还改了一件事–默认把 incompatible-rust-versions 从 allow 切到 fallback:解析时优先选 rust-version 不超过你工具链的版本,实在找不到才用更新的。老 resolver 会直接选最新版,然后在旧编译器上编译失败。配对做法:manifest 里声明 rust-version = "1.85",下游同样开 resolver 3,依赖图就自动适配双方的工具链底线。
workspace 版本继承:版本号是元数据,可以在根统一声明、成员继承,避免出现 domain 0.1.0 依赖 api 0.1.1 的漂移:
1 | |
1 | |
发布前只 bump 根一处,全体成员同步。注意这是声明继承,不是联动发布–成员仍是独立的 package,各自有各自的版本发布历史。
第三份配置:.cargo/config.toml 控制 Cargo 自身
前两份文件管"项目长什么样";.cargo/config.toml 管"Cargo 这个工具怎么干活":命令别名、追加给 rustc 的 flags、交叉编译的 linker/runner、网络与 registry 镜像、注入给子进程的环境变量。它不参与依赖解析,改它不会触碰 Cargo.lock。
发现与合并规则:cargo 从当前目录向上逐级查找 .cargo/config.toml,最后读 $CARGO_HOME/config.toml(默认 ~/.cargo/)。多份同时存在时按键合并,离当前目录越近优先级越高(家目录最低),数组则拼接。环境变量(如 CARGO_BUILD_JOBS=4)覆盖所有 TOML 文件,命令行 cargo --config KEY=VALUE 又覆盖环境变量。一个例外:在 workspace 根调用 cargo 时,不会读取成员目录里的 .cargo/config.toml。
| 位置 | 典型内容 | 是否提交 |
|---|---|---|
<repo>/.cargo/config.toml | 团队共享的 alias、[build]/[target] flags、runner | 提交(前提是换机器仍成立) |
~/.cargo/config.toml | 个人环境:镜像源、代理、默认 registry | 不提交 |
1 | |
1 | |
判别标准:放进去的东西换一台机器、换一个人还成立,就提交进仓库;依赖本机环境(镜像、代理、绝对路径)就留在 ~/.cargo。另注意 alias 只对本地敲命令的人生效,CI 与脚本必须写完整命令,不能依赖它。
六个粒度
- package:一个
Cargo.toml描述的发布/构建单元,有名字和版本。 - crate:一次编译单元。一个 package 通常含一个 lib crate(
src/lib.rs)或一个 bin crate(src/main.rs),也可含多个 bin(src/bin/*.rs)。 - binary:编译产物是可执行文件(有
main)。cargo run跑的就是它。 - library:编译产物供别人
use(无main)。cargo test对它跑单元测试。 - feature:编译期开关,控制可选代码与可选依赖。
#[cfg(feature = "x")]。 - workspace:多个 package 共享一个
Cargo.lock和一个target/,成员间可用 path 依赖。
生命周期示例:cargo new 创建 package -> cargo add 写入 Cargo.toml 并解析 -> cargo check 增量编译到 target/ -> cargo test 跑全部测试目标 -> 发布库时 cargo publish 读 manifest。
manifest 一:Edition 2024 单包
1 | |
edition = "2024" 选语言版本(影响 unsafe fn 体内必须显式 unsafe 块、static mut 规则等);rust-version(MSRV)声明最低工具链,cargo build 在更老的编译器上会直接报错而不是产生神秘失败。[dev-dependencies] 只在编译测试/示例时生效,不会进入发布依赖图。
manifest 二:三成员 workspace
1 | |
1 | |
1 | |
1 | |
配上最小的 src/lib.rs(pub fn version() -> &'static str { "domain 0.1.0" } 等)与 apps/server/src/main.rs,cargo check --workspace 通过。三个要点:
resolver = "3":Edition 2024 的特性解析器版本,避免不同 target(host/目标平台)的 feature 意外合并。workspace 显式声明它,成员继承。- 一个锁文件:根
Cargo.lock覆盖全部成员,成员不会各自解析。 - path 依赖:成员间用相对路径引用;发布时这些 path 依赖需要同时发布或有版本号映射。
manifest 三:feature 门控可选依赖
最小默认 feature 集 + 可选依赖:
1 | |
1 | |
optional = true 让依赖默认不编译;dep:colored 语法把 feature 与依赖显式挂接(而不是让 feature 名自动启用依赖)。default = [] 声明默认什么都不带。验证:cargo run 输出素色,cargo run --features colored 输出绿色。feature 是能力开关,不是配置系统–运行时配置用环境变量/配置文件,别塞进 feature。
日常命令的差异
| 命令 | 做什么 | 什么时候用 |
|---|---|---|
cargo add serde | 写入 Cargo.toml 并重新解析锁文件 | 引新依赖 |
cargo update -p serde | 只更新指定包(在约束范围内) | 定点升级 |
cargo tree | 打印依赖树(-i serde 反向查谁依赖它) | 查重复/意外引入 |
cargo check | 类型检查,不生成二进制 | 开发循环主力(最快反馈) |
cargo test | 编译并运行全部测试目标(单测/集成/doctest) | 提交前、CI |
cargo fmt --check | 检查格式,不修改 | 提交前、CI |
cargo clippy --all-targets -- -D warnings | lint 全部目标,警告升级为错误 | 提交前、CI |
cargo doc --no-deps | 只生成本 crate 文档 | 验证文档注释、发布前 |
最低质量闭环(本地提交前与 CI 一致):fmt --check + clippy --all-targets -- -D warnings + test。check 是开发时的快速反馈,不能替代 test(它不运行任何断言)。
依赖维护检查清单
引入一个新 crate 前逐项过:
- MSRV:它的
rust-version是否高于你的?cargo tree查看后与你的声明对齐。 - 许可证:与你的发布许可兼容(MIT/Apache-2.0 双许可最省心)。
- features:默认 feature 是否拖入重量级依赖?用
default-features = false+ 按需启用。 - build scripts(
build.rs):构建时执行任意代码,供应链风险点,审查它做什么。 - proc macros:编译期执行代码,同上审查。
- advisories:CI 跑
cargo audit(或cargo deny check advisories)扫描已知漏洞。 - 维护状态:issue 响应、最近发布、是否有维护者声明。
边界与失败场景
- 锁文件缺失/过期的症状:不同机器构建结果不一致、依赖行为漂移。诊断:比对
Cargo.lock的 git diff,任何非cargo update产生的锁文件变化都值得警惕。 - workspace 成员循环依赖:cargo 直接拒绝;按层次拆分(domain 不依赖 api)。
- feature 意外合并:resolver 版本 1 下,成员 A 启用
serde/derive、成员 B 不启用,可能全 workspace 生效;resolver = "3"消除这类意外。 cargo add后没跑测试:新依赖的 feature 可能改变现有代码路径;add之后立刻cargo check --workspace。target/被提交:仓库膨胀且跨机器不可用;.gitignore必须含target/。- 改了
.cargo/config.toml却"没生效":要么写在了 workspace 成员目录(从根调用时不读取),要么被更近一层目录或环境变量覆盖;把配置移到 workspace 根再排查覆盖来源。
为什么可行:声明式约束 + 锁定的解析结果
Cargo.toml 只声明允许的范围(语义化版本约束),从不锁定;锁定发生在解析层(Cargo.lock)。两层分离让"升级"成为显式动作(cargo update),而"日常构建"完全确定。这套模型成立的前提是语义化版本被依赖方诚实遵守,所以依赖维护清单里 MSRV/许可证/advisories 检查不是形式主义–它们是范围声明可信度的事后审计。workspace 把 N 个 package 的解析合并为一个锁文件,代价是任何成员的依赖变化都会 touch 同一个锁文件;这也是为什么 workspace 应按真实边界(库/应用/工具)划分,而不是"每个模块一个 crate"。
常见误区
- 库提交
Cargo.lock并相信它能约束下游:下游应用的锁文件才是最终裁判;库提交锁文件只影响自己的 CI。 - 把 feature 当配置系统:feature 在编译期定死,运行时无法切换;运行时可变的配置走配置文件。
cargo check通过 = 安全:check不运行测试、不运行 clippy 全部 lint;闭环是 fmt + clippy + test。- 无脑
cargo update:全量更新可能一次引入多个 breaking 边缘行为;用cargo update -p <crate>定点升级、逐个验证。 - 把
"1.2.3"读成精确版本:裸版本号是 caret 区间(>=1.2.3, <2.0.0);要精确就写=1.2.3或交给锁文件。另外 0.x 的 caret 会收紧(^0.3上限是 0.4.0 之前),0.4 不会自动到来。 - 过度拆分 workspace:编译时间不是拆 crate 的理由,边界清晰才是;每多一个成员就多一份版本协调成本。
- 忘记
rust-version:没有 MSRV 声明,老编译器上的失败表现为不可理解的编译错误,而不是清晰的"工具链过旧"。
自测
Cargo.toml和Cargo.lock各自记录什么?为什么应用要提交后者?
答的方向:前者是版本范围声明(允许哪些版本),后者是解析结果(精确版本+校验和);提交锁文件让开发/CI/部署拿到同一依赖图,消除"我这能跑"。- package、crate、binary、library 四个词的关系是什么?一个 package 可以同时有 bin 和 lib 吗?
答的方向:package 是发布单元,crate 是编译单元;可以有src/lib.rs+src/main.rs(或多个src/bin/)共存。 cargo check和cargo test的本质区别?为什么 CI 不能只跑 check?
答的方向:check 只做类型检查不执行代码;test 编译并运行断言;逻辑正确性只能由 test 证明。resolver = "3"解决什么问题?不写会怎样?
答的方向:feature 解析按 target 分离,避免成员间 feature 意外合并;同时默认启用 MSRV 感知解析(优先选rust-version兼容当前工具链的依赖版本)。不写(旧 resolver)可能让某成员的可选 feature 影响其他成员的构建,且会把老编译器上编不过的新版本选进来。- 引入新依赖前的六项检查是什么?
答的方向:MSRV、许可证、默认 features、build scripts、proc macros、advisories(+ 维护状态)。 .cargo/config.toml和Cargo.toml的分工是什么?仓库里那份适合放什么?
答的方向:前者配置 cargo 自身行为(alias、rustflags、linker、镜像、env),后者声明项目依赖与元数据;提交的只放跨机器成立的团队共享项,镜像/代理等个人配置放~/.cargo/config.toml。serde = "1.2.3"允许哪些版本?foo = "0.3"会自动升到 0.4 吗?CI 上怎么强制锁文件生效?
答的方向:caret 区间 >=1.2.3, <2.0.0,不是精确;0.x 的 minor 被视为破坏性,^0.3只允许 0.3.x,跨 0.4 要手动改需求;cargo test --locked在锁文件缺失/过期时报错。






