课程概览 · 第 18 章

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

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

Cargo 不只是构建命令,而是 Rust 项目的依赖、测试、文档和发布入口。理解 rustup、rustc、cargo 与两级配置文件(Cargo.toml/Cargo.lock)各自管什么,才能让"我这能跑"变成"谁拉下来都能跑"。本章把每个概念的职责和生命周期讲清楚,并给出三个可直接复制的 manifest:单包、workspace、feature 门控。

rustup、rustc 与 Cargo 的职责,Cargo.toml 和 Cargo.lock 的依赖解析,workspace、features 与 CI 质量闭环
图:清单声明允许的选择,锁文件固定已解析的选择;本地和 CI 以同一工具链和命令验证它。

学习目标与默认选择

学完本章你应当能够:

  • 准确说出 rustup、rustc、cargo、Cargo.tomlCargo.locktarget/ 各自管理什么、何时变化;
  • 区分 package、crate、binary、library、feature、workspace 六个粒度;
  • 写出 Edition 2024 单包 manifest、三成员 workspace manifest(resolver = "3")、feature 门控可选依赖 manifest;
  • 判断自己的仓库该不该提交 Cargo.lock
  • 熟练运用 cargo addcargo update -pcargo treecargo checkcargo testcargo fmt --checkcargo clippy --all-targets -- -D warningscargo 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
2
3
4
5
6
7
8
9
10
11
12
13
文件:Cargo.toml(完整内容)

[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]

edition = "2024" 选语言版本(影响 unsafe fn 体内必须显式 unsafe 块、static mut 规则等);rust-version(MSRV)声明最低工具链,cargo build 在更老的编译器上会直接报错而不是产生神秘失败。[dev-dependencies] 只在编译测试/示例时生效,不会进入发布依赖图。

manifest 二:三成员 workspace

1
2
3
4
5
文件:Cargo.toml(workspace 根,完整内容)

[workspace]
resolver = "3"
members = ["crates/api", "crates/domain", "apps/server"]
1
2
3
4
5
6
7
8
文件:crates/domain/Cargo.toml(完整内容)

[package]
name = "domain"
version = "0.1.0"
edition = "2024"

[dependencies]
1
2
3
4
5
6
7
8
9
文件:crates/api/Cargo.toml(完整内容)

[package]
name = "api"
version = "0.1.0"
edition = "2024"

[dependencies]
domain = { path = "../../crates/domain" }
1
2
3
4
5
6
7
8
9
10
文件:apps/server/Cargo.toml(完整内容)

[package]
name = "server"
version = "0.1.0"
edition = "2024"

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

配上最小的 src/lib.rspub fn version() -> &'static str { "domain 0.1.0" } 等)与 apps/server/src/main.rscargo check --workspace 通过。三个要点:

  • resolver = "3":Edition 2024 的特性解析器版本,避免不同 target(host/目标平台)的 feature 意外合并。workspace 显式声明它,成员继承。
  • 一个锁文件:根 Cargo.lock 覆盖全部成员,成员不会各自解析。
  • path 依赖:成员间用相对路径引用;发布时这些 path 依赖需要同时发布或有版本号映射。

manifest 三:feature 门控可选依赖

最小默认 feature 集 + 可选依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
文件:Cargo.toml(完整内容)

[package]
name = "task-cli"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"

[features]
default = []
# 可选依赖自动定义同名 feature;显式列出以声明最小默认集
colored = ["dep:colored"]

[dependencies]
colored = { version = "3", optional = true }
1
2
3
4
5
6
7
8
9
10
11
12
13
文件:src/main.rs(完整内容)

fn main() {
#[cfg(feature = "colored")]
{
use colored::Colorize;
println!("{}", "task-cli".bright_green());
}
#[cfg(not(feature = "colored"))]
{
println!("task-cli");
}
}

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 warningslint 全部目标,警告升级为错误提交前、CI
cargo doc --no-deps只生成本 crate 文档验证文档注释、发布前

最低质量闭环(本地提交前与 CI 一致):fmt --check + clippy --all-targets -- -D warnings + testcheck 是开发时的快速反馈,不能替代 test(它不运行任何断言)。

依赖维护检查清单

引入一个新 crate 前逐项过:

  • MSRV:它的 rust-version 是否高于你的?cargo tree 查看后与你的声明对齐。
  • 许可证:与你的发布许可兼容(MIT/Apache-2.0 双许可最省心)。
  • features:默认 feature 是否拖入重量级依赖?用 default-features = false + 按需启用。
  • build scriptsbuild.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 声明,老编译器上的失败表现为不可理解的编译错误,而不是清晰的"工具链过旧"。

自测

  1. Cargo.tomlCargo.lock 各自记录什么?为什么应用要提交后者?
    答的方向:前者是版本范围声明(允许哪些版本),后者是解析结果(精确版本+校验和);提交锁文件让开发/CI/部署拿到同一依赖图,消除"我这能跑"。
  2. package、crate、binary、library 四个词的关系是什么?一个 package 可以同时有 bin 和 lib 吗?
    答的方向:package 是发布单元,crate 是编译单元;可以有 src/lib.rs + src/main.rs(或多个 src/bin/)共存。
  3. cargo checkcargo test 的本质区别?为什么 CI 不能只跑 check?
    答的方向:check 只做类型检查不执行代码;test 编译并运行断言;逻辑正确性只能由 test 证明。
  4. resolver = "3" 解决什么问题?不写会怎样?
    答的方向:feature 解析按 target 分离,避免成员间 feature 意外合并;不写可能让某成员的可选 feature 影响其他成员的构建。
  5. 引入新依赖前的六项检查是什么?
    答的方向:MSRV、许可证、默认 features、build scripts、proc macros、advisories(+ 维护状态)。