项目组织与 Cargo
本章是从「会写 Rust」到「写出能维护的 Rust 项目」的分水岭:讲清 Cargo 工程组织、feature 设计、依赖治理、生态 crate 选型、文档与 lint 纪律、发布与 CI。前置知识:基础语法、所有权与借用、trait、集合与迭代器、错误处理。
本章目标
- 能独立设计
bin/lib分离的工程与 Cargo workspace,并说清pub/pub(crate)/pub(super)与lib.rs门面的取舍。 - 能设计只做加法的 feature,理解
dep:语法、--no-default-features与 feature 统一(unification)的后果。 - 能读懂
cargo tree、用 MSRV 与Cargo.lock策略管住依赖,知道每个常用 crate 解决什么问题、怎么cargo add。 - 能为公开 API 写文档注释与 doctest,能用
#[non_exhaustive]、impl Iterator、Builder、sealed trait 保护演进空间。 - 能配置
cargo fmt/clippy/[lints]与一条最小 GitHub Actions 流水线,并说清「该 allow 还是该重构」。 - 能在发布前跑完 checklist:
cargo package --list、--dry-run、release profile 调优、体积优化。
项目结构范式:bin/lib 分离
三种典型形态
Cargo 项目的目录结构决定了代码的可测试性。工程上有三种常见形态:
| 形态 | 目录要点 | 适用场景 |
|---|---|---|
| 纯 bin | 只有 src/main.rs | 一次性脚本、极小的工具 |
| bin + lib(推荐) | src/lib.rs + src/main.rs | 绝大多数 CLI:逻辑可被测试、可被复用 |
| workspace | 根 Cargo.toml + 多个成员 crate | 多产出物(CLI + 库 + 服务)、多团队协作 |
bin/lib 分离的核心动机:src/main.rs 里的代码无法被集成测试引用(tests/ 下的测试只能 use 库 crate),也无法被别的项目依赖。把所有逻辑放进 lib.rs,main.rs 只留「解析参数 → 调用库 → 处理退出码」三步:
rust
// src/main.rs —— 只做胶水,不做逻辑
use myapp::{Config, run}; // 注意:use 的是「包名」,不是 crate 目录名
fn main() -> std::process::ExitCode {
let cfg = match Config::from_env() {
Ok(c) => c,
Err(e) => {
eprintln!("配置错误: {e}");
return std::process::ExitCode::from(2);
}
};
match run(cfg) {
Ok(()) => std::process::ExitCode::SUCCESS,
Err(e) => {
eprintln!("运行失败: {e:#}");
std::process::ExitCode::FAILURE
}
}
}rust
// src/lib.rs —— 全部可测试的逻辑
pub mod config;
pub mod engine;
pub use config::Config; // 门面重导出,见「`#[path]` 慎用」
pub use engine::run;
/// 库级错误类型。
#[derive(Debug)]
pub enum AppError {
/// 配置缺失或非法。
Config(String),
/// 引擎执行失败。
Engine(String),
}
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AppError::Config(m) => write!(f, "配置错误: {m}"),
AppError::Engine(m) => write!(f, "引擎错误: {m}"),
}
}
}
impl std::error::Error for AppError {}
/// 引擎入口。
pub fn run(cfg: Config) -> Result<(), AppError> {
println!("运行于 {},并发度 {}", cfg.path.display(), cfg.jobs);
Ok(())
}🧠 原理:一个包(package)可以同时产出两类 crate:
src/lib.rs编译成库 crate,其名字是「包名把-换成_」;src/main.rs编译成二进制 crate。二进制 crate 会自动把同包的库 crate 加进extern prelude,所以use myapp::...能直接用,不需要写extern crate。包名my-app→use my_app::...。
💡 对照:Python 的
src/mypkg/+cli.py、Go 的cmd/+ 内部包、Node 的src/index.js+bin/cli.js是同一个思路。Rust 的区别是编译期强制:main.rs里写的东西默认外界拿不到,你不得不提前想清楚「哪些是 API」。
模块系统复习与深化
模块(module)的完整语法清单:
rust
// crate 根(lib.rs 或 main.rs)中
pub mod api; // 声明子模块,并对外公开
mod internal; // 声明子模块,仅 crate 内可见
pub mod net {
// 绝对路径:从当前 crate 根出发,推荐(edition 2018+)
use crate::internal::helper;
// 相对路径:从「当前模块」出发
use self::transport::Tcp; // self 指当前模块
// use super::... 指父模块;多层父模块用 super::super::
pub mod transport {
/// 用 super 指父模块 net
pub(super) fn shared_util() {} // 只对 net 及其子孙可见
}
/// 对外 API
pub fn fetch() -> usize {
let _ = helper();
Tcp::connect(); // 同模块内在作用域里,直接用
42
}
}
// as 给长路径起别名,避免深层嵌套
use crate::net::transport::Tcp as TcpConn;
// as 也用于导入 trait 但避免名字污染:use std::fmt::Display as _;可见性(visibility)阶梯,从紧到松:
| 写法 | 可见范围 | 典型用途 |
|---|---|---|
| (不写) | 当前模块及其子孙 | 默认,绝大多数条目 |
pub(self) | 等同不写(当前模块) | 显式强调「内部」 |
pub(super) | 父模块及其子孙 | 拆分实现细节给父模块用 |
pub(crate) | 当前 crate 任意位置 | 内部 API 的首选 |
pub(in path) | 指定祖先模块 | 少见,精细控制 |
pub | 从 crate 根可达 | 公开 API,需文档与兼容性承诺 |
🧠 原理:字段的可见性独立于类型本身。
pub struct Foo { pub a: i32, b: i32 }中b是私有的——外部不能构造也不能读取(Foo { a: 1, b: 2 }会报错),这是 Rust 实现「不变量封装」的主要手段。构造器模式(Foo::new)就是配合私有字段用的。
文件布局:mod.rs 风格 vs foo.rs + foo/ 风格
同一个模块树有两种物理写法,功能完全等价:
text
# 风格 A:mod.rs(2015 时代习惯,仍在用)
src/
├── main.rs
└── net/
├── mod.rs
└── transport.rs
# 风格 B:foo.rs + foo/ 并列(2018+ 推荐)
src/
├── main.rs
├── net.rs
└── net/
└── transport.rs两种风格对应关系:net/mod.rs ⟷ net.rs;net/transport.rs 在两种风格里名字相同。
⚠️ 陷阱:同一模块不能两种风格混用。如果
src/net.rs和src/net/mod.rs同时存在,编译器报file for module 'net' found at both ...。选定一种后全项目统一;Rust 官方与绝大多数新项目选风格 B(好处:编辑器标签页里一排net.rs、util.rs比一排mod.rs好认)。
2024 edition 的模块与路径变化要点
模块系统的路径语义在 2018 edition 定稿(use 必须以 crate/self/super/外部 crate 名开头),2024 edition 没有再次改变模块解析规则。写 2024 代码时真正需要注意的相关变化是:
gen成为保留关键字。名叫gen的模块/函数/变量必须改成generate等,或用r#gen。这也是rand0.9 把gen()改名为random()的原因。unsafe_op_in_unsafe_fn默认警告:unsafe fn内的不安全操作必须显式包unsafe {}(详见「unsafe 与 FFI 入门(只讲原则)」)。- RPIT 生命周期捕获规则收紧:
fn f<'a>(x: &'a str) -> impl Iterator<Item = &'a str>在 2024 中会捕获'a,此前需要写+ use<'a>才能捕获全部。这影响「返回impl Iterator」的签名(见「API 设计准则」)。 - rustfmt 的 style edition 默认跟随 edition,2024 会启用新的格式化规则。团队里有人用编辑器 format-on-save 时,建议显式加
rustfmt.toml:
toml
# rustfmt.toml:显式钉住,防止「命令行格式化」和「编辑器保存格式化」产出不同结果
style_edition = "2024"
edition = "2024"升级 edition 用 cargo fix --edition(先 cargo migrate 或手动改 Cargo.toml 的 edition 字段);逐条的行为变更与迁移方法,Rust Edition Guide 里有完整清单(入口见附录 D)。
#[path] 慎用
#[path = "..."] 可以强制指定模块对应的文件,绕过目录约定:
rust
// 不推荐:路径与目录结构脱钩,IDE 跳转、cargo package 白名单都会变得难懂
#[path = "generated/client_v2.rs"]
pub mod client;⚠️ 陷阱:
#[path]里的相对路径是相对于「声明它的那个文件的目录」,而声明在mod.rs与声明在foo.rs里时基准可能不同——这是历史上大量「文件找不到」问题的来源。合理用途只有两种:include!生成的代码、把同一个源文件编进两个不同路径的模块(如#[cfg]分支下的平台实现)。其余情况一律用目录约定。要在多个目标间共享源码,正确做法是抽成一个内部 crate,而不是#[path]。
lib.rs 作为门面(facade)与 pub use 重导出
内部模块结构应该随实现演进自由变动,但对外路径要稳定。做法:内部模块用 pub(crate) 或干脆 pub 但在 lib.rs 里重导出成扁平路径。
rust
// src/lib.rs —— 门面
mod config; // 私有:外部只能用重导出的名字
mod engine;
pub use config::{Config, ConfigBuilder}; // 外部写 myapp::Config
pub use engine::{Engine, run};
/// 常用类型的预导入(prelude)模块。
///
/// 使用方式:`use myapp::prelude::*;`
pub mod prelude {
pub use crate::config::Config;
pub use crate::engine::{Engine, run};
}💡 对照:这就是 Python 包里
from .impl import Thing再在__init__.py里__all__的模式,也是 Go 里「内部包 + 顶层 re-export 风格」的近似物。Rust 里pub use还有个额外好处:doc 里会直接显示重导出后的路径,读者不用理解你的内部布局。
可见性设计原则
- 默认私有,按需开放。写完先全私有,测试/调用方报错时再往上调一级,直到编译通过。
- 只暴露必要 API。「以后可能有人要用」不是公开的理由——公开就是兼容性承诺,改起来要走 semver major。
- 公开结构体加
#[non_exhaustive](见下),公开枚举也加,代价是外部不能match到穷尽。 - 状态字段一律私有,通过方法与构造器维护不变量。
- 内部跨模块共享用
pub(crate),不要为了省事写pub。
rust
/// 一个非穷尽的结构体:外部**不能**用字面量构造,只能走构造器。
///
/// 好处:未来加字段不算破坏性变更(semver 里属于 minor)。
#[non_exhaustive]
#[derive(Debug, Clone)]
pub struct ClientConfig {
/// 服务地址。
pub base_url: String,
/// 超时秒数。
pub timeout_secs: u64,
}
impl ClientConfig {
/// 用地址创建配置,其余字段用默认值。
#[must_use]
pub fn new(base_url: impl Into<String>) -> Self {
Self { base_url: base_url.into(), timeout_secs: 30 }
}
}
/// 非穷尽枚举:外部必须写 `_ =>` 分支,未来加变体不破坏下游。
#[non_exhaustive]
pub enum Error {
/// 网络不可达。
Network,
/// 服务端返回了非 2xx。
Status(u16),
}🧠 原理:
#[non_exhaustive]是编译期的兼容性保险。没有它,给公开 struct 加字段就是破坏性变更(下游用了结构体字面量),给公开 enum 加变体也是破坏性变更(下游match会因不穷尽而编译失败)。加上它之后,这两类改动都降级成 minor 版本可以做的加法。
feature 与条件编译
[features] 设计
feature 是 Cargo 的编译期开关,用于「同一份代码,不同裁剪」。典型用途:可选依赖(serde 支持)、可选重量级后端(tls)、平台实现。
toml
[package]
name = "mylib"
version = "0.3.0"
edition = "2024"
[dependencies]
# 可选依赖:只有 feature 打开时才会被编译
serde = { version = "1", features = ["derive"], optional = true }
regex = { version = "1", optional = true }
[features]
# default 是「什么都不指定时启用什么」,应当最小化
default = ["std"]
# 功能型 feature 用 dep: 精确指向「启用这个依赖」本身,
# 从而不隐式产生同名 feature(Cargo 1.60+)
json = ["dep:serde", "dep:serde_json"]
std = []
# feature 之间可以互相启用
full = ["std", "json"]
# 弱依赖 feature:只启用「某可选依赖的某个 feature」,不启用该依赖本身(Cargo 1.60+)
serde-derive = ["serde?/derive"]
[dependencies.serde_json]
version = "1"
optional = truedep: 与 ?/ 的语义,用一句话记:
| 写法 | 含义 |
|---|---|
feat = ["dep:foo"] | 启用可选依赖 foo,不创建名为 foo 的 feature |
feat = ["foo"] | 启用名为 foo 的 feature(若 foo 是可选依赖则是隐式 feature) |
feat = ["foo/bar"] | 启用可选依赖 foo 的 bar feature(同时意味着启用 foo) |
feat = ["foo?/bar"] | 仅当 foo 已被别处启用时,才顺带启用它的 bar(弱依赖,Cargo 1.60+) |
⚠️ 陷阱:一旦你在
[features]里任何地方用了dep:foo,Cargo 就不再为foo生成隐式同名 feature。此时如果有下游写features = ["foo"],会报feature foo does not exist。这是不少 crate 升级后「下游编译失败」的原因,故dep:应视为一次小型的 API 决策。
#[cfg(...)] 家族
rust
// feature 开关整块代码
#[cfg(feature = "json")]
pub mod json_io;
/// 有 json feature 时用快实现,否则退化为朴素实现。
#[cfg(feature = "json")]
pub fn encode(v: &[u8]) -> String {
format!("{:?}", v) // 仅演示:真实项目这里调用 serde_json
}
/// 没有 json feature 时的替代实现:签名必须一致。
#[cfg(not(feature = "json"))]
pub fn encode(v: &[u8]) -> String {
v.iter().map(|b| format!("{b:02x}")).collect()
}
// 平台判断
#[cfg(windows)]
pub fn default_shell() -> &'static str { "powershell" }
#[cfg(target_os = "linux")]
pub fn default_shell() -> &'static str { "bash" }
#[cfg(unix)]
pub fn is_executable_mode(mode: u32) -> bool { mode & 0o111 != 0 }
// 按指针宽度
#[cfg(target_arch = "x86_64")]
const WORD_BITS: u32 = 64;
// cfg_attr:条件性地「加属性」,属性本身不能写 cfg
#[cfg_attr(feature = "json", derive(serde::Serialize))]
#[cfg_attr(test, derive(Default))]
#[derive(Debug)]
pub struct Record {
/// 记录 ID。
pub id: u64,
}
// 测试专用模块(单元测试的标准写法)
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encode_is_hex_without_json_feature() {
assert!(!encode(&[0x0f]).is_empty());
}
}常用 cfg 键速记:feature、test、debug_assertions、unix/windows、target_os、target_family、target_arch、target_env、target_pointer_width、target_vendor。
🧠 原理:
#[cfg]在展开与类型检查之前裁剪语法树,被裁掉的代码不参与编译(因此不需要满足类型约束,也不必存在依赖)。这也是为什么「没有该 feature 时引用不存在的类型」不会报错——那部分代码根本不存在。cfg_attr则是「满足条件时再塞一个属性进去」,因为#[derive]这种属性无法自己写条件。
feature 必须是「加法」
核心准则:feature 只应增加能力,不应互相排斥或相互破坏。 两个 feature 同时打开,结果必须至少和各自单独打开一样好。
反面例子(不要这样设计):default = ["backend-a"],而 backend-b 打开时必须关掉 backend-a;或者 no-logging 这种「减法 feature」。原因在于 feature 统一(feature unification):Cargo 会把你依赖图里所有对该 crate 的同一版本请求合并成一个 feature 集合。你可能只想用 mycrate 的默认特性,但你的另一个依赖启用了 mycrate/full,于是你的构建里 full 也被打开了——编译产物与你期望的不一致,且你无法从自己的 Cargo.toml 看出来。
正面做法:
toml
# 好:能力是加法;用户想「全都要额外东西」就叠加,不需要关掉谁
[features]
default = [] # 默认最小
json = ["dep:serde", "dep:serde_json"]
yaml = ["dep:serde_yaml"]
metrics = ["dep:metrics"]
full = ["json", "yaml", "metrics"]验证「最小可用」的常用命令:
powershell
# 只开默认特性构建
cargo build
# 关掉默认特性,验证库在「零可选能力」下也能独立编译
cargo build --no-default-features
# 单开某个特性
cargo build --no-default-features --features json
# 检查所有特性组合(第三方工具,强烈推荐)
cargo install cargo-hack
cargo hack build --feature-powerset🚀 进阶:
cargo hack check --feature-powerset --depth 2能自动枚举特性组合,是「feature 加法」纪律的自动化保证。CI 里跑一次,可以彻底消灭「某个冷门组合编译不过」的长期积弊。
依赖管理实践
语义化版本与 caret 语义
版本号 MAJOR.MINOR.PATCH,Cargo 默认把 "1.2.3" 解读为 caret 需求 ^1.2.3:
| 需求写法 | 实际含义 | 允许升级到 |
|---|---|---|
"1.2.3" / "^1.2.3" | >=1.2.3, <2.0.0 | 任何 1.x |
"0.9.2" / "^0.9.2" | >=0.9.2, <0.10.0 | 任何 0.9.x(0.x 的 minor 视为 breaking) |
"0.0.5" | >=0.0.5, <0.0.6 | 只有 0.0.5 |
"=1.2.3" | 精确锁定 | 只有 1.2.3 |
"~1.2.3" | >=1.2.3, <1.3.0 | 1.2.x |
"*" | 任意 | 不要用 |
⚠️ 陷阱:
0.x版本的 minor 升级是 breaking change。一个 crate 从0.9升到0.10,Cargo 视为不兼容的另一个 crate,会同时编进二进制(见「常见坑与编译错误」 的重复依赖)。
日常命令
powershell
cargo add serde --features derive # 添加依赖并自动选择最新兼容版本
cargo add tokio --features rt-multi-thread,macros
cargo add --dev criterion # 加到 [dev-dependencies]
cargo add --build cc # 加到 [build-dependencies]
cargo add --optional serde # 可选依赖
cargo update # 在 semver 允许范围内升级,写回 Cargo.lock
cargo update -p serde # 只升级某一个包
cargo update -p serde --precise 1.0.219 # 精确指定版本
cargo tree # 打印依赖树
cargo tree -d # 只看「同一 crate 出现多版本」的节点
cargo tree -i serde # 反向:谁依赖了 serde
cargo tree -e features -p mylib # 带 feature 标注重复依赖与 links 冲突排查
text
$ cargo tree -d
serde v1.0.210
└── ...
serde v1.0.219
└── ...-d 列出所有被编译了多个版本的 crate。两个版本都在图里意味着:更大的二进制、更慢的编译、以及「同一类型却不可互换」的诡异错误(比如 foo::Error 有两个不兼容的定义)。处理办法:找出上游依赖,cargo update -p 对齐,或给上游提 PR。
⚠️ 陷阱:
links冲突。Cargo.toml里links = "foo"声明「我这个 crate 链接了系统原生库 foo」。同一个原生库在最终构建里只允许出现一次,所以两个不同版本的 crate 都声明links = "foo"时报错:multiple packages link to native library foo。这类错误不能用cargo update的普通方式解决(那是 semver 冲突,不是版本冲突),只能让两个依赖对齐到同一个上游版本。常见的links大户:openssl-sys、libz-sys、ring、libsqlite3-sys。
安全与许可证审计
powershell
cargo install cargo-audit cargo-deny
cargo audit # 对照 RustSec 漏洞库检查已知 CVE
cargo deny check # 许可证 / 禁用依赖 / 重复版本 / 来源 一站式检查deny.toml 最小示例:
toml
[licenses]
# 只允许这些许可证进入依赖树(按公司法务白名单填写)
allow = ["MIT", "Apache-2.0", "Apache-2.0 WITH LLVM-exception", "BSD-3-Clause", "ISC"]
[bans]
multiple-versions = "warn" # 重复版本:先警告,收敛后改 "deny"
wildcards = "deny" # 禁止 "*" 版本需求
[sources]
unknown-registry = "deny" # 禁止来自非白名单 registry 的依赖💡 对照:
cargo audit≈ JS 的npm audit/ Python 的pip-audit;cargo deny更接近企业里的「依赖准入策略」,把许可证合规也纳入 CI。
[patch]、[replace] 与 vendor
[patch]:临时把某个依赖替换成别的源(打补丁的 fork、本地路径),常用于「上游 bug 未发版,我先修」。
toml
# 只应存在于根 Cargo.toml;仅在改动的 crate 是「你自己 workspace 的成员或来自 crates.io」时生效
[patch.crates-io]
serde = { git = "https://github.com/serde-rs/serde", branch = "fix-1234" }
# 或本地路径
some-dep = { path = "../some-dep-fork" }[replace] 是 [patch] 的前身,语法要求写死 name:version,已不推荐,新项目一律用 [patch]。[patch] 的补丁在你发布自己的 crate 时不会传递给下游,所以它适合「本地/CI 抢修」,不适合长期方案。
vendor(离线构建与供应链固化):
powershell
cargo vendor vendor/ # 把全部依赖源码下载到 vendor/,并打印需要的配置
# 按提示把下面内容写进 .cargo/config.tomltoml
# .cargo/config.toml:让 Cargo 从本地目录取依赖,实现完全离线构建
[source.crates-io]
replace-with = "vendored-sources"
[source.vendored-sources]
directory = "vendor"
[net]
offline = true私有 registry 一句话:企业自建 registry(或替代方案如 git 依赖、稀疏 registry sparse+https://...)用于内部分发,配置写在 .cargo/config.toml 的 [registries];pub 生态主流仍是 crates.io。
Cargo.lock 的提交策略与 MSRV
| 项目类型 | Cargo.lock | 理由 |
|---|---|---|
| 二进制 / 应用 / CLI | 提交 | 要可复现的构建;cargo install 也依赖锁文件 |
| 库(要被别人依赖) | 通常不提交 | 下游的 lock 才是最终决定者;提交会误导使用者以为版本被钉住(历史上 Cargo 也会忽略它) |
| workspace | 提交(根目录只有一份) | 成员共享同一个 lock |
🧠 原理:
Cargo.lock记录的是解析结果(精确版本),而不是需求。库的依赖需求在Cargo.toml的 semver 范围里;下游解析时会用自己的 lock 做全图统一。因此库提交 lock 只对「在库仓库里跑测试」有意义——现代实践倾向于:在 CI 里额外跑一次「最小版本」测试来保证语义化版本声明诚实。
MSRV(Minimum Supported Rust Version):在 Cargo.toml 里声明的 rust-version 会:在版本过低时报明确错误而不是诡异编译失败;参与依赖解析(Cargo 1.84+ 的 MSRV-aware resolver 会优先挑满足 MSRV 的依赖版本)。
toml
[package]
name = "mylib"
version = "0.3.0"
edition = "2024"
rust-version = "1.85" # edition 2024 的最低要求就是 1.85
[workspace]
resolver = "3" # resolver 3(edition 2024 默认)启用 MSRV-aware 解析🚀 进阶:
cargo msrv与cargo hack check --rust-version可以在 CI 上验证「声称的 MSRV 真的能编过」。牢记:改动rust-version也是公开 API 变更,要写进 CHANGELOG。
延伸阅读
- 同一概念的第二种讲法(官方书中文版、Rust 圣经的逐章映射),见 附录 E · 对照阅读与组合学习法。
- 官方文档、中文资料、书单与工具的完整索引,见 附录 D · 学习资源与文档索引。