前七篇里你的对手一直是编译器。从这篇起,对手换成了项目本身:代码从三个文件长成三百个,“放哪”“给谁看”“怎么拆”开始比“怎么写”更花时间。大多数语言在这里递给你四五个工具——构建系统、包管理器、linter、文档生成器、测试运行器,各自为政;Rust 递给你 cargo 一个命令,而且整个生态用同一套约定。第 1 篇说工具链是“已解决的问题”,本篇把这句话展开成你每天的工作方式:模块怎么组织、可见性怎么开、大项目怎么拆、条件编译怎么做,以及那几个教程从不细讲、但你工作日每分钟都在用的命令。
Crate 与模块树:文件不会自动加入编译
先摆平三个被混用的名词。Package 是 Cargo.toml 管辖的单元,是发布和构建的单位;crate 是编译单元——一个 package 里最多两个:二进制 crate(根是 src/main.rs)和库 crate(根是 src/lib.rs),两者可以共存,这也是惯用布局;模块(module) 是 crate 内部的树状组织,节点由 mod 声明接线。
最后一个词值得重读:文件不会自动加入编译。 新建 src/net.rs 什么都不发生,直到某个祖先写下 mod net;——模块树是声明出来的,不是目录扫描出来的。这套显式接线和第 3 篇的生命周期、第 7 篇的 use<> 是同一个哲学:重要的关系都要落在纸面上。
一个可以照抄的最小惯用布局:
// src/lib.rs —— 库的门面
pub mod config; // 公开模块:库的 API 的一部分
mod net; // 私有模块:实现细节,外面看不见
pub fn run() {
net::connect(&config::load());
}
// src/config.rs
pub struct Config { pub url: String }
pub fn load() -> Config {
Config { url: "https://api.internal".into() }
}
fn secret_sauce() {} // 模块外不可见——默认就是私有
// src/net.rs
use crate::config::Config;
pub(crate) fn connect(cfg: &Config) { // crate 内任意处可用,库外不可见
println!("connecting to {}", cfg.url);
}
// src/main.rs —— 二进制只是库的薄壳
fn main() {
mytool::run();
}
可见性是一架三级阶梯:私有(默认)→ pub(crate) → pub。工程习惯是“开到刚好能编译的最小档”——每升一级,都是一份对外的承诺。从二进制里调用 mytool::net::connect 会被干脆拒绝(error[E0603]: module net is private),这正是你想要的:库的边界由编译器把守,重构内部实现不用担心砸到下游。路径写法上记住两个前缀就够:crate:: 从当前 crate 的根出发(库内引用一律用它),use 把长路径引进作用域。
Workspace:一个仓库,多个 crate
单个 package 装不下的时候——一个服务拆成协议层、核心逻辑、命令行外壳;一组共享内部库的微服务——就是 workspace 的领地。组织原则一句话:crate 保持小而专注,workspace 让它们步调一致。
# shop/Cargo.toml —— workspace 的根,本身可以没有代码
[workspace]
resolver = "3"
members = ["crates/core", "crates/api"]
[workspace.dependencies]
mytool = { path = "../mytool" }
三个立即兑现的好处:
- 一份
Cargo.lock,一个依赖图。 所有成员 crate 的依赖版本在整个 workspace 里统一解析——不会出现 api 用 serde 1.0.188 而 core 用 1.0.190 的幽灵分裂。 - 一个共享的
target/。 构建缓存全家共用:core 编译一次,api 直接复用。大项目里这是分钟级与秒级的差别。 [workspace.dependencies]一处定版。 成员里写mytool = { workspace = true }继承根部的定义,升级版本只需要改一行。
日常操作全部从根目录发起:cargo run -p api(点名跑某个成员)、cargo test --workspace(全量测试)、cargo build --release。什么信号提示该拆 workspace?当一个模块有了自己的错误类型、自己的测试套件、和“换了实现别人也不该关心”的边界——第 4 到第 7 篇教你的所有边界意识,在这里放大成项目结构。
Feature flags:只增不减的开关
条件编译在 Rust 里是声明式的。Cargo.toml 里定义开关,代码里用 #[cfg] 接线:
[features]
default = ["json"]
json = []
verbose = []
#[cfg(feature = "verbose")]
pub fn log(msg: &str) { println!("[verbose] {msg}"); }
#[cfg(not(feature = "verbose"))]
pub fn log(_msg: &str) {}
三种构建各跑一遍——cargo build、cargo build --features verbose、cargo build --no-default-features——被 cfg 门控的代码在开关关闭时根本不参与编译,连语法检查都跳过。依赖也可以是可选的(metrics = ["dep:atomic"] 这种写法),feature 开启时依赖才进依赖图。
最重要的规则是加性:feature 只能增加能力,永远不能移除。为什么?因为整个依赖图里同一个 crate 只编译一次——你的两个依赖各自开启了某库的不同 feature,cargo 取并集。如果 feature 能“关掉”什么,并集语义就崩了。这条规则推导出两条实践:default 保持精简(default 里的每个 feature 都是每个用户被迫支付的编译时间);CI 里加一条 cargo check --all-features,兜底那些“两个 feature 同时开启才暴露”的组合错误。
工作日的 cargo:教程不细讲的那几个命令
cargo check是你的日常驱动,不是cargo build。 check 只做到类型检查、跳过代码生成,速度快一个量级。写代码时的循环是:改 → check → 改。build 留给真的要运行的时刻。cargo clippy和cargo fmt进 CI,没有例外。 clippy 是第 1 篇那位“较真的导师”的另一半人格——几百条 lint 教你惯用写法;cargo fmt终结一切风格争论。两条命令各加进 CI 一行,团队省下的 review 口水以吨计。cargo test有过滤器。cargo test config只跑名字含 config 的测试;cargo test -- --nocapture放出被吞掉的println!。另外文档注释里的代码块会被当作测试运行(doc test)——示例代码从此不会悄悄腐烂。- 依赖管理三件套。
cargo add serde(改 Cargo.toml 不再用手)、cargo tree(谁把这块巨石拖进了我的依赖图)、cargo update(在语义化版本范围内升级并重写 lock)。 - 基准和发布永远
--release。 debug 与 release 的优化差距在 Rust 里轻松达到 10–100 倍——拿 debug 构建测性能,是这门语言里最常见的乌龙,没有之一。
练习,以及第 9 篇
- 把前面任何一篇的练习重构成 lib + bin 布局:全部逻辑进
lib.rs,main.rs只剩“解析参数、调用run()”。体会这层薄壳的价值——逻辑从此可以被测试、被别的 crate 复用。 - 制造一次 E0603:从
main.rs里直接调用 lib 的私有模块,读完整错误;然后分别用pub和pub(crate)修一遍,体会两者的承诺差异,最后留下刚好够用的那一档。 - 给你的项目加
verbosefeature:用#[cfg]门控一段日志代码,三种构建组合各跑一遍。然后在 Cargo.toml 上方写三行注释:default 里有什么、为什么、加性规则对你这个项目意味着什么——这是写给半年后的你的文档。
第 9 篇回到语言深处,兑现第 2 篇埋下的承诺:并发。Send 与 Sync 两个标记 trait 如何让数据竞争在编译期就无法写出、scoped threads 如何解决“线程借用栈上数据”的生命周期困境、Arc<Mutex<T>> 作为 Rc<RefCell<T>> 的多线程等价物,以及 channel 背后“用通信共享内存”的哲学。借用检查器最辉煌的一场胜仗,即将开讲。