第6章:工程骨架 —— 集合、错误、模块、测试与文档
约 9 分钟 · 更新于 2026-09-01
第6章:工程骨架 —— 集合、错误、模块、测试与文档
单个类型正确,并不代表项目可维护。本章把前面的工作项模型装进 work-catalog 库,重点观察五条边界:数据怎样存储、失败怎样返回、实现怎样隐藏、契约怎样测试、公开 API 怎样被文档化。
一、先画工程边界
目标项目不是“把代码拆成多个文件”,而是形成一个可以被其他程序依赖的小型库:
text
work-catalog/
├── Cargo.toml
├── src/
│ ├── lib.rs # 公开入口
│ ├── model.rs # 领域类型
│ ├── catalog.rs # 集合与操作
│ └── error.rs # 结构化错误
└── tests/
└── catalog_flow.rs
拆分前先回答五个问题:
- 顺序遍历和按编号查找分别需要什么数据结构?
- 重复编号、缺失对象和非法状态属于可恢复错误还是程序缺陷?
- 调用者真正需要哪些公开类型与方法?
- 哪些规则适合单元测试,哪些行为必须从公开 API 验证?
- 使用者能否仅凭文档示例完成一次正确调用?
模块结构应服务于这些边界,而不是为了追求“一个文件只能放一种语法”。
二、Vec<T>:有顺序、会增长的列表
rust
#[derive(Debug, Clone, PartialEq, Eq)]
struct WorkItem {
id: u64,
title: String,
done: bool,
}
let mut items: Vec<WorkItem> = Vec::new();
items.push(WorkItem { id: 1, title: "数据建模".into(), done: true });
items.push(WorkItem { id: 2, title: "工程骨架".into(), done: false });
Vec<T> 的长度在运行时可变,元素连续存放,适合按顺序遍历与索引读取。访问方式表达不同承诺:
rust
let first = &items[0]; // 调用者确信存在,否则 panic
match items.get(10) {
Some(item) => println!("{}", item.title),
None => println!("没有这个位置"),
}
外部输入形成的索引优先使用 .get()。三种遍历对应三种所有权语义:
| 写法 | 元素类型 | 结果 |
|---|
| for item in items | WorkItem | 消耗 Vec |
| for item in &items | &WorkItem | 只读借用 |
| for item in &mut items | &mut WorkItem | 可变借用 |
push 可能扩容并搬迁缓冲区,因此元素引用仍在使用时不能修改 Vec:
rust
let mut values = vec![1, 2, 3];
let first = &values[0];
// values.push(4); // first 后面仍要使用,禁止潜在搬迁
println!("{first}");
正确做法是先用完引用再修改集合,或保存稳定编号而不是元素地址。
三、HashMap<K, V>:按编号建立索引
每次按编号遍历 Vec 是线性查找。可以增加位置索引:
rust
use std::collections::HashMap;
let mut positions: HashMap<u64, usize> = HashMap::new();
positions.insert(1, 0);
positions.insert(2, 1);
if let Some(index) = positions.get(&2) {
println!("工作项 2 在位置 {index}");
}
HashMap 适合按键查值、去重与计数。get 返回借用,不会移走值。entry 可把查询与更新合成一次:
rust
let words = ["rust", "item", "rust"];
let mut counts = HashMap::new();
for word in words {
*counts.entry(word).or_insert(0) += 1;
}
assert_eq!(counts.get("rust"), Some(&2));
主存储与索引组合:
rust
#[derive(Debug, Default)]
struct Catalog {
items: Vec<WorkItem>,
positions: HashMap<u64, usize>,
}
impl Catalog {
fn get(&self, id: u64) -> Option<&WorkItem> {
let index = *self.positions.get(&id)?;
self.items.get(index)
}
}
? 此处作用于 Option:任一步为 None 就立即返回 None。
双结构带来一致性成本:插入、删除、重排都必须同步索引。小数据可先只用 Vec;确有查找或唯一性需求时再增加 HashMap。
四、panic! 与 Result:失败必须分类
Rust 把失败分成两类:
- 不可恢复、内部不变量被破坏:panic!;
- 调用者可以处理:Result<T, E>。
用户输错编号、重复添加工作项都可恢复,不应让库直接崩溃。unwrap 与 expect 会在 None / Err 时 panic,适合测试、示例和已被逻辑证明的前置条件,不应习惯性用于文件、网络与用户输入。
定义结构化领域错误:
rust
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CatalogError {
DuplicateId(u64),
NotFound(u64),
AlreadyDone(u64),
}
impl fmt::Display for CatalogError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DuplicateId(id) => write!(f, "工作项编号 {id} 已存在"),
Self::NotFound(id) => write!(f, "工作项编号 {id} 不存在"),
Self::AlreadyDone(id) => write!(f, "工作项编号 {id} 已完成"),
}
}
}
impl std::error::Error for CatalogError {}
枚举错误让调用者按变体处理,测试也能断言具体原因,而不是解析字符串。
添加工作项:
rust
impl Catalog {
pub fn add(&mut self, item: WorkItem) -> Result<(), CatalogError> {
if self.positions.contains_key(&item.id) {
return Err(CatalogError::DuplicateId(item.id));
}
let id = item.id;
let index = self.items.len();
self.items.push(item);
self.positions.insert(id, index);
Ok(())
}
}
传播错误:
rust
impl Catalog {
pub fn complete(&mut self, id: u64) -> Result<(), CatalogError> {
let index = self.positions.get(&id).copied()
.ok_or(CatalogError::NotFound(id))?;
let item = self.items.get_mut(index)
.ok_or(CatalogError::NotFound(id))?;
if item.done {
return Err(CatalogError::AlreadyDone(id));
}
item.done = true;
Ok(())
}
}
? 不是忽略错误,而是成功时取值、失败时提前返回,并在需要时转换错误。库返回结构化错误,应用层决定提示、重试、记录还是退出。
五、Package、Crate 与 Module
三个概念不要混淆:
| 概念 | 含义 |
|---|
| Package | Cargo.toml 定义的 Cargo 项目 |
| Crate | 编译单元,根通常是 lib.rs 或 main.rs |
| Module | crate 内的命名空间与可见性边界 |
一个 package 最多有一个 library crate,也可以有多个 binary crate。库入口 src/lib.rs 负责声明模块并设计公开门面:
rust
mod catalog;
mod error;
mod model;
pub use catalog::Catalog;
pub use error::CatalogError;
pub use model::WorkItem;
内部文件布局没有直接暴露给使用者。以后即使拆分 catalog.rs,只要 crate 根的重新导出保持不变,调用方仍可写 work_catalog::Catalog。
六、可见性与 use:默认私有,按需开放
model.rs:
rust
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkItem {
id: u64,
title: String,
done: bool,
}
impl WorkItem {
pub fn new(id: u64, title: impl Into<String>) -> Self {
Self { id, title: title.into(), done: false }
}
pub fn id(&self) -> u64 { self.id }
pub fn title(&self) -> &str { &self.title }
pub fn is_done(&self) -> bool { self.done }
pub(crate) fn mark_done(&mut self) {
self.done = true;
}
}
- 无 pub:模块私有;
- pub:对 crate 外公开;
- pub(crate):只在当前 crate 内公开;
- pub(super):仅父模块范围可见。
字段保持私有,调用者只能通过构造函数和方法建立合法状态。mark_done 仅给内部 Catalog 使用,外部不能绕开错误检查。
catalog.rs 引入依赖:
rust
use std::collections::HashMap;
use crate::error::CatalogError;
use crate::model::WorkItem;
use 只缩短路径,不会移动值。as 可解决同名冲突,pub use 则兼具引入与重新导出,是设计公开门面的关键工具。
七、最小实现
src/catalog.rs:
rust
use std::collections::HashMap;
use crate::{CatalogError, WorkItem};
#[derive(Debug, Default)]
pub struct Catalog {
items: Vec<WorkItem>,
positions: HashMap<u64, usize>,
}
impl Catalog {
pub fn len(&self) -> usize { self.items.len() }
pub fn is_empty(&self) -> bool { self.items.is_empty() }
pub fn add(&mut self, item: WorkItem) -> Result<(), CatalogError> {
if self.positions.contains_key(&item.id()) {
return Err(CatalogError::DuplicateId(item.id()));
}
let id = item.id();
let index = self.items.len();
self.items.push(item);
self.positions.insert(id, index);
Ok(())
}
pub fn get(&self, id: u64) -> Option<&WorkItem> {
let index = *self.positions.get(&id)?;
self.items.get(index)
}
pub fn complete(&mut self, id: u64) -> Result<(), CatalogError> {
let index = self.positions.get(&id).copied()
.ok_or(CatalogError::NotFound(id))?;
let item = self.items.get_mut(index)
.ok_or(CatalogError::NotFound(id))?;
if item.is_done() {
return Err(CatalogError::AlreadyDone(id));
}
item.mark_done();
Ok(())
}
pub fn iter(&self) -> impl Iterator<Item = &WorkItem> {
self.items.iter()
}
}
src/lib.rs:
rust
//! 一个用于学习 Rust 工程骨架的内存工作项目录。
mod catalog;
mod error;
mod model;
pub use catalog::Catalog;
pub use error::CatalogError;
pub use model::WorkItem;
工程边界已经形成:模型维护自身状态,Catalog 维护集合与索引,错误模块描述失败,lib.rs 提供稳定入口。
八、单元测试:靠近实现验证内部规则
在 catalog.rs 底部:
rust
#[cfg(test)]
mod tests {
use super::*;
fn sample_item(id: u64) -> WorkItem {
WorkItem::new(id, format!("item-{id}"))
}
#[test]
fn add_and_get_item() {
let mut catalog = Catalog::default();
catalog.add(sample_item(1)).unwrap();
let item = catalog.get(1).expect("工作项应存在");
assert_eq!(item.id(), 1);
assert!(!item.is_done());
}
#[test]
fn duplicate_id_is_rejected() {
let mut catalog = Catalog::default();
catalog.add(sample_item(1)).unwrap();
assert_eq!(
catalog.add(sample_item(1)).unwrap_err(),
CatalogError::DuplicateId(1),
);
}
#[test]
fn complete_twice_is_rejected() {
let mut catalog = Catalog::default();
catalog.add(sample_item(1)).unwrap();
catalog.complete(1).unwrap();
assert_eq!(
catalog.complete(1).unwrap_err(),
CatalogError::AlreadyDone(1),
);
}
}
#[cfg(test)] 只在测试构建时编译该模块。单元测试可访问私有项,适合检查内部不变量。
常用断言:assert!、assert_eq!、assert_ne!。debug_assert! 系列通常只在非优化构建启用,适合验证内部假设,不能替代正式错误处理。
测试也可返回 Result 以便使用 ?:
rust
#[test]
fn complete_flow() -> Result<(), CatalogError> {
let mut catalog = Catalog::default();
catalog.add(WorkItem::new(1, "写测试"))?;
catalog.complete(1)?;
assert!(catalog.get(1).unwrap().is_done());
Ok(())
}
九、集成测试:只从使用者视角看公开 API
tests/catalog_flow.rs:
rust
use work_catalog::{Catalog, CatalogError, WorkItem};
#[test]
fn user_can_add_and_complete_an_item() {
let mut catalog = Catalog::default();
catalog.add(WorkItem::new(1, "发布版本")).unwrap();
catalog.complete(1).unwrap();
assert!(catalog.get(1).unwrap().is_done());
}
#[test]
fn missing_item_returns_public_error() {
let mut catalog = Catalog::default();
assert_eq!(
catalog.complete(404).unwrap_err(),
CatalogError::NotFound(404),
);
}
tests/ 下每个文件是独立 crate,只能访问公开 API。它能检验 API 是否好用、pub use 是否完整、内部细节是否意外泄漏。
常用命令:
bash
cargo test
cargo test duplicate_id
cargo test -- --show-output
cargo test -- --test-threads=1
cargo test -- --ignored
默认测试可能并行运行。依赖端口、文件或环境变量的测试应隔离资源;只有确实共享资源时才考虑串行。
十、文档与文档测试:公开 API 的第一层界面
项级文档使用 ///:
rust
/// 一个按插入顺序保存工作项、按编号建立索引的目录。
///
/// # Examples
///
/// ```
/// use work_catalog::{Catalog, WorkItem};
///
/// let mut catalog = Catalog::default();
/// catalog.add(WorkItem::new(1, "写文档"))?;
/// assert_eq!(catalog.get(1).unwrap().title(), "写文档");
/// # Ok::<(), work_catalog::CatalogError>(())
/// ```
#[derive(Debug, Default)]
pub struct Catalog {
// ...
}
crate 或 module 文档使用 //!。常见标题有 # Examples、# Errors、# Panics、# Safety。
文档代码块会被 cargo test 编译和运行,能同时验证示例、公开路径和用户体验。以 # 开头的辅助行参与编译但不展示;只编译不运行可用 no_run,故意验证无法编译可用 compile_fail。
生成文档:
bash
cargo doc --no-deps
cargo doc --no-deps --open
不要用 ignore 掩盖本应修复的过期示例。能执行的文档才不容易与代码分叉。
十一、基准测试与 CI
普通测试回答「对不对」,基准测试回答「在受控条件下有多快」。稳定 Rust 项目通常使用 Criterion 等工具。基准应明确输入规模、隔离初始化、避免结果被优化掉并进行多次采样;一次纳秒级数字不足以下结论。
最小 GitHub Actions:
yaml
name: Rust CI
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- run: cargo fmt --all -- --check
- run: cargo clippy --all-targets --all-features -- -D warnings
- run: cargo test --all-features
- run: cargo doc --no-deps
它把格式、静态检查、测试和文档生成变成每次变更的自动门禁。第三方 Action 会执行外部代码,生产仓库应固定可信版本并审查权限与供应链风险。
十二、常见误区
- 小数据也维护多个索引:先测量需求,再承担一致性成本。
- 外部失败大量 unwrap:库返回 Result,应用边界决定是否退出。
- 错误只用 String:调用者只能解析文字;稳定领域错误优先使用枚举。
- 所有字段和模块都 pub:公开 API 越大,未来修改成本越高。
- 文件越多越工程化:模块应围绕变化原因和边界拆分。
- 只测成功路径:重复编号、找不到、重复完成才是规则密集区。
- 集成测试依赖内部路径:通常说明公开门面设计不完整。
- 文档示例不编译:不可执行示例最容易随 API 漂移。
- 一次 benchmark 就宣布优化成功:必须说明环境、规模和统计方法。
十三、实践练习
练习一:选择主存储
只用 Vec<WorkItem> 实现新增、查询和删除,测量或推导各操作复杂度。再增加 HashMap<u64, usize> 索引,列出必须同步维护的路径。
练习二:扩展错误类型
增加 InvalidTitle 与 CapacityExceeded,让调用方可以通过枚举变体区分原因。为错误实现 Display,但测试优先断言变体而不是错误字符串。
练习三:收紧公开 API
把字段设为私有,只公开构造、查询和状态转换方法。运行集成测试,确认测试只能使用 crate 对外承诺的接口。
练习四:补一条文档测试
为 Catalog::add 写可运行示例,包含成功路径和重复编号错误。执行 cargo test --doc,确保文档代码不会随实现变化而失效。
练习五:建立 CI 最小门禁
设计一组命令,使格式错误、warning、单元测试失败、集成测试失败和文档测试失败都会阻止合并。说明基准测试为什么通常不适合作为每次提交的硬门禁。
十四、总结
- Vec<T> 擅长顺序存储与遍历,HashMap<K, V> 擅长按键访问;组合使用会引入一致性成本。
- panic! 表示无法继续维护程序不变量,Result 表示调用者有机会处理的失败。
- Package、Crate 与 Module 解决不同层级的组织问题;文件拆分只是模块设计的一种实现方式。
- 默认私有能缩小修改影响面,公开 API 应只暴露稳定契约。
- 单元测试验证内部规则,集成测试验证使用者视角,文档测试同时验证示例与接口。
- CI 的价值是把团队约定变成自动检查,而不是简单增加命令数量。
- 工程化的核心是让数据、错误、可见性和验证边界彼此一致。
十五、下一章
这三章已经形成完整基础主线:用类型建模数据,用泛型与 Trait 建立抽象,再用集合、错误、模块、测试与文档搭起工程骨架。
下一章把本章的工程骨架真正做成一个命令行程序,并用 linefind 的演进串起闭包、迭代器、环境变量、错误输出与测试:
- 第7章:函数式 Rust —— 闭包、迭代器与命令行实战
- Rust 深度教程
- 第4章:数据建模
- 第5章:抽象能力
- 第7章:函数式 Rust
- Pi 第12章:测试模式 —— 测试替身与断言面的工程对照