第12章:Cargo 工程化 —— 依赖、工作空间、构建、测试与发布
约 13 分钟 · 更新于 2026-09-01
第12章:Cargo 工程化 —— 依赖、工作空间、构建、测试与发布
一个 Rust 项目能在开发机上运行,只说明源代码通过了一次构建。可持续交付还需要稳定的包边界、可复现的依赖图、受控的 feature 组合、明确的测试层级、可诊断的产物和经过审批的发布流程。本章以“事件采集平台”Workspace 为主线,把 Cargo 的清单、构建、测试、CI 和发布能力组织成一条工程闭环。
本章目标
- 区分 Package、Crate、Target 与 Workspace。
- 设计单向依赖图,并在根清单统一版本、edition、依赖和 lint。
- 理解 Cargo.toml、Cargo.lock、registry、git、path 与 [patch] 的职责。
- 将 features 设计为可组合能力,并在 CI 中验证关键组合。
- 使用 Profile、build.rs 和缓存策略平衡开发效率与发布质量。
- 建立单元、集成、文档、编译失败、属性和基准测试的分层体系。
- 在发布前检查包内容、SemVer、凭据、许可证和依赖顺序。
一、工程结构先于命令清单
本章示例工作空间:
text
telemetry-platform/
├── Cargo.toml
├── Cargo.lock
├── rust-toolchain.toml
├── crates/
│ ├── model/
│ ├── ingest/
│ ├── storage/
│ └── service/
├── tests/
└── .github/workflows/ci.yml
建议依赖方向:
text
service -> ingest -> model
\-> storage -> model
model 只定义领域数据和规则,不依赖网络运行时或数据库驱动;ingest 和 storage 负责基础设施适配;service 组装应用并安装 tracing subscriber。
这种边界带来的价值包括:
- 核心类型可快速编译和独立测试;
- 基础设施可以替换;
- feature 作用范围更清晰;
- 发布单元和 SemVer 影响更容易判断;
- CI 可以按包定位失败。
二、Cargo 的四个基本概念
2.1 Package
Package 由一个 Cargo.toml 描述,是依赖解析和发布的基本单元。一个 Package 可以包含多个构建目标。
2.2 Crate
Crate 是一次 Rust 编译的单元,主要分为 library crate 和 binary crate。包名可以带连字符:
toml
[package]
name = "telemetry-model"
代码中的 crate 标识符使用下划线:
rust
use telemetry_model::Event;
2.3 Target
一个 Package 可以声明或自动发现以下 Target:
- library:src/lib.rs;
- binary:src/main.rs、src/bin/*.rs;
- example:examples/*.rs;
- integration test:tests/*.rs;
- benchmark:benches/*.rs;
- proc-macro crate。
Target 决定 Cargo 要构建什么,Package 决定这些目标属于哪个清单和发布单元。
2.4 Workspace
Workspace 将多个 Package 放入同一工程上下文,可共享:
- 根级命令入口;
- 一份 Cargo.lock;
- 一个 target/;
- 可继承的元数据、依赖和 lint;
- 一致的依赖解析策略。
Workspace 不会消除 crate 边界,也不会自动阻止循环依赖。依赖方向仍需由设计和清单共同维护。
三、创建虚拟 Workspace
3.1 根清单
toml
[workspace]
resolver = "2"
members = [
"crates/model",
"crates/ingest",
"crates/storage",
"crates/service",
]
default-members = ["crates/service"]
[workspace.package]
version = "0.1.0"
edition = "2021"
rust-version = "1.78"
repository = "https://example.invalid/telemetry-platform"
[workspace.dependencies]
anyhow = "1"
async-trait = "0.1"
serde = { version = "1", features = ["derive"] }
thiserror = "2"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
[workspace.lints.rust]
unsafe_code = "deny"
[workspace.lints.clippy]
all = "warn"
只有 [workspace] 而没有 [package] 的根清单称为虚拟清单。根目录本身不生成 crate,适合纯多包仓库。
3.2 成员继承
toml
[package]
name = "telemetry-model"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
repository.workspace = true
[dependencies]
serde.workspace = true
thiserror.workspace = true
[lints]
workspace = true
继承可以减少版本漂移,但不意味着每个成员必须使用所有共享依赖。根清单声明可复用版本,成员仍按实际需要选择。
3.3 依赖方向是可执行架构
model 若反向依赖 service,通常意味着领域层知道了应用装配细节;storage 与 ingest 互相依赖则可能形成循环。
应通过以下方式保持方向:
- 将共享 trait 放入更内层的 crate;
- 由外层实现适配器并注入;
- 通过事件或接口解耦基础设施;
- 使用 cargo tree 和架构测试持续检查。
四、成员清单与依赖类别
4.1 本地依赖
toml
[package]
name = "telemetry-storage"
version.workspace = true
edition.workspace = true
[dependencies]
async-trait.workspace = true
telemetry-model = { path = "../model" }
thiserror.workspace = true
如果该包将发布到 registry,本地依赖通常还需版本约束:
toml
telemetry-model = { path = "../model", version = "0.1.0" }
本地构建优先使用 path,发布后的依赖解析使用 version。
4.2 三种依赖分区
toml
[dependencies]
serde.workspace = true
[dev-dependencies]
proptest = "1"
[build-dependencies]
prost-build = "0.13"
- [dependencies]:正常构建需要;
- [dev-dependencies]:测试、示例和基准需要;
- [build-dependencies]:只供 build.rs 使用。
将测试工具或代码生成器放进普通依赖会扩大下游依赖图、编译时间和供应链面。
4.3 可选依赖不等于运行时配置
可选依赖适合编译期能力,例如启用某种数据库驱动。部署环境、连接地址、日志级别和租户配置应在运行时读取,而不是通过 feature 区分。
五、Cargo.toml 与 Cargo.lock
5.1 清单描述可接受范围
默认使用 caret 版本要求,允许 SemVer 兼容更新。其他写法包括:
toml
foo = "~1.2.3"
foo = ">=1.2, <2"
foo = "=1.2.3"
精确固定版本会限制依赖解析,只有明确需要时才使用。
5.2 Lockfile 记录实际解析结果
Cargo.lock 保存确切版本、来源和校验信息,使其他开发者和 CI 更容易复现依赖图。
常见策略:
- 应用、服务、命令行工具:提交 lockfile;
- 同时包含应用与库的 Workspace:通常提交;
- 纯库:可按团队策略决定是否在仓库提交,但发布后的下游不会使用该库仓库的 lockfile 锁定整个应用。
5.3 可复现命令
bash
cargo build --locked
cargo test --locked
如果清单和 lockfile 不一致,命令直接失败,而不是悄悄更新依赖。
更新依赖时应明确范围:
bash
cargo update
cargo update -p serde
cargo update -p serde --precise 1.0.210
依赖升级应伴随变更审查、测试和必要的 changelog,而不是由 CI 意外完成。
六、依赖来源与临时覆盖
6.1 registry、git 与 path
toml
[dependencies]
serde = "1"
protocol = { git = "https://example.invalid/protocol.git", rev = "abc123" }
telemetry-model = { path = "../model" }
可复现性角度通常优先:
text
稳定 registry 版本 -> 固定 commit 的 git 依赖 -> 本地 path
跟随移动分支的 git 依赖会让构建结果随远端变化,不适合作为长期生产策略。
6.2 [patch] 用于全图替换
toml
[patch.crates-io]
network-lib = { git = "https://example.invalid/network-lib.git", rev = "def456" }
[patch] 会影响整个 Workspace 中对应来源的包,适合验证尚未发布的修复。应记录退出条件:上游发布哪个版本后删除 patch,避免临时措施永久化。
6.3 检查依赖图
bash
cargo tree
cargo tree -d
cargo tree -e features
cargo metadata --format-version 1
- cargo tree -d:查找重复版本;
- cargo tree -e features:解释 feature 启用来源;
- cargo metadata:为自动化工具提供结构化工程信息。
重复版本不一定错误,但会增加体积、编译时间和类型不兼容风险,应确认是否处于迁移期。
七、Features:为能力做加法
7.1 定义可组合能力
toml
[features]
default = ["json-log"]
json-log = ["tracing-subscriber/json"]
postgres = ["dep:sqlx", "sqlx/postgres", "sqlx/runtime-tokio-rustls"]
metrics = ["dep:metrics"]
[dependencies]
sqlx = { version = "0.8", optional = true, default-features = false }
metrics = { version = "0.24", optional = true }
代码使用条件编译:
rust
#[cfg(feature = "postgres")]
mod postgres;
7.2 Feature 合并要求加法语义
同一依赖可能被多条路径启用 features,最终能力通常取并集。因此 feature 最好表示“增加什么”,不要让两个 feature 对同一 API 表达互斥环境。
不推荐:
toml
[features]
staging = []
production = []
环境差异应由配置文件、环境变量或部署参数表达。
7.3 确有互斥能力时明确失败
rust
#[cfg(all(feature = "rustls", feature = "native-tls"))]
compile_error!("features `rustls` and `native-tls` cannot be enabled together");
CI 至少检查:
bash
cargo check --no-default-features
cargo check --all-features
cargo check --features rustls
cargo check --features native-tls
7.4 避免无条件启用 full
在教程或原型中使用 tokio/full 很方便,但生产库应尽量只开启真实需要的能力,以减少编译时间、依赖面和潜在安全风险。
八、Profile:为不同阶段定义构建取舍
8.1 常用 Profile
| Profile | 常见命令 | 主要目标 |
|---|
| dev | cargo build、cargo run | 快速增量编译、调试 |
| test | cargo test | 测试二进制 |
| release | cargo build --release | 运行性能和产物优化 |
| bench | cargo bench | 性能基准 |
8.2 根级配置示例
toml
[profile.dev]
opt-level = 0
debug = 1
incremental = true
[profile.test]
opt-level = 1
debug = 1
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
strip = "symbols"
[profile.release-observable]
inherits = "release"
debug = 1
strip = "none"
可使用:
bash
cargo build --profile release-observable
线上需要栈回溯或性能剖析时,完全移除调试信息可能得不偿失。Profile 应由部署诊断需求、编译时长、产物大小和运行性能共同决定。
8.3 明确整数与 panic 策略
overflow-checks、panic = "abort" 等配置会改变运行和故障行为。应用若依赖 unwinding 做资源清理或捕获 panic,就不能只为了减小产物而切换到 abort。
九、build.rs:受控的编译前步骤
9.1 声明输入变化
rust
fn main() {
println!("cargo:rerun-if-changed=schemas/event.proto");
println!("cargo:rerun-if-env-changed=BUILD_CHANNEL");
}
构建脚本通过标准输出中的 Cargo 指令声明依赖、环境和链接信息。重建条件越精确,构建越可预测。
9.2 生成内容写入 OUT_DIR
rust
use std::{env, fs, path::PathBuf};
fn main() {
println!("cargo:rerun-if-changed=assets/schema-version.txt");
let out = PathBuf::from(env::var_os("OUT_DIR").unwrap());
let version = fs::read_to_string("assets/schema-version.txt").unwrap();
fs::write(
out.join("schema_version.rs"),
format!(
"pub const SCHEMA_VERSION: &str = {:?};",
version.trim()
),
)
.unwrap();
}
业务代码引入:
rust
include!(concat!(env!("OUT_DIR"), "/schema_version.rs"));
不要把生成文件直接写入 src/,否则会污染工作树,并可能在并发构建中相互覆盖。
9.3 构建脚本的安全边界
适合:代码生成、编译本地 C/C++、探测系统库、注入非敏感构建元数据。
不适合:
- 下载并执行未固定的远程脚本;
- 读取秘密并写入二进制;
- 修改仓库源文件;
- 执行部署、上传或数据库变更;
- 依赖开发者机器上的绝对路径。
构建应尽可能确定、离线可复现且易于审查。
十、测试体系:每层验证不同合同
10.1 单元测试
与实现同模块,适合验证私有逻辑和边界条件:
rust
pub fn normalize(value: i64) -> i64 {
value.clamp(0, 100)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clamps_out_of_range_values() {
assert_eq!(normalize(-1), 0);
assert_eq!(normalize(120), 100);
}
}
10.2 集成测试
tests/*.rs 作为独立 crate 编译,只通过公共 API 使用被测库,适合验证外部合同:
rust
use telemetry_model::Event;
#[test]
fn event_can_be_constructed() {
let event = Event::new("cpu", 72);
assert_eq!(event.name(), "cpu");
}
10.3 异步测试
rust
#[tokio::test]
async fn stores_and_reads_event() {
let repository = InMemoryRepository::new();
repository.save(sample_event()).await.unwrap();
let event = repository.find("cpu").await.unwrap();
assert!(event.is_some());
}
共享端口、数据库和环境变量必须隔离,避免测试执行顺序影响结果。
10.4 文档与编译测试
文档测试验证示例能否编译和运行;compile_fail 可验证非法用法必须被拒绝。对于宏、高级生命周期和线程属性,trybuild 可以更系统地维护成功与失败案例。
10.5 属性测试与 fuzz
边界空间很大时,仅列举固定样例不足。属性测试可以验证“序列化往返保持等价”“排序后单调”等性质;fuzz 则适合协议解析、unsafe 容器和二进制输入边界。
10.6 常用命令
bash
cargo test --workspace
cargo test -p telemetry-model
cargo test event_roundtrip
cargo test --test api_contract
cargo test --doc
cargo test -- --nocapture
-- 前是 Cargo 参数,后是测试二进制参数。
十一、质量门禁与基准
一套常见的提交前门禁:
bash
cargo fmt --all -- --check
cargo check --workspace --all-targets --all-features
cargo clippy --workspace --all-targets --all-features -- -D warnings
cargo test --workspace --all-features
cargo test --workspace --doc
cargo build --workspace --release --locked
unsafe 模块可追加:
bash
cargo +nightly miri test
性能基准应避免只记录一次耗时。使用 Criterion 等工具时,应固定数据、进行预热和多次采样,并区分:
- 微基准:单个函数或数据结构;
- 组件基准:序列化、查询、批处理;
- 端到端负载:真实并发、I/O 和部署环境。
基准数字只有在环境和测量方法稳定时才可比较。
十二、CI:把工程约定变成机器检查
12.1 GitHub Actions 示例
yaml
name: ci
on:
push:
pull_request:
jobs:
check:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- run: cargo fmt --all -- --check
- run: cargo check --workspace --all-targets --all-features --locked
- run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
- run: cargo test --workspace --all-features --locked
minimal-features:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo check --workspace --no-default-features --locked
12.2 固定工具链
toml
[toolchain]
channel = "1.78.0"
components = ["clippy", "rustfmt"]
profile = "minimal"
固定版本提升复现性。若项目同时承诺最低支持版本和最新 stable,应建立两类任务:一个验证 MSRV,一个及时发现新工具链兼容问题。
12.3 Feature 与平台矩阵
CI 不应只验证默认 feature。至少考虑:
- --no-default-features;
- --all-features;
- 关键 feature 组合;
- Linux、Windows 或目标部署平台;
- 最低支持 Rust 版本;
- 发布构建和文档构建。
12.4 缓存策略
Cargo Home 缓存 registry 索引、源码和 git checkout;target/ 保存编译产物。缓存键应考虑:
- 操作系统与目标架构;
- rustc 版本;
- Cargo.lock;
- feature 集合;
- Profile 和 target triple。
错误共享缓存可能比不缓存更慢,还可能造成难以解释的污染。缓存应是加速手段,不是构建正确性的前提。
十三、tracing:让异步服务可关联
普通文本日志在异步任务跨线程切换后很难还原上下文。tracing 使用 Span 和结构化 Event 关联请求:
rust
#[tracing::instrument(
skip(repository),
fields(event_name = event.name())
)]
async fn persist_event(
repository: &dyn EventRepository,
event: Event,
) -> Result<(), StorageError> {
repository.save(event).await?;
tracing::info!("event persisted");
Ok(())
}
工程约定应明确:
- 库发出事件,但不安装全局 subscriber;
- 应用入口负责格式、过滤和输出目的地;
- 字段名称保持稳定,如 request_id、tenant_id、operation、latency_ms;
- token、密码、支付信息和隐私数据不得进入日志;
- 异步函数优先使用 #[instrument] 或 .instrument(span),避免 enter guard 跨 .await。
可观测性不是发布后的附加项,而是 API 与字段设计的一部分。
十四、发布前的包级检查
14.1 元数据必须真实
toml
[package]
name = "telemetry-model"
version = "0.1.0"
edition.workspace = true
description = "Domain types for a telemetry service"
readme = "README.md"
repository = "https://example.invalid/telemetry-platform"
keywords = ["telemetry", "events"]
publish = true
许可证字段、仓库地址、README 和描述都是对使用者的真实声明,不能把模板占位符当作事实。
14.2 检查将要上传的内容
bash
cargo package --list
cargo package
应检查是否包含:
- .env、token、私钥;
- 内部域名、账号或调试快照;
- 本地绝对路径;
- 大型生成文件;
- 不应公开的测试数据;
- 缺失的 README、许可证或构建输入。
使用 include 或 exclude 控制内容:
toml
[package]
include = [
"src/**",
"Cargo.toml",
"README.md",
]
14.3 SemVer 与发布顺序
如果 storage 依赖 model,通常需要先发布 model,等待 registry 可见,再发布 storage。多包版本更新不是 Cargo 自动完成的事务。
发布前确认:
- 公共 API 变化对应正确版本级别;
- 依赖约束已同步;
- changelog、tag 和清单版本一致;
- 打包后的 crate 能在干净环境构建;
- 下游 smoke test 使用的是已打包或已发布版本,而非工作区 path。
14.4 发布是外部动作
bash
cargo publish -p telemetry-model
真正发布会把内容上传到外部 registry,应在明确审批后执行。凭据应通过 Cargo 认证机制或 CI secret 提供,不能写入清单、构建脚本或日志。
cargo yank 只影响新的依赖解析,不能删除已经公开的内容。因此,发布前预检比发布后补救更重要。
十五、从日常开发到发布的闭环
阶段 1:日常迭代
- 按成员包执行 cargo check;
- 运行受影响模块的定向测试;
- 用本地 mock 或测试容器验证基础设施适配;
- 避免每次修改都构建整个 Workspace。
阶段 2:提交前
- 格式检查;
- 全 Target check;
- Clippy;
- 单元、集成和文档测试;
- 关键 feature 组合;
- 工作树和生成文件检查。
阶段 3:发布候选
- 使用 --locked 构建 release;
- 验证 MSRV 和主要平台;
- 执行 cargo package;
- 检查依赖漏洞、许可证与包内容;
- 运行候选产物的 smoke test;
- 确认版本、changelog 和回滚方案。
阶段 4:发布后
- 在干净项目中安装或依赖已发布版本;
- 验证文档页面和 registry 元数据;
- 观察下游构建与服务指标;
- 严重问题发布修复版本,必要时评估 yank。
十六、常见陷阱
- 成员各自声明依赖版本。 长期会形成漂移,应通过 [workspace.dependencies] 统一可复用版本。
- 把 Workspace 当作架构边界。 目录在一起不代表依赖方向合理。
- 认为库仓库的 lockfile 能锁住下游。 下游应用有自己的完整解析图。
- 使用移动分支 git 依赖。 构建结果会随远端变化。
- feature 表达环境。 feature 会合并,部署配置应放到运行时。
- 默认开启全部能力。 编译、产物和供应链面会不必要地扩大。
- build.rs 输入未声明。 小改动可能触发全量重建,或者真实输入变化没有被正确跟踪。
- CI 只运行 cargo test。 会遗漏格式、lint、文档、feature、平台和发布构建问题。
- 把秘密放入 env!。 编译期环境值可能直接进入产物。
- 缓存成为正确性依赖。 清空缓存后构建失败说明流程本身不可复现。
- 发布元数据使用模板值。 许可证、仓库和描述都必须反映真实项目。
- 认为 yank 能撤回发布。 已发布内容应视为可能被长期保存和使用。
十七、实践练习
- 搭建 model、ingest、storage、service 四个 crate,并画出依赖图,确保无环。
- 在根清单统一 edition、MSRV、依赖和 lint,让成员只选择需要的依赖。
- 为 storage 设计 memory、postgres、sqlite features,说明哪些可组合、哪些互斥,并编写 CI 矩阵。
- 编写 build.rs 从 schema 版本文件生成常量,保证输出确定、离线、写入 OUT_DIR 且重建条件准确。
- 为事件模型建立单元测试、集成测试、文档测试、compile-fail 测试和属性测试。
- 用 cargo tree -d 找出重复主版本,判断哪些是必要迁移,哪些可以收敛。
- 设计 release-observable Profile,在性能、调试信息和产物大小之间做书面取舍。
- 编写 CI,覆盖默认、最小和全部 features,以及 MSRV 与最新 stable。
- 执行 cargo package --list 和 cargo package 做发布演练,但不执行真正上传;记录包内容检查结果和发布顺序。
- 为应用定义稳定 tracing 字段规范,并加入测试确保敏感字段不会被记录。
十八、总结
- Package 是清单与发布单元,Crate 是编译单元,Target 是构建目标,Workspace 是多包工程上下文。
- Workspace 的核心价值是共享解析和工程约定,同时保留清晰 crate 边界。
- 依赖方向比目录结构更重要,核心模型不应反向依赖基础设施和应用装配。
- Cargo.toml 描述允许范围,Cargo.lock 记录实际解析结果;应用和混合 Workspace 通常应提交 lockfile。
- registry、固定 rev 的 git、path 和 [patch] 各有用途,临时覆盖必须有退出条件。
- feature 应表达可组合能力,并在 CI 中覆盖默认、最小、全部和关键组合。
- Profile 是编译速度、运行性能、可诊断性、panic 策略和产物大小之间的工程取舍。
- build.rs 应确定、离线、输入明确,并把生成结果写入 OUT_DIR。
- 单元、集成、文档、编译、属性、fuzz 和基准测试验证不同层面的合同。
- CI 应使用 --locked,并验证格式、lint、测试、feature、工具链和主要平台。
- tracing 为异步服务提供结构化上下文,库负责发出信号,应用负责收集策略。
- 发布前必须核对包内容、SemVer、依赖拓扑、凭据、真实元数据和许可证。
- cargo publish 是外部发布动作,必须经过明确审批;yank 不能抹去已经公开的内容。
- 成熟工程流程应覆盖日常迭代、提交门禁、候选验证和发布后观察。
十九、章节导航
本章将前面介绍的类型、并发、异步与底层能力放入可复现工程流程。下一章通过多线程 Web 服务器综合练习所有权、channel、线程池、错误处理与优雅关闭。
- 上一章:第11章:底层能力
- 下一章:第13章:综合实战 —— 从零实现多线程 Web 服务器
- Rust 深度教程
- 第10章:异步编程
- 第11章:底层能力
- 第13章:多线程 Web 服务器