Skip to content

项目组织与 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:逻辑可被测试、可被复用
workspaceCargo.toml + 多个成员 crate多产出物(CLI + 库 + 服务)、多团队协作

bin/lib 分离的核心动机src/main.rs 里的代码无法被集成测试引用tests/ 下的测试只能 use 库 crate),也无法被别的项目依赖。把所有逻辑放进 lib.rsmain.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-appuse 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.rsnet.rsnet/transport.rs 在两种风格里名字相同。

⚠️ 陷阱同一模块不能两种风格混用。如果 src/net.rssrc/net/mod.rs 同时存在,编译器报 file for module 'net' found at both ...。选定一种后全项目统一;Rust 官方与绝大多数新项目选风格 B(好处:编辑器标签页里一排 net.rsutil.rs 比一排 mod.rs 好认)。

2024 edition 的模块与路径变化要点

模块系统的路径语义2018 edition 定稿(use 必须以 crate/self/super/外部 crate 名开头),2024 edition 没有再次改变模块解析规则。写 2024 代码时真正需要注意的相关变化是:

  1. gen 成为保留关键字。名叫 gen 的模块/函数/变量必须改成 generate 等,或用 r#gen。这也是 rand 0.9 把 gen() 改名为 random() 的原因。
  2. unsafe_op_in_unsafe_fn 默认警告unsafe fn 内的不安全操作必须显式包 unsafe {}(详见「unsafe 与 FFI 入门(只讲原则)」)。
  3. RPIT 生命周期捕获规则收紧fn f<'a>(x: &'a str) -> impl Iterator<Item = &'a str> 在 2024 中会捕获 'a,此前需要写 + use<'a> 才能捕获全部。这影响「返回 impl Iterator」的签名(见「API 设计准则」)。
  4. 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.tomledition 字段);逐条的行为变更与迁移方法,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 里会直接显示重导出后的路径,读者不用理解你的内部布局。

可见性设计原则

  1. 默认私有,按需开放。写完先全私有,测试/调用方报错时再往上调一级,直到编译通过。
  2. 只暴露必要 API。「以后可能有人要用」不是公开的理由——公开就是兼容性承诺,改起来要走 semver major。
  3. 公开结构体加 #[non_exhaustive](见下),公开枚举也加,代价是外部不能 match 到穷尽。
  4. 状态字段一律私有,通过方法与构造器维护不变量。
  5. 内部跨模块共享用 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 = true

dep:?/ 的语义,用一句话记:

写法含义
feat = ["dep:foo"]启用可选依赖 foo创建名为 foo 的 feature
feat = ["foo"]启用名为 foo 的 feature(若 foo 是可选依赖则是隐式 feature)
feat = ["foo/bar"]启用可选依赖 foobar 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 键速记:featuretestdebug_assertionsunix/windowstarget_ostarget_familytarget_archtarget_envtarget_pointer_widthtarget_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.01.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 标注
text
$ cargo tree -d
serde v1.0.210
└── ...
serde v1.0.219
└── ...

-d 列出所有被编译了多个版本的 crate。两个版本都在图里意味着:更大的二进制、更慢的编译、以及「同一类型却不可互换」的诡异错误(比如 foo::Error 有两个不兼容的定义)。处理办法:找出上游依赖,cargo update -p 对齐,或给上游提 PR。

⚠️ 陷阱links 冲突。Cargo.tomllinks = "foo" 声明「我这个 crate 链接了系统原生库 foo」。同一个原生库在最终构建里只允许出现一次,所以两个不同版本的 crate 都声明 links = "foo" 时报错:multiple packages link to native library foo。这类错误不能用 cargo update 的普通方式解决(那是 semver 冲突,不是版本冲突),只能让两个依赖对齐到同一个上游版本。常见的 links 大户:openssl-syslibz-sysringlibsqlite3-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-auditcargo 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.toml
toml
# .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 msrvcargo hack check --rust-version 可以在 CI 上验证「声称的 MSRV 真的能编过」。牢记:改动 rust-version 也是公开 API 变更,要写进 CHANGELOG。



延伸阅读


内容以 rustc 1.98.1 · Rust 2024 edition 为基准