楼层: 首页/ 软件技术/ Rust 语言基础/ Cargo workspace 与工程化:从"能跑"到"能交付"
21

Cargo workspace 与工程化:从"能跑"到"能交付"

Cargo Workspace & Production Engineering

前面二十章你写的都是"一个 crate 一个 main.rs"的形态,这种结构在 demo 阶段很爽,一进真实项目立刻撞墙:core 逻辑和 web 层互相 import 成环、三个子项目各锁一个 tokio 版本、CI 上跑一次全量构建要八分钟、同事本地编过了 CI 编不过。这一章讲的不是语法,而是把 Rust 项目从"能跑"推到"能交付"的那套工程规矩。这些东西不会出现在语法书里,但决定了你六个月后的项目是不是一团泥。

单 crate 还是 workspace:先想清楚"边界"再动手

先说结论:不要一上来就拆 workspace,但一定要在"第一个 crate 开始被第二处 import"时拆。单 crate 的痛点是渐进出现的,你会在下面这张表里的某一个时刻第一次感到别扭。

信号单 crate 的表现该拆成 workspace 的理由
编译越来越慢改一行 handler,整个项目重编,因为 bin 和 lib 是同一个 crate拆成 core(lib) / app(bin),只改 bin 时 core 的编译产物直接复用
依赖爆炸web 框架、数据库、CLI 解析器全塞进一个 Cargo.toml,谁都看得见谁每个 member 只声明自己真正用的依赖,边界靠 Cargo.toml 强制
版本打架两处写 serde = "1.0.190" 和 "1.0.200",升级时漏改一处[workspace.dependencies] 一处定义、处处 workspace = true
想要多产物同一个库里想同时出 CLI 和 HTTP 服务,main.rs 里一堆 if args.cli {}多个 bin crate 共享同一个 lib,各自独立发布
想拆出去复用要把某块逻辑给团队另一个项目用,只能复制粘贴member 可以单独 cargo publish 到私有 registry 或 crates.io

典型三层 workspace 目录结构

order-service/ ├── Cargo.toml # 虚拟 workspace 根,没有 [package] ├── Cargo.lock # 全 workspace 只有一个锁文件 ├── .cargo/config.toml # 链接器、target 别名等构建配置 ├── crates/ │ ├── core/ # 纯领域逻辑,不依赖任何框架 │ │ ├── Cargo.toml │ │ └── src/lib.rs │ ├── infra/ # 数据库、缓存、外部 API 适配 │ │ └── src/lib.rs │ └── api/ # axum 路由 + main.rs,依赖 core 与 infra │ └── src/main.rs └── xtask/ # 自定义任务:cargo xtask release └── src/main.rs

根 Cargo.toml(虚拟 workspace:只有 [workspace],没有 [package])

[workspace] # 虚拟 workspace 的 resolver 必须显式写,否则默认回落成 "1"(老解析算法) resolver = "2" members = ["crates/core", "crates/infra", "crates/api", "xtask"] # 全仓库唯一的版本与元信息,member 用 version.workspace = true 引用 [workspace.package] version = "0.1.0" edition = "2021" license = "MIT" rust-version = "1.82" # MSRV,CI 上会真的用来验证 # 依赖版本只在根写一次,member 写 xxx = { workspace = true } [workspace.dependencies] serde = { version = "1", features = ["derive"] } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } axum = "0.8" tracing = "0.1" # 内部 crate 也能放进 workspace.dependencies,用 path + version 双写 order-core = { path = "crates/core", version = "0.1.0" } # 统一 release 优化档,避免每个 member 各写一套 [profile.release] lto = "thin" codegen-units = 1 strip = true

member crate 只写差异

[package] name = "order-api" version.workspace = true edition.workspace = true rust-version.workspace = true [dependencies] order-core = { workspace = true } axum = { workspace = true } tokio = { workspace = true } # 想让某个 member 换个 feature 组合?可以,但会全仓库取并集,见下节 serde = { workspace = true, features = ["rc"] }

常用命令记住三条就够:cargo build -p order-api 只编目标 crate 及其依赖;cargo test --workspace 跑全部;cargo add tokio -p order-core 往指定 member 加依赖(不写 -p 会在虚拟 workspace 根报错)。

整个 workspace 只有一个 Cargo.lock,这件事比你想的重要

很多人以为每个 member 有自己的锁文件,其实Cargo.lock 只在 workspace 根生成一份,所有 member 共用。好处是"一处升级、处处一致";坏处是"一处乱升、处处被污染"。

关于"要不要提交 Cargo.lock",网上至今还有一堆过时说法。按 Cargo Book 现在的口径,正确姿势是:

项目形态提交 Cargo.lock?原因与配套动作
bin / 应用 / 服务必须提交应用要的是"今天编出来的东西明天还能编出来"。CI 上用 cargo build --locked,锁文件与 Cargo.toml 不一致就直接失败,而不是悄悄改锁文件。
lib / 发布到 crates.io可以提交,但不影响下游下游用的是自己的锁文件,你的 Cargo.lock 会被忽略。提交的收益是你自己的 CI 可复现,代价是 PR 里多一份 diff。
lib 且承诺 MSRV提交 + 额外验证锁文件里的新依赖可能悄悄抬高 MSRV。需要用 cargo +nightly update -Z direct-minimal-versions 或 cargo hack 单独跑一套"最低版本"任务。
# 精确升级单个依赖,别用 cargo update 一把梭 cargo update -p serde --precise 1.0.210 # 看看谁把 serde 拉到了旧版本 cargo tree -i serde # 看看哪些依赖出现了重复版本(同一个 crate 被编了两遍,体积和编译时间双输) cargo tree -d # CI 上验证锁文件没有被偷偷改动 cargo check --locked --workspace --all-targets

论为什么"全 workspace 一个锁文件"是设计的必然

① 依赖只有一份编译产物。同一个 serde 版本在一个 workspace 里只会被编译一次,所有 member 复用。如果每个 member 有独立锁文件,就会出现"core 用 serde 1.0.200、api 用 1.0.210",链接时两个版本同时存在,二进制里躺着两套代码。

② feature 是取并集的。Cargo 的 feature 解析规则是"只要图里任何一个地方开启了某 feature,整个图里这个 feature 就是开的"。所以 A 成员开 serde/rc,B 成员即使没写,也吃到了这个 feature。这条规则解释了后面 feature 章节里所有奇怪现象,请务必记住 feature 只增不减。

③ 版本统一 ≠ 版本相同。workspace 统一的是"声明的区间",最终解析出来的具体版本仍由 Cargo 的 caret 语义决定。serde = "1" 的意思是 >=1.0.0, <2.0.0,所以两个声明 "1" 的 member 永远解析到同一个版本,不会分裂。

path 依赖与发布到 crates.io:从"本地联调"到"对外发版"

workspace 内互引有两种写法,区别只有一行,但决定了这个 crate 能不能 cargo publish。

# 写法 A:只写 path —— 只能本地用,cargo publish 会直接报错 order-core = { path = "../core" } # 写法 B:path + version —— 本地走 path,发布时 Cargo 自动把 path 剥掉、只留 version order-core = { path = "../core", version = "0.1.0" }
# 发布前一定先 dry-run:它会真的把包打出来、在隔离环境里编译一遍 cargo publish -p order-core --dry-run # 正式发布,注意依赖顺序:被依赖的 crate 必须先发 cargo publish -p order-core cargo publish -p order-api # 发布后如果发现版本号写错,cargo yank 只能撤回不能被新项目选中,不能删除 cargo yank --version 0.1.1 order-core

发布前 Cargo 会做几件你容易忘的事:检查 license 或 license-file、检查 description、按 include/exclude 或 .gitignore 收敛要打包的文件、把 dev-dependencies 从发布清单里去掉。团队里最常见的翻车是"本地能编,发出去的包少了一个目录",原因就是 .gitignore 里排掉了某个 assets/ 而在 include 里没补回来。

临时想用 fork 或本地补丁覆盖某个依赖,不要改依赖者的写法,用根 Cargo.toml 的 [patch]:

[patch.crates-io] # 只影响本 workspace 的解析结果,不改 Cargo.toml 里的版本声明 serde = { git = "https://github.com/serde-rs/serde", branch = "master" } tokio = { path = "../forks/tokio" }
坑:改了 path 依赖忘了删,cargo publish 报 "no matching package named"

报错长这样:error: no matching package named `order-core` found. location searched: registry `crates-io`。原因几乎总是依赖只写了 path 没写 version,Cargo 剥掉 path 之后发现没有版本信息可填。另一个常见副本是:被依赖的 crate 还没发布,你先把依赖者发了。解决顺序是"先发 core,再发 api",或者干脆在 api 上标 publish = false,明确表示它只作为应用的产物、不对外发。

Feature flags:Rust 最优雅也最容易翻车的机制

feature 的设计目标是"让使用者按需付费"——不用 tokio 全功能就别编全功能,不用 serde 就别把序列化代码塞进二进制。但它的语义有一处反直觉的地方,理解这一点能省掉你后来 80% 的困惑。

一个可选的 JSON 输出能力:dep: 语法与默认 feature

[features] # default 是一个普通 feature,cargo build 时不带 --no-default-features 就会开 default = ["json"] # dep:serde_json 表示"开启这个 feature 时把可选依赖 serde_json 打开" # 用 dep: 前缀后,serde_json 本身不会再隐式生成同名 feature(Rust 1.60+) json = ["dep:serde_json"] # 转发别人的 feature:开 postgres 就等于开 sqlx/postgres postgres = ["sqlx/postgres"] full = ["json", "postgres"] [dependencies] serde_json = { version = "1", optional = true } sqlx = { version = "0.8", default-features = false, optional = true }

代码里怎么根据 feature 切换实现

// 1) 条件编译:这段代码在没开 json 时根本不存在 #[cfg(feature = "json")] pub fn to_json(v: &Report) -> String { serde_json::to_string(v).unwrap() } // 2) 条件派生:开了 feature 才给结构体加 Serialize #[derive(Debug, Clone)] #[cfg_attr(feature = "json", derive(serde::Serialize))] pub struct Report { pub id: u64, pub total: f64 } // 3) 给"必须二选一"的场景兜底,防止一个 feature 都不开导致编不过 #[cfg(not(any(feature = "postgres", feature = "sqlite")))] compile_error!("必须至少开启 postgres 或 sqlite 中的一个 feature");

论feature 只增不减:为什么组合爆炸无法回避

① 规则本身。Cargo 在解析依赖图时,把所有路径上出现的 feature 求并集,然后只编译一次。也就是说"给某个 crate 单独开 feature"这件事在 Cargo 里做不到——除非你开两个不同 major 版本的依赖,那就是两个不同的编译单元了。

② 因此 feature 必须"可加"。如果一个 feature 会删掉某段代码或改掉公开 API 的签名,那么"另外某个 crate 因为别的原因打开了它"就会把你的代码编崩,而你看自己的 Cargo.toml 完全看不出问题。所以正确的做法是:feature 只做加法,default 只做减法式的优化(关掉 default 只是少编东西,不该改语义)。

③ 组合是要测的。假设你有 5 个互不相关的 feature,就是 25=32 种组合。CI 上不可能全跑,务实做法是跑三档:--no-default-features、默认、--all-features;再加一条 cargo hack check --feature-powerset --depth 2 抽查两两组合。

CI 里把三种极端组合都跑一遍

# 一个 feature 都不开,能编过说明"最少依赖"路径是通的 cargo check -p order-core --no-default-features # 默认组合 cargo check -p order-core # 全开,能编过说明 feature 之间没有互相打架 cargo check -p order-core --all-features # 更狠:两两组合抽查(需要 cargo-hack) cargo hack check --feature-powerset --depth 2 --workspace
坑:default-features = false 写在了错误的层级

你可能见过 axum = { version = "0.8", default-features = false } 导致 cargo build 报一堆 "no method named ... found"。原因是关掉 default 之后,连 tokio 集成都被关掉了,Router::serve 之类的 API 直接消失。关 default 之前先看这个 crate 的 Cargo.toml 里 default 到底包含了什么,通常需要手动把它 default 里真正必需的那几项补回来:

# 先看这个 crate 有哪些 feature、default 是什么 cargo info axum # 再看当前解析结果里,某个 feature 是被谁打开的 cargo tree -e features -i serde_json

build.rs:编译期做代码生成,别拿它当"启动脚本"

build.rs 是 Cargo 在你 crate 编译之前自动跑的一段程序,它编译产物是你 crate 的构建脚本、运行在你的开发机上。它不是构建产物的一部分,也不会出现在最终二进制里。

该用 build.rs不该用 build.rs
用 prost-build 从 .proto 生成 Rust 代码读配置文件、连数据库、发起网络请求——这些是运行时的事
用 bindgen 从 C 头文件生成 FFI 绑定做业务逻辑计算(应该写成普通函数,好测)
链接本地 C 库:cargo:rustc-link-lib / rustc-link-search跑 git / 下载依赖 / 调 npm(构建变成不可复现、不可离线)
把 git sha、构建时间编译进二进制(rustc-env)跑单元测试(测试请用 cargo test)
探测平台能力,输出 cargo:rustc-cfg 供 cfg() 使用做"每次构建都重编"的重活,让 cargo build 从 3 秒变 3 分钟

一个规范的 build.rs:既有代码生成,也有元信息注入

// build.rs —— 注意:它编译后运行在【宿主】平台,不是目标平台 fn main() { // 关键:告诉 Cargo 只有这些文件变了才重跑 build.rs // 不写这行,Cargo 会保守地每次都重跑(这是最常见的"构建变慢"元凶) println!("cargo:rerun-if-changed=proto/order.proto"); println!("cargo:rerun-if-changed=build.rs"); // 代码生成:prost-build 把 proto 编成 Rust tonic_build::configure() .build_server(true) .compile_protos(&["proto/order.proto"], &["proto"]) .expect("编译 proto 失败"); // 注入编译期环境变量,代码里用 env!("BUILD_SHA") 取 let sha = std::process::Command::new("git") .args(["rev-parse", "--short", "HEAD"]) .output() .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) .unwrap_or_else(|_| "unknown".into()); println!("cargo:rustc-env=BUILD_SHA={sha}"); // 让代码里能写 #[cfg(has_custom_alloc)] if std::env::var("ENABLE_CUSTOM_ALLOC").is_ok() { println!("cargo:rustc-cfg=has_custom_alloc"); } // 链接一个系统库(例如 zlib) println!("cargo:rustc-link-lib=z"); }
// src/version.rs —— 读 build.rs 注入的变量 pub const BUILD_SHA: &str = env!("BUILD_SHA"); pub const VERSION: &str = env!("CARGO_PKG_VERSION"); // 暴露成接口,方便运维排查"线上跑的是哪个 commit" pub fn version_string() -> String { format!("{} ({})", VERSION, BUILD_SHA) }
坑:build.rs 里跑目标平台的东西,交叉编译直接炸

build.rs 被编译成宿主平台的二进制(你在 Mac 上交叉编译到 Linux,它仍然是 Mac 可执行文件)。所以你在里面 Command::new("./some_arm_binary") 是绝对跑不起来的。要判断"目标平台"应该看 Cargo 给的环境变量:CARGO_CFG_TARGET_OS、CARGO_CFG_TARGET_ARCH、TARGET。另外两条高频坑:build.rs 里的编译错误不会告诉你哪一行行号,请把它当独立程序调试(cargo build -vv 能看到它的 stderr);build.rs 输出不进 Cargo 的增量缓存粒度,生成 OUT_DIR 里的文件务必让 rerun-if-changed 覆盖到输入。

交叉编译与 cross:在 Mac 上产出 Linux 二进制

静态链接的 musl 目标是容器部署的默认答案:一个 5MB 的二进制,运行时镜像可以用 scratch,连 libc 都不用装。

本机直接交叉编译(不加 Docker,需要自己装链接器)

# 1. 加目标平台 rustup target add x86_64-unknown-linux-musl # 2. 在 .cargo/config.toml 里指定链接器(musl 目标必须显式给) # [target.x86_64-unknown-linux-musl] # linker = "x86_64-linux-musl-gcc" # 3. 构建。--release 走 [profile.release],lto + strip 一起上 cargo build --release --target x86_64-unknown-linux-musl -p order-api # 4. 验证:必须是静态链接、不依赖外部 .so file target/x86_64-unknown-linux-musl/release/order-api # 期望输出类似:ELF 64-bit LSB executable, statically linked, stripped

用 cross 一条命令搞定(内部跑 Docker,自动带好链接器与 QEMU)

cargo install cross --git https://github.com/cross-rs/cross cross build --release --target aarch64-unknown-linux-musl -p order-api # 苹果芯片上想跑 ARM64 Linux 的测试,cross 会用 QEMU 模拟 cross test --target aarch64-unknown-linux-gnu

cross 的价值在于它把一个"环境问题"变成了"一条命令":容器里预置了各种 target 的交叉链接器、pkg-config、常见 -sys 库,还能通过 Cross.toml 描述额外依赖。它的代价是首次构建要拉镜像、且必须能访问到镜像仓库。cross 覆盖不到的场景(例如需要特殊 vendor SDK 的嵌入式目标)才建议手配 .cargo/config.toml + cargo-zigbuild。

# Cross.toml —— 给某个 target 装上系统依赖 [target.aarch64-unknown-linux-gnu] # 在 cross 的容器里先装好 openssl 开发包再编译 pre-build = ["apt-get update && apt-get install -y libssl-dev"] image = "ghcr.io/cross-rs/aarch64-unknown-linux-gnu:main"

CI 四道门:fmt、clippy、test、deny

Rust 的 CI 比别的语言省心,因为工具链自带。四道门按"越快越靠前"的顺序插,让便宜的错误先失败。

.github/workflows/ci.yml(精简版)

name: CI on: [push, pull_request] jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 # 缓存 registry 与 target,省掉每次重新下载依赖 - uses: Swatinem/rust-cache@v2 - run: rustup toolchain install stable --component rustfmt,clippy # 门 1:格式。--check 不改文件,只汇报差异 - run: cargo fmt --all -- --check # 门 2:静态检查。-D warnings 把 warn 升级成 error,别让 warning 越积越多 - run: cargo clippy --workspace --all-targets --all-features -- -D warnings # 门 3:测试。--locked 保证用的就是提交进仓库的那份锁文件 - run: cargo test --workspace --all-features --locked # 门 4:依赖审计:安全公告、许可证、版本来源 - uses: EmbarkStudios/cargo-deny-action@v2

deny.toml:把"依赖治理"写成可执行的规则

[advisories] # 有 RUSTSEC 安全公告的依赖直接失败(也可按 id 例外,但要写理由和到期日) db-path = "~/.cargo/advisory-db" ignore = [] [licenses] # 只允许这些许可证进入依赖树,防止法务风险 allow = ["MIT", "Apache-2.0", "ISC", "BSD-3-Clause", "Unicode-3.0"] confidence-threshold = 0.9 [bans] # 同一个 crate 出现两个版本就报警(重复编译、体积翻倍) multiple-versions = "warn" # 禁止某些 crate 出现在依赖树里(例如历史遗留的 openssl-sys) deny = [{ name = "openssl" }, { name = "openssl-sys" }] [sources] # 只信任 crates.io,禁止依赖 git 分支(否则构建不可复现) unknown-registry = "deny" unknown-git = "deny" allow-registry = ["https://github.com/rust-lang/crates.io-index"]

论为什么 clippy 要配 -D warnings,以及它为什么不会烦死你

① 不设阈值,warning 会指数增长。一个项目只要有 50 条 warning,第 51 条没人会在意;反之如果 CI 上一条 warning 就红,每个人提 PR 时会顺手清掉自己引入的那条。这是"破窗效应"在工程上的直接体现。

② clippy 默认只报正确性/风格问题,不报主观偏好。真正带主观色彩的 lint 在 pedantic、nursery 组里,默认不开。所以 -D warnings 打到的通常都是"确实可以改"的东西,比如 needless_return、redundant_clone、clone_on_copy。

③ 必须允许局部豁免。确实不该改的地方用 #[allow(clippy::...)] 加一行注释说明理由,而不是把 -D warnings 整个降级成 warn——那等于把门禁拆了。门禁的价值在于"要么过、要么明确豁免",而不是"随便过"。

记
本章小结

① 拆 workspace 的时机是"第二个地方开始 import 第一块逻辑"时,不是项目第一天;路径用 crates/{core,infra,api},边界靠 Cargo.toml 强制。

② 版本只写一处:[workspace.dependencies] + member 的 { workspace = true };虚拟 workspace 记得写 resolver = "2"。

③ Cargo.lock:bin 必提交、lib 可提交且不影响下游;CI 一律 --locked。

④ feature 只增不减——这是 feature 组合爆炸不可回避的根源,所以设计时禁止让 feature 做减法。

⑤ build.rs 是代码生成器,不是启动脚本;写了就必须配 cargo:rerun-if-changed。

⑥ 门禁四件套 fmt --check / clippy -D warnings / test --locked / cargo deny,让廉价错误先失败。

小练习 · 五道工程化自测题(点开看答案)

1.(排错题)虚拟 workspace 根只写了 [workspace] members = [...],没写 resolver,会发生什么?

查看答案

会警告并使用旧的 resolver v1。根 Cargo.toml 如果没有 [package](虚拟 workspace),Cargo 无法从 edition 推断 resolver,默认回落 "1"。后果是 feature 解析规则仍是旧的"每个 crate 独立求交集",可能把本该统一开启的 feature 拆开,也可能编译出重复的依赖。补上 resolver = "2" 即可。

2.(概念题)member A 开了 tokio/full,member B 只想用最小的 tokio。B 能"禁用"A 打开的 feature 吗?

查看答案

不能。同一个 workspace(准确说是同一个依赖解析图)里 feature 求并集,full 一旦被任何地方打开,全局都开。想要隔离只能让两个会员依赖不同 major 版本的 tokio(不现实)或者拆成两个独立 workspace。

3.(操作题)把内部 crate 发布到 crates.io 时,依赖要怎么写?

查看答案

order-core = { path = "../core", version = "0.1.0" }。本地开发走 path,cargo publish 时 Cargo 自动剥掉 path、只留 version。若只写 path,publish 会报 no matching package named。另外发布顺序必须"被依赖者先发"。

4.(性能题)同事说"我什么都没改,cargo build 却每次都重新编译 build.rs",最可能的原因?

查看答案

没写 cargo:rerun-if-changed=...。Cargo 看不到 build.rs 的输入依赖时,会保守地每次构建都重跑它(并在依赖树变更时重新编译 crate)。补上输入文件的 rerun-if-changed 即可;cargo build -vv 可以看到 build.rs 的输出与重跑提示。

5.(部署题)为什么容器镜像里推荐 x86_64-unknown-linux-musl 而不是 -gnu?

查看答案

musl 是静态链接的 libc。编出来的二进制不依赖外部 glibc 动态库,运行时镜像可以用 scratch,镜像体积从几十 MB 降到个位数 MB,也彻底绕开"构建机 glibc 版本比运行机新"的经典事故。代价是某些依赖 glibc 特性的库(如部分 DNS 行为、getaddrinfo)行为略有差异,需要测试覆盖。