Cargo 与依赖:建立可重复的 Rust 开发闭环
课程概览 · 第 18 章
Cargo 不只是构建命令,而是 Rust 项目的依赖、测试、文档和发布入口。理解 rustup、rustc、cargo 与两级配置文件(Cargo.toml/Cargo.lock)各自管什么,才能让"我这能跑"变成"谁拉下来都能跑"。本章把每个概念的职责和生命周期讲清楚,并给出三个可直接复制的 manifest:单包、workspace、feature 门控。
学习目标与默认选择
学完本章你应当能够:
- 准确说出 rustup、rustc、cargo、
Cargo.toml、Cargo.lock、target/各自管理什么、何时变化; - 区分 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)按应用对待。
六个粒度
- 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.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>定点升级、逐个验证。 - 过度拆分 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 意外合并;不写可能让某成员的可选 feature 影响其他成员的构建。- 引入新依赖前的六项检查是什么?
答的方向:MSRV、许可证、默认 features、build scripts、proc macros、advisories(+ 维护状态)。






