Skip to content

一个 Rust 项目的解剖:Cargo 与模块 ​

第 4–7 章你在函数与类型这一层读代码;这一章把镜头拉远,看一个 Rust 项目由什么组成、谁指挥编译、依赖从哪来到哪去。读完本章,你应该能回答:

  • 拿到一个陌生的 Cargo 项目(比如刚 clone 下来的 jackdaw),半分钟内说出:入口在哪、依赖怎么声明、测试放哪、构建产物去哪;
  • 解释 tokio = { version = "1", features = ["full"] } 这一行:版本允许的范围多大、features 开关控制什么;
  • AI 交回一段 diff 后,立刻选出正确的仲裁命令——check、test、clippy、fmt 各回答一个不同的问题。

一、全貌:目录树里每个文件的职责 ​

解剖从文件列表开始,这是 jackdaw 的真实目录:

text
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] 段:

toml
[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 主要在发布时被工具读取。

再看依赖段:

toml
# 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" } 的简写;要开特性才用表形式。每行回答三个问题:依赖哪个箱、允许多新的版本、要哪些可选功能。依赖段上方这段注释还示范了一个习惯:没有"顺手装上"的依赖,每个箱都对应规格里的一条行为。

最后是发布配置:

toml
# 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 只负责二进制这一侧的接线。

rust
// ...
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 里逐个声明:

rust
// 教学示意:模块声明长这样(机制演示,非源码节选)
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 给出的命令就两条:

bash
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 看这些依赖从哪来、怎么判断一个箱是否可靠。

自检 ​

  1. 你从 Node.js 过来:Cargo.toml、Cargo.lock、target/ 分别对应 package.json / package-lock.json / node_modules 里的哪个?哪些该提交进 git,为什么?
  2. AI 把 serde = { version = "1", features = ["derive"] } "简化"成了 serde = "1"。预测 cargo check 的结果,并说出依据。
  3. 为什么 main.rs 里写 use jackdaw::cli::Cli,而库内部文件互相引用用 crate:: 开头?这说明 binary 箱与 library 箱是什么关系?
参考答案
  1. 依次对应 package.json(声明要什么)、package-lock.json(锁定精确版本)、node_modules(本地产物)。提交进 git 的是前两个与源码:target/ 是纯产物,可随时重建且体积大、平台相关;锁文件进 git 是为了可复现构建——这一点与多数语言生态的 lock 文件实践一致,Cargo.lock 连传递依赖也一并锁定。
  2. 编译失败。第 6 章用过的 #[derive(Serialize, Deserialize)] 依赖 serde 的 derive 特性;特性是真实的编译开关,不开就没有这对派生宏,所有使用它们的类型定义都会报错。
  3. 因为一个包里的 binary 箱与 library 箱是两个独立的编译单元。crate:: 指"当前箱的根"——对 main.rs 来说根就是它自己;要用库里的东西,只能走库的外部名字 jackdaw::。这说明 main.rs 不是"总入口文件",而是库的第一个客户:库才是全部可复用逻辑的家。