Agent X-Ray
RuntimeNotesAbout
Notes/代码工程/Rust 深度教材/第12章

第12章:Cargo 工程化 —— 依赖、工作空间、构建、测试与发布

13 分钟 · 更新于 2026-09-01

第12章:Cargo 工程化 —— 依赖、工作空间、构建、测试与发布

一个 Rust 项目能在开发机上运行,只说明源代码通过了一次构建。可持续交付还需要稳定的包边界、可复现的依赖图、受控的 feature 组合、明确的测试层级、可诊断的产物和经过审批的发布流程。本章以“事件采集平台”Workspace 为主线,把 Cargo 的清单、构建、测试、CI 和发布能力组织成一条工程闭环。

本章目标

  • 区分 Package、Crate、Target 与 Workspace。
  • 设计单向依赖图,并在根清单统一版本、edition、依赖和 lint。
  • 理解 Cargo.tomlCargo.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 只定义领域数据和规则,不依赖网络运行时或数据库驱动;ingeststorage 负责基础设施适配;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.rssrc/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,通常意味着领域层知道了应用装配细节;storageingest 互相依赖则可能形成循环。

应通过以下方式保持方向:

  • 将共享 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 清单描述可接受范围

toml
serde = "1.0.203"

默认使用 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常见命令主要目标
devcargo buildcargo run快速增量编译、调试
testcargo test测试二进制
releasecargo build --release运行性能和产物优化
benchcargo 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-checkspanic = "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_idtenant_idoperationlatency_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、许可证或构建输入。

使用 includeexclude 控制内容:

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。

十六、常见陷阱

  1. 成员各自声明依赖版本。 长期会形成漂移,应通过 [workspace.dependencies] 统一可复用版本。
  2. 把 Workspace 当作架构边界。 目录在一起不代表依赖方向合理。
  3. 认为库仓库的 lockfile 能锁住下游。 下游应用有自己的完整解析图。
  4. 使用移动分支 git 依赖。 构建结果会随远端变化。
  5. feature 表达环境。 feature 会合并,部署配置应放到运行时。
  6. 默认开启全部能力。 编译、产物和供应链面会不必要地扩大。
  7. build.rs 输入未声明。 小改动可能触发全量重建,或者真实输入变化没有被正确跟踪。
  8. CI 只运行 cargo test 会遗漏格式、lint、文档、feature、平台和发布构建问题。
  9. 把秘密放入 env! 编译期环境值可能直接进入产物。
  10. 缓存成为正确性依赖。 清空缓存后构建失败说明流程本身不可复现。
  11. 发布元数据使用模板值。 许可证、仓库和描述都必须反映真实项目。
  12. 认为 yank 能撤回发布。 已发布内容应视为可能被长期保存和使用。

十七、实践练习

  1. 搭建 modelingeststorageservice 四个 crate,并画出依赖图,确保无环。
  2. 在根清单统一 edition、MSRV、依赖和 lint,让成员只选择需要的依赖。
  3. storage 设计 memorypostgressqlite features,说明哪些可组合、哪些互斥,并编写 CI 矩阵。
  4. 编写 build.rs 从 schema 版本文件生成常量,保证输出确定、离线、写入 OUT_DIR 且重建条件准确。
  5. 为事件模型建立单元测试、集成测试、文档测试、compile-fail 测试和属性测试。
  6. cargo tree -d 找出重复主版本,判断哪些是必要迁移,哪些可以收敛。
  7. 设计 release-observable Profile,在性能、调试信息和产物大小之间做书面取舍。
  8. 编写 CI,覆盖默认、最小和全部 features,以及 MSRV 与最新 stable。
  9. 执行 cargo package --listcargo package 做发布演练,但不执行真正上传;记录包内容检查结果和发布顺序。
  10. 为应用定义稳定 tracing 字段规范,并加入测试确保敏感字段不会被记录。

十八、总结

  1. Package 是清单与发布单元,Crate 是编译单元,Target 是构建目标,Workspace 是多包工程上下文。
  2. Workspace 的核心价值是共享解析和工程约定,同时保留清晰 crate 边界。
  3. 依赖方向比目录结构更重要,核心模型不应反向依赖基础设施和应用装配。
  4. Cargo.toml 描述允许范围,Cargo.lock 记录实际解析结果;应用和混合 Workspace 通常应提交 lockfile。
  5. registry、固定 rev 的 git、path 和 [patch] 各有用途,临时覆盖必须有退出条件。
  6. feature 应表达可组合能力,并在 CI 中覆盖默认、最小、全部和关键组合。
  7. Profile 是编译速度、运行性能、可诊断性、panic 策略和产物大小之间的工程取舍。
  8. build.rs 应确定、离线、输入明确,并把生成结果写入 OUT_DIR
  9. 单元、集成、文档、编译、属性、fuzz 和基准测试验证不同层面的合同。
  10. CI 应使用 --locked,并验证格式、lint、测试、feature、工具链和主要平台。
  11. tracing 为异步服务提供结构化上下文,库负责发出信号,应用负责收集策略。
  12. 发布前必须核对包内容、SemVer、依赖拓扑、凭据、真实元数据和许可证。
  13. cargo publish 是外部发布动作,必须经过明确审批;yank 不能抹去已经公开的内容。
  14. 成熟工程流程应覆盖日常迭代、提交门禁、候选验证和发布后观察。

十九、章节导航

本章将前面介绍的类型、并发、异步与底层能力放入可复现工程流程。下一章通过多线程 Web 服务器综合练习所有权、channel、线程池、错误处理与优雅关闭。

  • 上一章:第11章:底层能力
  • 下一章:第13章:综合实战 —— 从零实现多线程 Web 服务器

  • Rust 深度教程
  • 第10章:异步编程
  • 第11章:底层能力
  • 第13章:多线程 Web 服务器

本章目录
一、工程结构先于命令清单二、Cargo 的四个基本概念三、创建虚拟 Workspace四、成员清单与依赖类别五、Cargo.toml 与 Cargo.lock六、依赖来源与临时覆盖七、Features:为能力做加法八、Profile:为不同阶段定义构建取舍九、build.rs:受控的编译前步骤十、测试体系:每层验证不同合同十一、质量门禁与基准十二、CI:把工程约定变成机器检查十三、tracing:让异步服务可关联十四、发布前的包级检查十五、从日常开发到发布的闭环十六、常见陷阱十七、实践练习十八、总结十九、章节导航Related Documents
苏ICP备2025204887号-2