面向 10 万行以上、多 crate、多人协作的 Rust 工程。
每条规则都由两类依据支撑:(一) 业界共识(微软 Pragmatic Rust Guidelines、matklad 的 rust-analyzer 三篇、Roman Kashitsyn 的 IC 经验、corrode / Rust Project Primer,以及 Cargo Book 与 Rust Book); (二) 在一个 233,238 行真实 Rust 仓库上的实测——包括三条推翻了流行建议的测量结果。
标注约定:[实测] = 本文作者在真实仓库上跑过并计时;[共识] = 来自权威规范或多篇文章的一致结论;[推翻] = 实测结果与流行建议相反。完整出处见附录 C。
- 结构:单 crate 内按业务域组织模块;多 crate 必进 workspace,平铺在
crates/下;逻辑在 lib、入口在 main;crate DAG 要宽不要深。 - 测试:单元测试跟着代码走(大模块放到独立
tests.rs),集成测试合并成少数二进制且只测公共 API,内部 crate 关掉 doctest。 - 构建:先量化(
cargo build --timings)再优化;冷构建调 profile、热增量拆 crate、CI 另开一份(关 incremental、关 debuginfo)。
不能被机器检查的规则,会腐烂。
写在文档里的约定,半年后一定与代码不一致——不是因为团队不自律,而是因为没有任何东西会在它被违反的那一刻叫停。
本宝典每一部的末尾都给出该部规则如何变成 cargo xtask 门禁。规则与门禁是一对,只写规则等于没写。
一个真实的教训[实测]:某仓库的 ADR 明文规定「发布前必须运行 local-release-gates.sh 验证架构、测试、备份恢复」。该脚本在一次以重构 wire 层为目的的 30 文件删除提交中被顺带删掉,同批消失的还有 5 个部署测试套件。两份引用它的 ADR 都没有修订,此后数月无人察觉。
为什么:workspace 的核心价值不是「组织 crate」,而是共享 Cargo.lock + 共享 target/ 编译缓存。两个独立 package 各编一份依赖,是纯粹的浪费。
怎么做:
[workspace]
members = ["crates/*", "apps/desktop/src-tauri", "services/cloud"]
resolver = "3" # edition 2024 + 声明了 rust-version 时,resolver 3 是 MSRV 感知的,严格更优注意:虚拟清单不会从 edition 推断 resolver,必须显式写。微软 M-LATEST-EDITION 说「一般不必写 resolver」——那条只对带
[package]的根清单成立。写"2"是能用但过时的选择。
根 Cargo.toml 只有 [workspace] 段,没有 [package]。不要把主 crate 放在根上。
为什么:主 crate 一旦占据根位置,它就成了「默认的那个」,新代码会不断被塞进去,因为放别处需要额外决策。仓库最大的那个 crate 通常就是这么长出来的。
规则:所有 crate 平铺在 crates/ 下一级目录;超过 1–2 打 crate 才考虑分层。禁止把 crate 塞进另一个 crate 的 src/ 里。
为什么不嵌套:没有完美的层级;加新 crate 时要纠结放哪;树会随项目腐烂。rust-analyzer 的 32 个 crate 全部平铺。
为什么目录名必须等于 crate 名:cargo tree、编译错误、--timings 报告里出现的都是 crate 名。当目录叫 psd-x 而 crate 叫 psdx 时,你无法把报告里的一行映射回磁盘上的一个目录。
多语言单仓库的例外:apps/ services/ tools/ 这类顶层划分不是 Rust 拓扑决策——当 tools/ 下同时有 Rust crate 和 pnpm 包时,按部署角色划分是合理的。M-CRATES-FLAT-FOLDER 管的是 crate 的布局,不是多语言仓库的布局。把这个例外写进文档,而不是留给后人重新发现。
反例[实测]:某仓库的 services/cloud/migration 是一个独立 workspace 成员,却位于 services/cloud 这个 package 的目录内部。更糟的是主 crate 用 15 处 #[path = "../migration/src/*.rs"] 把它的源码再编译一遍——5,049 行里有 3,967 行被编译两次,一次进 libcloud_migration.rlib,一次进 libozon_cloud.rlib。
不发布的 crate 不需要维护 semver。用 [workspace.package] 继承:
[workspace.package]
edition = "2024"
rust-version = "1.97" # 库 crate 应声明 MSRV
version = "0.0.0"成员侧:
[package]
name = "my-crate"
version.workspace = true
edition.workspace = true
rust-version.workspace = true反模式:成员里硬编码 version = "0.1.0" / edition = "2024"。它们不会报错,只会在你需要统一升级时逐个绊你一下。
规则:业务逻辑放 lib.rs(及其模块树),main.rs 只做参数解析与调用。
为什么:这不是审美问题——集成测试(tests/)只能访问库的公共 API,无法 import main.rs 里的任何类型。逻辑写在 main.rs 里 = 这部分逻辑永远无法被集成测试覆盖。
标准形态:
// src/main.rs — 16 行是健康的
fn main() {
my_app_lib::run();
}
// src/lib.rs — 只有 mod 声明和一个入口
#![forbid(unsafe_code)]
pub mod domain;
pub mod infra;
pub fn run() { app::run(); }多个二进制放 src/bin/,每个文件自动成为独立目标。
操作性判据(proj-mod-by-feature):
删掉一个功能,应该等于删掉一个文件夹。
如果删除「优惠券」功能需要动 api/、service/、repository/、dto/ 四个目录,说明你是按技术层切的。
分层架构(API / service / repository)的失效方式[实测]:某 80,476 行的 crate 按技术层漂移后,测得——
- 持久化层(
store)反向 import 了 4 个业务域,29 处引用全部来自同一个文件的头部; - API 层(
commands)里藏着产品规则:一个「适配器」函数硬编码了「每次投递 1–15 张图」的业务上限; - 结果是 13 对互相依赖的模块、58 个 3–4 节点的环。
用上面的判据实测该仓库的 5 个功能,每个功能平均散落在 6 个顶层模块,无一收敛在单个文件夹内。
正确形态:
src/
├── domain/ # 业务域:无 SQL、无 HTTP、无 UI 框架类型
│ ├── listing/
│ ├── billing/
│ └── account/
├── infra/ # 可替换的适配器:可依赖 domain,反之不可
│ ├── store/ # 持久化
│ ├── http/ # 外部 API 客户端
│ └── secret/
└── ipc/ # 对外适配层:唯一允许出现框架 DTO 的地方
选型标准(常见五种模式):
| 模式 | 适用规模 | 判据 |
|---|---|---|
| 扁平 | < 1,000 行 | 单文件模块够用 |
| 分层(API/service/repo) | 中等,且领域简单 | 竖切,容易退化成上面的反例 |
| DDD 按业务域 | 大型系统首选 | 有明确的领域词汇表 |
| 插件(trait + registry) | 需要开放扩展点 | 没有真实扩展需求时不要用——投机抽象 |
模块命名也按业务域,不按技术角色。traits/、errors/、types/ 这种目录不帮任何人找到东西——它们是分层架构在模块名上的残留[共识:M-BALANCED-MODULES]。用户必须找到才能用的类型(Client、Draft)放 crate 根;其余按用例分组(account、network)。
(私有) → pub(super) → pub(crate) → pub(in path) → pub
每一个 pub 都是一个承诺。 公共 API 越小越好。
自查指标[实测]:健康的大型 crate,pub 与 pub(crate) 的比例应接近 1:1 或更保守。某仓库实测 1,579 个 pub 对 190 个 pub(crate),比例 8.3:1,其中两个模块(合计 10,763 行)一次都没用过 pub(crate)。
进一步测量:468 个可从 crate 外触达的 pub 条目中,169 个(36%)在自己模块之外从未被引用——纯粹的过度暴露。
最省力的修法:不要逐个条目改,先改 mod 声明。把 pub mod internal_thing; 降为 pub(crate) mod internal_thing;,其内部所有 pub 条目一次性被封住。
Rust 允许 crate 内部模块成环,禁止 crate 之间成环。
这个不对称是本条规则存在的全部理由:模块环不会报错,所以它会一直长,直到你想拆 crate 的那天才发现拆不动。
实测[实测]:某 80,476 行 crate 的 15 个顶层模块中,10 个处于同一个强连通分量,覆盖 65,761 行(占该 crate 81.7%)。这个仓库从未成功拆出过任何 crate——不是没人想,是没人能。
诊断方法(可直接抄):
# 提取模块间 crate::X 引用,构建邻接表,跑 Tarjan SCC
grep -rn "crate::[a-z_]*" src/ --include='*.rs' | ...破环方法[共识:Kashitsyn]:把共享类型抽到第三个模块/crate。
环几乎总是由「A 需要 B 的类型、B 也需要 A 的类型」造成。抽出 records / types / model 之类的叶子 crate,双方都依赖它,环立刻消失。
更便宜的破环:如果 A 只依赖 B 的类型名、不依赖 B 的字段定义,把那个类型改成关联类型,A 就不必再依赖 B——改 B 的字段不会再重编 A 及其下游[共识:Kashitsyn]。
判断哪些模块「今天就能拆」:算出度。出度为 0 的模块(只被别人依赖、不依赖别人)可以立即抽成 crate,不必等破环。
抽出之后立刻检查:新 crate 会不会变成反向依赖枢纽(见 1.11)。一个什么都依赖的 types crate,只是把环换成了全仓库重编。
反例[实测]:某仓库的「类目 Schema」模型曾被平行声明了三套(分属两个模块),代价是 208 行纯 From<> 转换代码,以及一个 2,418 行的「legacy」模型——它仅有的两个消费者,都在明文规定「不拥有」它的那个模块里。Desktop 现已收成 listing 拥有的一份 CategorySchemaSnapshot(listing/records.rs 类型体 + listing/schema.rs 行为)。
识别信号:当你写下第二个 impl From<AFoo> for BFoo 时,停下来问:为什么有两个 Foo?
深路径 use crate::a::b::c::d::Thing 常被当成结构过复杂的信号。这是对的,但不完整。
实测发现[实测]:某仓库 768 条 crate 内 use 的深度分布是 0:139 / 1:417 / 2:211 / 3:1 / 4+:0——路径深度问题几乎不存在。但同一个仓库里:
| 文件 | 行数 |
|---|---|
store/mod.rs |
8,768(其中单个 impl 块 3,493 行 / 79 个方法) |
psd.rs |
7,428(251 个类型声明) |
tests/postgres_http.rs |
7,069(23 个测试) |
结论:树很浅不代表结构健康。 当没有目录层级可用时,复杂度不会消失,它会变成文件长度。
经验阈值:单文件超过 1,000 行、单 crate 超过 5,000 行 就该审视一次。这不是硬性上限,而是「该停下来看一眼」的信号。
好消息:浅树意味着有拆分余量。加一层 store/repo/* 只会把最大深度从 2 推到 3,仍远低于警戒线。
一个 crate 最重要的属性,是它(传递地)不依赖哪些 crate。
编译是按 crate DAG 并行的。下面两条链的总代码量可以一样,墙钟时间差一个数量级:
# 慢:必须串行
A → B → C → D → E
# 快:公共词汇 + 互不依赖的功能 + 叶子拼装
┌→ feature_a ┐
vocab ────┼→ feature_b ├─→ app
└→ feature_c ┘
两种必须拆开的依赖枢纽:
| 枢纽 | 症状 | 修法 |
|---|---|---|
| 出度大(依赖很多) | 改它自己很慢;它挡住整张图的并行 | 按被测对象拆 test-utils,按功能拆 replica 式拼装 crate |
| 入度大(被很多依赖) | 改一行,半个仓库重编 | 让它小、让它稳;能用关联类型消掉的依赖不要变成真实 use |
组件接线优先运行时多态[共识:Kashitsyn]。Arc<dyn Interface> 比重泛型参数 Foo<A, B, C> 少一份单态化税,也少一份「改一个 impl 就重编所有接线方」的增量。编译期多态留给热路径上真正需要零成本的地方。
这是 3.9「crate 是重编译边界」在结构上的对称命题:拆 crate 只在 DAG 变宽时才赚到并行;拆成一条更深的链,冷构建只会更慢。
# ❌ 成员互相用相对路径
[dependencies]
sibling = { path = "../sibling" }
# ✅ 一律经 workspace 表
# 根:
[workspace.dependencies]
sibling = { path = "crates/sibling" }
# 成员:
[dependencies]
sibling.workspace = true手写 path 不会报错,但会让「到底用的是哪一份」无法从根清单一眼看清,也让 cargo tree / 架构门禁必须同时解析两种写法。
// xtask: 结构类门禁
check_no_orphan_modules() // 见 2.6,最重要的一条
check_module_direction() // 构建 crate::X 邻接矩阵,断言允许表
check_crate_direction() // 解析各成员 Cargo.toml,断言依赖方向
check_no_raw_path_deps() // 1.12 —— 成员间禁止 path = "..",必须 workspace = true
check_file_length() // 超过阈值的文件进白名单,只许减不许增| 层 | 位置 | 作用 |
|---|---|---|
| 单元 | #[cfg(test)] mod tests;(独立文件) |
覆盖内部逻辑,可访问私有项 |
| 集成 | tests/ |
只测公共 API,是契约的守卫 |
| 文档 | /// 里的代码块 |
保证示例不腐烂 |
| 属性 | proptest / quickcheck |
生成式覆盖输入空间 |
| 异步 | #[tokio::test] |
— |
| E2E | 独立目标 | 系统级 |
| 基准 | benches/ |
性能是需求时必备 |
健康配比:大量快速单元测试 + 适量保护公共契约的集成测试 + 少数系统测试。
最常被跳过的两层,和跳过的代价:
- 基准测试[实测]:某仓库在根
Cargo.toml用注释明确写着「PSD 光栅化含计算密集循环,故设opt-level = 3」——但仓库里benches/目录数量为 0。为一个从不度量的指标,支付一份永久的构建成本。 没有基准,你既不知道优化是否有效,也不知道哪天它被改回去了。 - 文档测试:公共 API 的可执行文档。某仓库 1,115 个单元测试对应 2 个可执行 doc test。对已发布的库这是缺口;对内部 crate 这是正确的税(见 2.10)。
// src/parser.rs —— 单元测试就放在被测代码下面(测试即文档)
pub fn parse(s: &str) -> Result<Ast> { ... }
#[cfg(test)]
mod tests {
use super::*;
#[test] fn parses_empty_input() { ... }
}大模块应优先用文件变体[共识:matklad]:
#[cfg(test)]
mod tests; // 指向同目录 tests.rs / parser_tests.rs只改测试时,Cargo 知道这份文件只在 --test 下参与编译,不会重编库本身。内嵌 mod tests { ... } 做不到这一点。#[cfg(test)] 门控绝不能忘。
绝不要:为了让测试能访问,把私有函数改成 pub(crate)。测试应当适应设计,而不是设计迁就测试。
这是 Rust 测试组织里最经典的陷阱:
tests/common.rs→ 被当作一个独立的集成测试二进制编译,你会看到一个跑了 0 个测试的空测试目标;tests/common/mod.rs→ 正确,作为模块被mod common;引入。
跳过它的代价[实测]:某仓库全仓库不存在任何 tests/common/——既没有正确形式,也没有错误形式,是从未尝试共享。结果:
| 辅助函数 | 逐字节重复的份数 |
|---|---|
fixture_psd |
14 |
solid |
6 |
json_eq / golden_json / clean_for_json |
各 5 |
user_auth.rs ✅ · auth.rs text_edit_m14.rs ❌ · ticket46_tests.rs ❌
为什么:_m14 编码的是「什么时候写的」,ticket46 编码的是「哪个工单要求的」——两者都不是「测了什么」。半年后想知道「文本编辑功能覆盖够不够」,你必须逐个打开文件。这直接摧毁了「测试即文档」的价值。
实测:某仓库有 19 个文件按冲刺/工单命名,其中 5 个 composite_*_m15 文件按冲刺而非行为切分,边界对读者完全不可预测。
tempfile、assert_cmd、mockall、serial_test、proptest、criterion 一律放 [dev-dependencies]。它们不会进入下游的依赖图。
注意:[dependencies] 里的东西对测试自动可见,所以在两处都写是冗余的,且会在下次升级时静默不同步。
并行测试共享状态:用 serial_test 而不是 --test-threads=1——后者会拖慢整个套件来迁就少数几个测试。
Rust Book 警告说「src/tests/ 目录会被静默忽略」。这只是更普遍规律的一个特例:
rustc 只编译从 crate 根经
mod声明可达的文件。任何其他.rs文件,编译器根本不会打开。
不报错、不警告、不提示。它在 git 里、在你的编辑器里、在 code review 里看起来完全正常——但它不存在。
实测后果[实测]:某仓库有 24 个文件、7,801 行 Rust 处于这种状态,其中包含 85 个测试函数。它们出现在 grep 统计的「2,505 个测试」里,实际执行数为 0。
最危险的两个:
api_key_client_never_sends_a_bearer_token
oauth_client_never_sends_seller_api_key_headers
有人以为这两条凭据隔离保证被测试守着。它们一次都没跑过。
精确对账方法(任何仓库都能跑):
grep -rn --include='*.rs' -E '#\[(tokio::)?test\]' src | wc -l # 源码里写了多少
cargo test --lib -- --list | grep -c ': test$' # 实际存在多少
# 两者之差 = 从未编译的测试数这条规则必须做成门禁,因为它是唯一一类「代码审查绝对看不出来」的缺陷:
/// 每个成员 src/ 下的 .rs 都必须能从 mod 声明、#[path] 或 cargo target 到达。
/// 不可达文件不会被编译、不会被 lint、不会被测试——它们读起来像已交付的代码,但不是。
fn check_no_orphan_modules(root: &Path, violations: &mut Vec<String>) { ... }问题的根源是真实的:tests/ 把库当外部依赖链接,所以 #[cfg(test)] 模块对集成测试不可见。于是人们把测试辅助改成 pub mod test_support;。
代价[实测]:某仓库两个这样的模块(合计 481 行)被编译进生产库——其中一个所在的 crate 是桌面应用的普通依赖,另一个所在的 crate 连 [features] 段都没有,无从关闭。
正确解法,按优先级:
tests/common/mod.rs——辅助代码留在测试树里,对库的公共 API 完全隐形。这正是 2.3 那条规则存在的理由。- 确实需要库内部能力时,用 单一
test-utilfeature 门控[共识:M-TEST-UTIL],且不给任何消费者启用:[features] test-util = []
#[cfg(feature = "test-util")] pub fn bypass_certificate_checks() { ... }
每个 tests/*.rs 都是一个独立的 crate,各自编译并链接整个被测库。
实测[实测]:某仓库 cargo test --no-run --workspace 需要 441 秒,产出 69 个测试二进制;而其中 1,334 个单元测试的实际运行时间是 0.11 秒。
编译耗时是运行耗时的约 4,000 倍。
这意味着:任何「让测试跑得更快」的努力都方向错了。 杠杆在构建图和二进制数量上。
所以拆分巨型测试文件时,要拆成模块而不是二进制:
tests/
└── cloud/
├── main.rs # 唯一的集成测试二进制
├── common/mod.rs # 共享辅助
├── auth_sessions.rs # mod,不是独立 crate
└── entitlements.rs
把一个 7,000 行文件拆成 24 个顶层 tests/*.rs,会新增 ~21 次针对最重依赖集的链接。拆成 24 个 mod 则零链接成本,且同样满足「按功能命名」。
Cargo 自身做过同样的合并:测试套件编译时间降 3×,磁盘产物降 5×[共识:matklad]。
内部 crate 更进一步:能用 #[cfg(test)] 单元测试讲清楚的,就不要再开集成测试二进制。集成测试的价值是「逼你走公共 API」——内部 crate 没有外部消费者,这份税交得不值。
已发布的库 crate 反过来:优先一个集成测试 crate(常叫 it),少写单元测试,让测试本身成为公共 API 的设计反馈。
反模式:
#[test]
fn my_db_test() {
let Ok(url) = std::env::var("DATABASE_URL") else { return }; // ❌ 静默通过
...
}CI 上环境变量没配 → 测试「通过」→ 没人发现覆盖率是假的。
两种可接受形态:
// (a) fail-loud:缺依赖就报错,并告诉人怎么修
let db = EphemeralDatabase::acquire()
.expect("PostgreSQL 未就绪,请运行 `cargo xtask db up`");
// (b) 显式忽略:cargo test -- --ignored 才跑
#[test]
#[ignore = "需要本地 PostgreSQL"]
fn my_db_test() { ... }(a)更好,因为它让「我以为跑了」变成「我知道没跑」。
每个文档测试都是一个独立二进制,要单独链接一次。内部 crate 没有「示例即文档」的外部读者,却按 crate 数缴纳这份税。
# 不发布的 crate
[lib]
doctest = false已发布、公共 API 稳定的 crate 保留 doctest——那是契约的一部分。内部 crate 的「2 个可执行 doc test 对 1,115 个单元测试」(见 2.1)不是覆盖不足,是不该付的编译税。
const CHECKPOINTS: [u32; 4] = [0, 90, 180, 270];
#[test]
fn checkpoints_are_correct() {
assert_eq!(CHECKPOINTS, [0, 90, 180, 270]); // ❌ 通过是因为抄了同一处
}这种测试通过是构造出来的。它增加噪声、在后续改动里制造假安全感,对行为零断言。
有意义的测法是测不在源码里写死的性质:间距相等、单调递增、越界被拒、与另一份权威数据一致。
AI 助手特别容易写出这种测试——它会把实现抄进断言。审查时看到「期望值与被测常量逐字相同」,直接删。
check_no_orphan_modules() // 2.6 —— 最高优先级
check_no_test_code_in_lib() // 2.7 —— 禁止非 feature 门控的 pub fn/mod test*
check_tests_common_shape() // 2.3 —— 禁止 tests/common.rs,禁止 src/tests/
check_no_silent_test_skip() // 2.9 —— 禁止测试体内因缺环境变量而 return
check_internal_doctest_off() // 2.10 —— 未发布 crate 必须 doctest = falsecargo build --workspace --timings # 产出 HTML 报告:每个单元的耗时与并行度
cargo check --workspace --timings # check 与 build 的瓶颈往往不同
cargo tree --duplicates # 被编译多次的 crate
cargo tree -i <crate>@<ver> # 反向追溯:谁把这个版本拖进来的
cargo tree -e features -p <crate> # feature 实际展开成了什么
cargo llvm-lines | head # 泛型单态化:哪份函数被实例化了多少次
RUSTFLAGS="-Zmacro-stats" cargo +nightly build # 哪个 proc-macro 生成了多少代码--timings 图里看三件事:绿条(正在编)够不够宽、红条(等 CPU)是不是堆在同一个 crate 后面、蓝条(等依赖)是不是一条长链。红/蓝都指向 1.11 的 DAG 形状,不是 profile。
没有 --timings 就动 profile,等于闭着眼睛调参。
诱人的反模式:
[profile.dev.package."*"]
opt-level = 3 # 「让依赖都跑快点」"*" 匹配每一个非 workspace 包,在 Cargo 里这包括你根本没想到的东西:
- 所有 proc-macro crate——它们是编译期运行的宿主程序,不产出任何交付代码;
- 所有构建脚本及其依赖——
build.rs里的cc会继承OPT_LEVEL,把捆绑的 C 源码按-O3编译; - 整个服务端 / TLS / ORM / GUI 栈——与你想加速的计算路径毫无关系。
实测归因[实测],某仓库依赖构建 CPU 的分布:
| 桶 | 占比 |
|---|---|
| proc-macro + 构建依赖宿主工具 | 38.3% |
| 重复版本 crate | 10.6% |
真正需要 opt-level = 3 的计算集 |
5.0% |
为了让 5% 变快,给 38% 的代码生成工具链交了税。
正确做法:枚举真正在热路径上的包。
[profile.dev]
opt-level = 0
# 只给计算热路径开优化——从 `cargo tree -p <你的计算 crate>` 推导出这个清单
[profile.dev.package.image] { opt-level = 3 }
[profile.dev.package.flate2] { opt-level = 3 }
[profile.dev.package.rayon] { opt-level = 3 }
# ... 通常 20–30 个包,而不是 600 个这是本宝典最反直觉的一条,也是最容易踩的坑。
按 3.2 删掉 "*" 之后,你会顺带把所有 proc-macro 也降到 opt-level = 0——因为它们本来就是靠 "*" 才被优化的。
实测结果[实测]:
| 配置 | 冷 cargo check --workspace |
|---|---|
现状("*" = 3) |
254.9 s |
| 只删通配符 | 269.2 s ← 更慢了 |
删通配符 + build-override |
75.8 s |
只做一半的「优化」是净倒退。
原因:derive 宏的执行速度在大型仓库里是一等成本。某仓库有 1,771 个 Serialize + 1,916 个 Deserialize + 403 个 TS derive 要展开——未优化的 syn 慢,慢在每一次编译。
正确写法(两半必须在同一个 commit 里):
[profile.dev]
opt-level = 0
# ★ 编译期运行的东西保持优化:proc macro、build script 及其依赖
[profile.dev.build-override]
opt-level = 3
debug = false
# 运行期热路径按包点名
[profile.dev.package.image]
opt-level = 3记忆口诀:build-override 管编译时跑的代码,package.<name> 管运行时跑的代码。两者是正交的,缺一不可。
这是最容易误导人的地方。 同一个 profile 改动:
| 场景 | 效果[实测] |
|---|---|
冷 cargo build --workspace |
278.9 s → 159.0 s |
冷 cargo check --workspace |
277.9 s → 75.8 s(3.7×) |
| 热增量(改一个文件重编) | 19–26 s → 19–26 s,无差异 |
所以先问:你的痛点到底是哪个?
| 痛点描述 | 真正的杠杆 |
|---|---|
| 「新克隆 / 切分支 / CI / Docker 构建要好几分钟」 | profile(3.2 + 3.3),一次清单编辑,2–4 倍 |
| 「保存一次要等 25 秒」 | 拆 crate(3.9)。profile 对此完全无效 |
热增量的耗时由你正在编辑的那个 crate 有多大决定。一个 80,476 行的 crate,无论 opt-level 设什么,改一行都要重编它自己。
流行建议是「Linux 用 mold、macOS 用 lld」。在 macOS 上,这条建议今天是错的。
实测(M4 Pro / macOS 26 / Apple ld PROJECT ld-1267,同一条链接命令各跑 3 次):
| 二进制 | Apple ld-prime(默认) | ld_classic |
Rust 自带 ld64.lld |
|---|---|---|---|
| 34 MB 桌面应用 | 0.298 s | 0.523 s | 0.495 s |
| 36 MB 服务端 | 0.178 s | 0.366 s | 0.310 s |
- 默认最快;
ld64.lld慢 1.7 倍。 mold根本不支持 Mach-O(Mach-O 支持在已停止维护的sold里)。- 链接占约 25 秒增量重建的 0.2–0.3 秒——约 1%,天花板本来就很低。
Apple 在 Xcode 15 引入的新链接器(ld-prime)已经很快了,早年「macOS 链接慢」的经验已经过期。
同理,2021 年「split-debuginfo = "unpacked" 让 macOS 增量快 70%」这条也过期了:对 debug 非零且走增量的 dev profile,unpacked 早已是 macOS 默认值。写进清单不会坏,但也不会再给你那 70%。
规则:链接器 / debuginfo 优化在 Linux 上通常有效,在现代 macOS 上通常无效。无论哪种情况,测了再改,并把测量结果写进 .cargo/config.toml 的注释里,避免后人反复争论。
# ❌ 把整个 tokio 拖进来
tokio = { version = "1", features = ["full"] }
# ✅ 只要用得到的
tokio = { version = "1", default-features = false, features = ["rt-multi-thread", "macros", "time"] }关掉 tokio 的默认 features 实测可省约 23% 编译时间(corrode 引用的测量)。
必查项:
default-features = false+ 按需列举,对所有重型依赖(tokio、reqwest、sea-orm、image)。- 库 crate 的默认 feature 会在 workspace 构建里生效。若你的库
default = ["native-tls"]而所有消费者都显式选rustls-tls,那么cargo build --workspace会把两套 TLS 栈都编译一遍[实测]。 - 用
cargo tree -e features验证展开结果,不要靠推测。 cargo machete/cargo udeps找完全没用到的依赖。- 同一依赖在不同成员上启用不同 feature,会被编多次。这是
[workspace.dependencies]管不了的——它只统一版本,不统一 feature 展开。cargo-hakari(workspace-hack crate)专门收这个问题;先用cargo tree -e features确认真有分裂,再引入,不要预防性加一层。 - 共享类型 crate 不要无条件
derive(Serialize, Deserialize)。serde 及其宏会成为入度枢纽上的编译瓶颈:每个只用到类型、不做序列化的下游都得等它。把 derive 收进可选 feature,只在真正编解码的叶子 crate 打开[共识:corrode]。
#[cfg(debug_assertions)]
mod pg_backend; // 源码被门控了postgres = "0.19" # ❌ 但依赖没有!release 构建照样编译整个 postgrescfg 属性拦不住 Cargo 编译一个已声明的依赖。 上例中 release 二进制会链接一个永远到不了的 PostgreSQL 客户端[实测]。
正确写法:
[features]
dev-backend = ["dep:postgres"]
[dependencies]
postgres = { version = "0.19", optional = true }#[cfg(feature = "dev-backend")]
mod pg_backend;cargo clean 摧毁增量缓存,下一次构建从零开始。
但 target/ 确实会失控[实测]:某仓库的 target/ 达到 239 GB / 608,180 个文件,其中 incremental/ 108 GB。这不只是磁盘问题——实测一次后台文件系统回收让一个 137 秒的构建膨胀到 280 秒。
正确做法:
cargo install cargo-sweep
cargo sweep --time 15 # 清理 15 天未使用的产物,保留活跃缓存改动一个 crate 里的任何一行,整个 crate 重编。 这是 crate 拆分最实际的理由。微软的口令是「拿不准就拆」:一个子模块能独立使用,就应该是独立 crate。
经验值:单 crate 控制在 5,000 行以内(corrode / rustprojectprimer)。
推论:常改的代码与稳定的代码要分开。 把每天都动的 config 和几乎不动的 core 放同一个 crate,等于让 core 每天重编一次。
拆完立刻看 1.11:拆成宽 DAG 才赚并行;拆成更深的链,冷构建更慢。这也是 3.4 里「热增量唯一的杠杆」——但它是多天的结构工作,不是一次清单编辑。排期时要诚实对待这个成本差异。
sccache 与 CARGO_INCREMENTAL=1 不兼容。在单台开发机上,它通常打不过 cargo 自己的增量缓存。
它真正有价值的场景:CI(每次都是冷缓存)、或多台机器共享缓存。
没有 CI 时,装它是负收益。 先建 CI,再谈 sccache。
[profile.dev]
opt-level = 0
debug = 1 # 行号表:够用于 backtrace,比完整 DWARF 生成/链接都快
incremental = true
codegen-units = 256 # 保持重编译的爆炸半径小
# ★ 编译期运行的代码保持优化(见 3.3,与下面的 package 覆盖是一对)
[profile.dev.build-override]
opt-level = 3
debug = false
# 运行期计算热路径(见 3.2,按 cargo tree 推导,通常 20–30 个)
[profile.dev.package.image]
opt-level = 3
# 迭代非计算代码时用:cargo build --profile fast
[profile.fast]
inherits = "dev"
debug = 0
[profile.release]
opt-level = 3
lto = "thin" # release 用 thin LTO;dev 绝不开 LTO
codegen-units = 1
strip = "symbols"
incremental = false
# 打包冒烟用,不必等 1-CGU
[profile.release-fast]
inherits = "release"
lto = false
codegen-units = 16
panic = "abort"能进一步减小体积,但会改变运行时语义——任何依赖catch_unwind的代码都会失效,且测试需要 unwind。默认不要加。
3.4 把冷构建和热增量拆开了。CI 是第三种:每次都接近冷构建,但还要缓存、还要出可比较的数字。
CI 上该关的东西,本地不该关:
# 只给 CI 用的 profile,或在 CI 脚本里用环境变量覆盖
[profile.ci]
inherits = "dev"
incremental = false # 冷构建上增量是纯开销,还把 target/ 撑大、把缓存打废
debug = 0 # debuginfo 是 target/ 膨胀的主因;要行号用 "line-tables-only"# CI 脚本
export CARGO_INCREMENTAL=0
export RUSTFLAGS="-D warnings" # 见 5.3:只在 CI 拒警告,绝不写 #![deny(warnings)]
cargo test --workspace --no-run # 先看编译耗时
cargo test --workspace # 再看运行耗时不要把整个 target/ 丢进 CI 缓存。 缓存该留的是很少变的依赖,不是每次 PR 都变的本仓库 crate。缓存整棵 target/ 又大又脏,命中率差,还拖慢缓存读写。用 Swatinem/rust-cache 或自己按「依赖 / 本仓库」切开。
CI 是标准化基准。 本地增量随改动类型乱跳,不能用来判断「仓库是不是越来越慢」。每次 PR 的 CI 墙钟时间才是可比较的时间序列。
| 习惯 | 收益 |
|---|---|
用 cargo check 而非 cargo build 迭代 |
跳过 codegen,通常快数倍 |
cargo-nextest 跑测试 |
进程级并行 + 每测试计时;也修掉 Cargo 串行跑多个测试二进制的问题(见 2.8) |
[workspace] default-members 排除探针/示例 crate |
裸 cargo build 不再编译它们[实测:一个 50 行的探针 crate 可以拖进整整一套 HTTP 栈] |
Docker 构建用 cargo-chef 或 BuildKit cache mount |
否则每次镜像构建从零重编所有依赖 |
target/ 放内置 SSD |
外置卷实测写入带宽约为内置的一半 |
| macOS:把终端加入 Developer Tools | 免去 Gatekeeper 对每个新编出来的二进制做一次公证检查(corrode / Zed) |
check_no_wildcard_opt_level() // 3.2 —— 禁止 [profile.*.package."*"]
check_build_override_paired() // 3.3 —— 有 package 覆盖就必须有 build-override
check_no_default_features() // 3.6 —— 重型依赖必须显式 default-features = false# 根 Cargo.toml —— 只钉版本和 default-features,不在这里开业务 feature
# [共识:M-CARGO-WORKSPACE]workspace 表开 feature 会让所有成员被动继承
[workspace.dependencies]
serde = { version = "1.0.229", default-features = false }
tokio = { version = "1.47.1", default-features = false }# 成员 —— 谁用谁开
[dependencies]
serde = { workspace = true, features = ["derive"] }
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }收益的诚实说明 ★[实测]:
流行说法是「统一版本能大幅提速,因为每个重复版本都要多编一次」。这个说法需要归因验证。
某仓库实测:Cargo.lock 有 788 个包版本,30 个 crate 被编译 2–4 次。但逐个用 cargo tree -i 反查后发现,绝大多数重复是上游传递依赖强制的(例如 syn 2 和 syn 3 同时存在,因为不同的宏库各自依赖)——你无法通过统一自己的清单来消除它们。
真正由自己造成、可消除的重复只有少数几个。
所以 [workspace.dependencies] 的首要价值是治理,不是速度:
- 它把「23 个 crate 在 10 处各自声明、19 处写法不一致」变成一处;
- 它阻止下一次
cargo update引入新的分裂。
照实说,不要夸大。 夸大的收益承诺会在没兑现时损害整个方案的可信度。
# 根
[workspace.lints.rust]
unsafe_code = "deny"
unused_must_use = "deny"
unreachable_pub = "warn" # 对应 1.7 的「pub 收敛」
[workspace.lints.clippy]
todo = "deny"
unimplemented = "deny"
dbg_macro = "deny"
# 其余按 5.3 棘轮:今天违规数为 0 的才能 deny# ★ 每个成员必须显式启用,否则整张表无效
[lints]
workspace = true采纳策略见 5.3——直接上会被回滚。
在有 [workspace.dependencies] 的前提下,= 通常是多余的:workspace 表已经给了单点控制,再叠一层 = 只会让「某成员用 =1.2.3、另一成员用 ^1.2.3」这种分裂更隐蔽。
真正需要 = 的场景:该依赖的 patch 版本曾经引入过破坏(如 tauri 这类与构建产物强绑定的框架)。这时要写注释说明原因,否则后人不敢动也不知道为什么。
[workspace.package]
edition = "2024" # 新 crate 一律用最新 edition
rust-version = "1.97" # 库 crate 必须声明
[workspace]
resolver = "3" # MSRV 感知;虚拟清单不会自动推断,必须显式写# rust-toolchain.toml
[toolchain]
channel = "1.97.1"
profile = "minimal"
components = ["clippy", "rustfmt"] # ★ minimal 不含它们!★ profile = "minimal" 不安装 clippy 和 rustfmt[实测坑]。你本机有,只是因为 stable 工具链带了。新克隆的贡献者今天加的 lint 门禁会静默不运行。 加门禁时必须同一次改动补上 components。
库 crate 的任意 feature 组合都必须能编过。这不是风格,是 Cargo 的特征统一(feature unification):workspace 里只要有一个成员开了 foo,其他成员也会看到 foo。
推论:
- 不要做
no-stdfeature,做stdfeature。 - 打开一个 feature 不得删除或改语义已有的公共项。
- feature 不得要求调用方再手动打开另一个 feature。
- 互斥 feature(
native-tls/rustls-tls)是已知的痛,必须在文档里写清楚,并用cargo hack --feature-powerset在每夜任务里验。
3.6 里「库的默认 feature 会在 workspace 构建里生效」就是这条规则的实测特例。
Cargo.toml 写的是你以为自己依赖了什么。Cargo.lock 写的是二进制里真正有什么。
定期打开 lockfile,对每一个直接或传递依赖问:它对坐在产品前面的人解决了什么问题?rust-analyzer 曾经因为日志库的过滤表达式拖进整份 regex——他们有精确的 Rust / Markdown 解析器,运行时根本不需要正则。
同一份 lockfile 也能抓住「隐式全局 + 重复版本」这种运行时幽灵:IC 的两个包分别链了 prometheus 0.9 和 0.10,二进制里出现两份全局 registry,一半指标永远看不见[共识:Kashitsyn]。显式传递 logger / metrics / runtime,这种 bug 会变成编译错误。
改 feature、给上游发 PR、或换一个更瘦的库,通常就够了。先读 lockfile,再谈「我们依赖不多」。
# .cargo/config.toml
[alias]
xtask = "run -p xtask --"为什么不用 Makefile/脚本堆:xtask 是 Rust 代码——跨平台、能被类型检查、能被测试、能复用 toml/serde 等库解析清单。
但要分清边界:进程监管、数据库集群生命周期、SSH/Docker 传输、代码签名与打包,这些天然是 shell 形状的,用 Rust 重写只会更糟。
判据:「检查」进 xtask,「编排」留给 shell。 而且 shell 应当调用 cargo xtask ci,而不是自己重新实现检查。
| 层 | 时间预算 | 内容 |
|---|---|---|
| pre-commit | ≤ 6 s | fmt --check、生成物 --check、版本一致性、架构门禁 |
| pre-push | ≤ 3 min | 上层全部 + clippy(只管 deny 集)+ test + 前端检查 |
| 发布门禁 | 不限 | 上层全部 + 端到端契约 + 备份恢复演练 |
| 每夜 | 非阻塞 | lint 存量趋势、tree --duplicates、audit、--timings、cargo hack --feature-powerset |
关键洞察[实测]:某仓库的 4 条既有检查合计 5.2 秒、全部通过、且无人调用——cargo xtask architecture 在全仓库的出现位置只有 .md 文档和它自己的 usage 字符串。
最高性价比的工程改进,往往不是写新检查,而是把已经写好、已经通过的检查接上变更路径。
一张会让几百处报错的 lint 表,第一天就会被回滚。
正确顺序:
- 落表:所有
deny项选今天违规数为 0 的规则;真实存量一律先设warn并在注释里记录基数。 cargo clippy --fix机械清除(通常能清掉 30–40%)。- 找根因:某仓库 499 处告警中,
result_large_err一条占 163 处——这不是 163 个缺陷,而是一个设计事实(某个被广泛返回的Result其Err变体过大)。在根上装箱一次,163 处一起消失。 - 清一条,就在同一个 commit 里把它从
warn翻成deny。
永远不要在源码里写 #![deny(warnings)]。 它会在 rustc 升级时把新 lint 变成全仓库红灯,也会让下游用户的编译无故失败[共识:matklad]。
CI 上用环境变量即可(见 3.12):
RUSTFLAGS="-D warnings" cargo test --workspace本地保持 warn,逐条棘轮翻成 deny。门禁才不会静默倒退。
有些团队有正当理由不用托管 CI(成本、合规、构建产物本来就在本地)。这个决定可以尊重,但必须回答:那么检查在哪里跑?
# .git/hooks/pre-commit —— 由 `cargo xtask hooks --install` 写入
#!/bin/sh
exec cargo xtask ci --tier 1没有 CI 又没有 hooks = 没有任何东西是自动的,一切依赖人记得。而人一定会忘——序言里那个被顺带删掉的发布门禁就是这么消失的。
实测[实测]:某仓库文档里有 30 条明文规则,逐条核对后——
| 状态 | 条数 |
|---|---|
| 完全有自动检查 | 8 |
| 部分检查 | 6 |
| 仅存在于散文里 | 16 |
且 7 处文档自相矛盾,包括:两份 ADR 引用一个已被删除的脚本;同一个 README 相隔 99 行自我否定;两份 ADR 记载了一个不存在的子命令。
规律:有门禁的规则一直成立,没门禁的规则全部漂移。 这不是团队素质问题,是机制问题。
推论:写规则时就要问「这条怎么检查」。答不上来的规则,要么改写成可检查的形式,要么承认它只是建议。
每条事实只有一个「文档之家」:
- 目录树 / 所有权 / 依赖方向 → 布局文档
- 长期架构选择 → ADR
- 命令 / 构建 / 部署 → README
- 大 bug 的根因 → 错误日志文档
索引文档应当链接,而不是复述。 复述出来的那份,就是半年后与代码不一致的那份。
可门禁化的部分:
check_layout_sync() // 布局文档里列出的路径必须都存在;每个 workspace 成员必须被列出
check_docs_home() // 按改动区域要求对应文档在同一提交范围内被更新□ 虚拟根清单 + resolver = "3"
□ [workspace.package] edition / rust-version / version = "0.0.0"
□ [workspace.dependencies] —— 从第一个共享依赖开始就用
□ [workspace.lints] + 每个成员 [lints] workspace = true
□ rust-toolchain.toml 含 components = ["clippy", "rustfmt"]
□ crates/ 平铺,目录名 = crate 名
□ 薄 main + 厚 lib
□ tools/xtask crate + .cargo/config.toml 的 alias
□ [profile.dev] opt-level=0 + build-override opt-level=3
□ tests/common/mod.rs(第一个集成测试就建)
□ 内部 crate `[lib] doctest = false`
□ benches/(如果性能是需求)
□ git hooks 或 CI —— 第一天就接上;CI 关 incremental、关 debuginfo
cargo build --workspace --timings # 构建瓶颈
cargo tree --duplicates # 重复版本
cargo tree -e features -p <crate> # feature 实际展开
cargo machete # 无用依赖
cargo clippy --workspace --all-targets # lint 存量(趋势只许降)
cargo test --workspace -- --list | wc -l # 与 grep 计数对账(见 2.6)
cargo hack --feature-powerset -p <lib> # feature 组合是否都能编(见 4.5)
du -sh target/ # 超过 ~50 GB 就 cargo sweep| # | 反模式 | 后果 | 规则 |
|---|---|---|---|
| 1 | [profile.dev.package."*"] opt-level = 3 |
为 5% 的收益给 38% 的工具链交税 | 3.2 |
| 2 | 删通配符但不加 build-override |
比不改还慢 | 3.3 |
| 3 | 照抄「macOS 换 lld」 | 性能倒退;mold 根本不支持 Mach-O | 3.5 |
| 4 | 文件不在 mod 图里 |
代码与测试静默失效,review 看不出来 | 2.6 |
| 5 | pub mod test_support; |
测试代码进生产库依赖图 | 2.7 |
| 6 | tests/common.rs |
被当成空测试二进制 | 2.3 |
| 7 | 测试因缺环境变量 return |
覆盖率是假的 | 2.9 |
| 8 | 把 7,000 行测试拆成 24 个顶层 tests/*.rs |
新增 ~21 次重量级链接 | 2.8 |
| 9 | cfg(debug_assertions) 门控源码但依赖非 optional |
release 仍编译并链接 | 3.7 |
| 10 | 按技术层切模块 | 删一个功能要动 6 个目录 | 1.6 |
| 11 | 持久化层 import 业务域 | crate 永远拆不出来 | 1.8 |
| 12 | 同一概念声明多套 + From<> 互转 |
转换代码成为永久税 | 1.9 |
| 13 | 测试文件按冲刺号/工单号命名 | 摧毁「测试即文档」 | 2.4 |
| 14 | cargo clean 清空间 |
摧毁增量缓存 | 3.8 |
| 15 | 没有 CI 的仓库装 sccache | 负收益 | 3.10 |
| 16 | 源码里 #![deny(warnings)] 或一次性上 -D warnings |
rustc 升级即全红;或第一天被回滚 | 5.3 |
| 17 | 写了门禁但没有任何入口调用 | 等于没写 | 5.2 |
| 18 | profile = "minimal" 却依赖 clippy 门禁 |
新贡献者处静默不运行 | 4.4 |
| 19 | 成员间手写 path = "../sibling" |
依赖图有两套写法,门禁漏检 | 1.12 |
| 20 | 抽出一个什么都依赖的 types crate |
环没了,全仓库重编还在 | 1.11 |
| 21 | 内部 crate 保留 doctest | 每个示例单独链接一次,无人读 | 2.10 |
| 22 | 测试断言抄被测常量 | 通过是构造出来的 | 2.11 |
| 23 | CI 缓存整个 target/、开着 incremental |
缓存又脏又大,冷构建更慢 | 3.12 |
| 24 | workspace 表里给 serde 开 derive |
所有成员被迫编 serde 宏 | 4.1 |
| 25 | 照抄「macOS 设 split-debuginfo = unpacked」 | 早已是默认值,零收益 | 3.5 |
规范与长文(按本文引用密度):
| 来源 | 本文用到的部分 |
|---|---|
| 微软 Pragmatic Rust Guidelines(2026.6) | M-CARGO-WORKSPACE、M-CRATES-FLAT-FOLDER、M-CRATES-IN-WORKSPACE、M-LATEST-EDITION、M-MSRV、M-SMALLER-CRATES、M-BALANCED-MODULES、M-INTEGRATION-TESTS、M-TEST-UTIL、M-FEATURES-ADDITIVE、M-TAUTOLOGICAL-TESTS、M-STATIC-VERIFICATION |
| matklad Large Rust Workspaces | 虚拟根清单、平铺、0.0.0、xtask |
| matklad Fast Rust Builds | --timings、宽 DAG、读 lockfile、CI 关 incremental / debuginfo、不要缓存整棵 target/、不要 #![deny(warnings)] |
| matklad Delete Cargo Integration Tests | 合并集成测试二进制、内部 crate 关 doctest、测试放到独立文件以免重编库 |
| Roman Kashitsyn Rust at scale(IC,~35 万行) | 依赖枢纽、关联类型破环、运行时多态接线、显式传递 logger/metrics |
| corrode Tips For Faster Rust Compile Times(2026-03 更新) | feature 裁剪、cargo llvm-lines、-Zmacro-stats、serde 下沉到叶子、Gatekeeper、cargo-hakari |
| Rust Project Primer | 组织与耦合、指向以上长文的阅读路径 |
| Rust Book ch11 / Cargo Book | tests/common/mod.rs、集成测试即独立 crate、profile 语义 |
| Leapcell Mastering Large Project Organization in Rust | workspace 共享 lock / target/;resolver 仍写 "2",已被本文 1.1 覆盖 |
本文明确不收、或收了但降级为「先测」的流行建议:
- 「macOS 换 lld / mold」——3.5 实测推翻;mold 不支持 Mach-O。
- 「只删
opt-level = "*"」——3.3 实测比不改更慢。 - 「
[workspace.dependencies]能大幅提速」——4.1 实测主要是治理,不是速度。 - 「
split-debuginfo = "unpacked"让 macOS 快 70%」——已是默认值。 - 微软 M-LATEST-EDITION「一般不必写 resolver」——对虚拟清单不成立。
- cranelift / watt / dylib 包装依赖——实验性,未在本仓库测量,不写进规则。
实测来源:一个 233,238 行 Rust + 53,100 行 TypeScript 的真实生产仓库(10 个 workspace 成员,edition 2024,rustc 1.97.1),在 Apple M4 Pro / 12 核 / 24 GB 上测量。测量方法与可靠性约束:
- 冷构建全部使用全新的
CARGO_TARGET_DIR,/usr/bin/time -p计时; - 会话内绝对耗时存在漂移(一次后台文件系统回收让同一构建从 137 s 膨胀到 280 s),因此所有比值均取背靠背 A/B 配对,不跨时段比较绝对值;
- 链接器对比通过捕获 rustc 真实链接命令后原地重跑,每种链接器 3 次;
- 孤儿模块结论用两种独立方法交叉验证(rustc dep-info 差分 vs
mod可达性扫描),并与仓库自有的一份人工审计逐字吻合。
本宝典中标注[推翻]的三条,是实测结果与流行建议相反的地方:换链接器(3.5)、只删 opt-level 通配符(3.3)、以及 [workspace.dependencies] 的提速幅度(4.1)。
收录它们,是因为一本只会复述网络建议的宝典没有价值。 遇到与本文冲突的建议时,在你自己的仓库上测一遍——这正是规则 3.1 的意义。