Cargo workspace 与工程化:从"能跑"到"能交付"
前面二十章你写的都是"一个 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 目录结构
根 Cargo.toml(虚拟 workspace:只有 [workspace],没有 [package])
member crate 只写差异
常用命令记住三条就够: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 单独跑一套"最低版本"任务。 |
论为什么"全 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。
发布前 Cargo 会做几件你容易忘的事:检查 license 或 license-file、检查 description、按 include/exclude 或 .gitignore 收敛要打包的文件、把 dev-dependencies 从发布清单里去掉。团队里最常见的翻车是"本地能编,发出去的包少了一个目录",原因就是 .gitignore 里排掉了某个 assets/ 而在 include 里没补回来。
临时想用 fork 或本地补丁覆盖某个依赖,不要改依赖者的写法,用根 Cargo.toml 的 [patch]:
报错长这样: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
代码里怎么根据 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 里把三种极端组合都跑一遍
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 里真正必需的那几项补回来:
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 被编译成宿主平台的二进制(你在 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,需要自己装链接器)
用 cross 一条命令搞定(内部跑 Docker,自动带好链接器与 QEMU)
cross 的价值在于它把一个"环境问题"变成了"一条命令":容器里预置了各种 target 的交叉链接器、pkg-config、常见 -sys 库,还能通过 Cross.toml 描述额外依赖。它的代价是首次构建要拉镜像、且必须能访问到镜像仓库。cross 覆盖不到的场景(例如需要特殊 vendor SDK 的嵌入式目标)才建议手配 .cargo/config.toml + cargo-zigbuild。
CI 四道门:fmt、clippy、test、deny
Rust 的 CI 比别的语言省心,因为工具链自带。四道门按"越快越靠前"的顺序插,让便宜的错误先失败。
.github/workflows/ci.yml(精简版)
deny.toml:把"依赖治理"写成可执行的规则
论为什么 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)行为略有差异,需要测试覆盖。