一个 Rust 项目的解剖:Cargo 与模块
第 4–7 章你在函数与类型这一层读代码;这一章把镜头拉远,看一个 Rust 项目由什么组成、谁指挥编译、依赖从哪来到哪去。读完本章,你应该能回答:
- 拿到一个陌生的 Cargo 项目(比如刚 clone 下来的 jackdaw),半分钟内说出:入口在哪、依赖怎么声明、测试放哪、构建产物去哪;
- 解释
tokio = { version = "1", features = ["full"] }这一行:版本允许的范围多大、features开关控制什么; - AI 交回一段 diff 后,立刻选出正确的仲裁命令——
check、test、clippy、fmt各回答一个不同的问题。
一、全貌:目录树里每个文件的职责
解剖从文件列表开始,这是 jackdaw 的真实目录:
jackdaw/Cargo.lock
jackdaw/Cargo.toml
jackdaw/README.md
jackdaw/jackdaw.example.toml
jackdaw/src/ask.rs
jackdaw/src/cli.rs
jackdaw/src/config.rs
jackdaw/src/gateway.rs
jackdaw/src/lib.rs
jackdaw/src/main.rs
jackdaw/src/model.rs
jackdaw/src/openai.rs
jackdaw/src/provider.rs
jackdaw/src/router.rs
jackdaw/tests/integration.rs读法:把树分成五组。Cargo.toml 是项目的清单——元数据与依赖声明;Cargo.lock 是锁文件(第五节展开);src/ 下除了 main.rs 与 lib.rs 这两个有特殊地位的入口,其余每个 .rs 文件对应一个功能模块;tests/integration.rs 放集成测试(第 15 章展开)。最后一组给人看:README.md 是项目自述,jackdaw.example.toml 是配置模板——复制成 jackdaw.toml 再照着改。树上没有 target/,因为它被 .gitignore 忽略——那是编译产物,删了随时能重建,所以不进版本库。
二、Cargo.toml 逐行:项目的自我介绍
Cargo 把构建工具与包管理器合为一身,它读的第一份文件就是清单。先看 [package] 段:
[package]
name = "jackdaw"
version = "0.1.0"
edition = "2024"
license = "MIT"
description = "A tiny, transparent, OpenAI-compatible LLM gateway with routing groups"读法:五行都是元数据。name 决定二进制名与库的外部名字;version 是 jackdaw 自己的版本;edition = "2024" 指定语言版本(edition)——它决定按哪一代语言规则编译,与你装的编译器新旧是两个正交的轴;license 与 description 主要在发布时被工具读取。
再看依赖段:
# Dependencies are kept to the minimum the blueprint lists: nothing here is
# "nice to have" — each crate appears because a spec behavior needs it.
[dependencies]
tokio = { version = "1", features = ["full"] }
axum = "0.8"
reqwest = { version = "0.12", features = ["json", "stream"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.9"
clap = { version = "4", features = ["derive"] }
thiserror = "2"
anyhow = "1"
# ...读法:依赖只有两种写法——axum = "0.8" 是 axum = { version = "0.8" } 的简写;要开特性才用表形式。每行回答三个问题:依赖哪个箱、允许多新的版本、要哪些可选功能。依赖段上方这段注释还示范了一个习惯:没有"顺手装上"的依赖,每个箱都对应规格里的一条行为。
最后是发布配置:
# Shipping profile: small binaries beat last-percent compile speed for a
# teaching project you build once and copy around.
[profile.release]
lto = true
strip = true读法:[profile.release] 只影响 --release 构建:lto 开跨箱的链接期优化,strip 去掉符号表,两者都拿编译时间换更小的二进制——上方注释写明,这是"编译一次、拷着到处跑"的教学项目的取舍。
三、main.rs 与 lib.rs:一个包,两个箱
先分清三个词:包是构建与发布的单位,箱是编译单元,模块是箱内的分层。一个包可以同时装两个箱:binary 箱(入口 src/main.rs)和 library 箱(入口 src/lib.rs)。jackdaw 两者都有,而且是刻意的:可复用的逻辑全在库里,main.rs 只负责二进制这一侧的接线。
// ...
use jackdaw::cli::Cli;
use jackdaw::config::{self, ConfigError};
#[tokio::main]
async fn main() {
init_tracing();
if let Err(error) = run(Cli::parse()).await {
// `{:#}` prints the whole anyhow chain, one cause per line.
eprintln!("jackdaw: {error:#}");
std::process::exit(exit_code(&error));
}
}
// ...读法:先看这两行 use——前缀是 jackdaw:: 而不是 crate::,意思是 main.rs 在以"库的客户"身份引用自己的库。main 只做三件事:初始化日志、把命令行交给 run、把最终错误翻译成退出码。Cli::parse() 背后是 clap 的子命令解析(第 10 章展开);.await 你在第 7 章见过,是让出点。
那 jackdaw::cli 这条路径从哪来?src/ 下的文件不会自动成为模块,必须在 lib.rs 里逐个声明:
// 教学示意:模块声明长这样(机制演示,非源码节选)
pub mod cli;
pub mod config;
// ...一行 pub mod cli; 把 src/cli.rs 挂进模块树并对外开放。回看 main.rs 的 use jackdaw::config::{self, ConfigError}:{self, ConfigError} 的意思是"引入 config 模块本身,外加它里面的 ConfigError 类型"。
INFO
多个独立的包共享同一个 target/ 与锁文件时,根清单会出现 [workspace] 段。工作区在多包仓库里很常见;jackdaw 只有一个包,用不上。
四、cargo 子命令:每个命令回答一个问题
第 3 章说过,cargo 系命令是你与 AI 之间的仲裁者;仲裁的前提是知道每个命令在问什么:
| 命令 | 回答的问题 |
|---|---|
cargo check | 能编译过吗?(不产出二进制,最快) |
cargo test | 行为对不对? |
cargo clippy | 写法地道吗?(在编译之上再抓惯用性问题) |
cargo fmt | 格式统一吗?(只动排版不动语义) |
cargo build | 能产出可用的二进制吗? |
cargo run | 现在跑起来是什么样? |
读 AI 交回的 diff 时,顺序通常是 check → test → clippy:先问能不能编译,再问行为,最后问质量。收尾时,jackdaw/README.md 给出的命令就两条:
cargo test
cargo install --path . # or: cargo build --release读法:cargo install --path . 构建 release 版并装进 ~/.cargo/bin。产物目录按 profile 分:日常 cargo run 用 target/debug/(编译快、带调试信息),release 用 target/release/(优化,且吃上第二节那两个开关)。整个 target/ 随时可删,Cargo 会重建。
五、版本、锁文件与特性
语义化版本(semver)的格式是主.次.修订,如 1.6.3。清单里写的 "1" 是 ^1 的简写:允许 ≥1.0.0 且 <2.0.0 的一切。一条容易踩的规则:主版本是 0 时,次版本顶替主版本的角色——axum 的 "0.8" 只允许 ≥0.8.0 且 <0.9.0,因为 0.x 阶段随时可能破坏兼容。读 AI 的依赖 diff 时,先看它动的是修订(低风险)、次版本(可能有新行为),还是主版本或 0.x 的次版本(可能有破坏性变更)。
Cargo.lock 回答"到底装了哪个版本"。第一次构建时,Cargo 把每个范围解析成精确版本写进锁文件——version = "1" 最终可能锁定某个具体的 tokio 小版本。锁文件要提交进仓库(jackdaw 树里就有):你和任何一台重新构建的机器拿到的依赖因此完全相同;只要清单不动,就只有 cargo update 才会重新解析。
特性(feature)回答"这个箱的哪些可选功能参与编译"。不开的特性连同它的代码不进编译,依赖树更小、编译更快。serde 的 derive 你在第 6 章见过成果——#[derive(Serialize, Deserialize)] 那对宏正是它打开的;reqwest 的 stream 为逐块读 SSE 响应体而开(第 12 章展开)。tokio 开 "full" 是教学项目的取巧;缺特性时,编译报错通常会直接指到缺的东西,按提示补上即可。
小结
回收开场:目录树五组文件各司其职,入口是 main.rs 与 lib.rs,产物在可删的 target/;Cargo.toml 分元数据、依赖、发布配置三段;"1" 是范围、锁文件是精确版、特性是编译开关;check/test/clippy/fmt 各回答一个问题。下一章离开这个项目,去 crates.io 看这些依赖从哪来、怎么判断一个箱是否可靠。
自检
- 你从 Node.js 过来:
Cargo.toml、Cargo.lock、target/分别对应 package.json / package-lock.json / node_modules 里的哪个?哪些该提交进 git,为什么? - AI 把
serde = { version = "1", features = ["derive"] }"简化"成了serde = "1"。预测cargo check的结果,并说出依据。 - 为什么
main.rs里写use jackdaw::cli::Cli,而库内部文件互相引用用crate::开头?这说明 binary 箱与 library 箱是什么关系?
参考答案
- 依次对应 package.json(声明要什么)、package-lock.json(锁定精确版本)、node_modules(本地产物)。提交进 git 的是前两个与源码:
target/是纯产物,可随时重建且体积大、平台相关;锁文件进 git 是为了可复现构建——这一点与多数语言生态的 lock 文件实践一致,Cargo.lock 连传递依赖也一并锁定。 - 编译失败。第 6 章用过的
#[derive(Serialize, Deserialize)]依赖 serde 的derive特性;特性是真实的编译开关,不开就没有这对派生宏,所有使用它们的类型定义都会报错。 - 因为一个包里的 binary 箱与 library 箱是两个独立的编译单元。
crate::指"当前箱的根"——对main.rs来说根就是它自己;要用库里的东西,只能走库的外部名字jackdaw::。这说明main.rs不是"总入口文件",而是库的第一个客户:库才是全部可复用逻辑的家。