10.1 这节课解决什么问题
前 9 课写的都是"一个 main.rs 里全塞下"的玩具。真实工程(比如实战篇的共享核心)动辄几十个文件:models、network、store、api......它们如何组织、如何互相引用?依赖版本怎么锁定?代码怎么保证不倒退?------工程化三件事:
rust
模块系统 拆文件、声明可见性,组织成清晰的代码树
依赖管理 引入第三方 crate、锁版本、构建可复现
测试体系 单元 + 集成 + 文档三种测试,让行为可回归
本课也是基础篇的验收课:学完你会把 1-9 课的能力收进一个规范工程,达到"能写、能测、能提交"的程度。
10.2 模块系统:mod / use / pub
10.2.1 最小模块:文件内拆分 + 可见性
rust
mod math { // 声明模块
pub fn add(a: i32, b: i32) -> i32 { a + b } // pub:模块外部可见
fn secret() {} // 默认私有:只在本模块(及子模块)内可见
}
fn main() {
println!("{}", math::add(1, 2)); // 模块名::路径 访问
// math::secret(); // ❌ 私有,访问不到
}
可见性速记(默认一切私有,逐级放开):
rust
(不写) private:仅当前模块及其子模块可见
pub 对所有人可见(对外部 crate 也可见)
pub(crate) 仅本 crate 可见(跨模块随便用,但不对外暴露)
pub(super) 仅父模块可见
💡 从第 6 课起你写的每个
pub fn、pub struct都是这套规则的实例。规则本质只有一个:"谁需要看见谁,就显式声明",编译器强迫你把"API 边界"想清楚。
10.2.2 拆文件的规范姿势:目录即模块
模块树和文件树一一对应,这是 Rust 工程的基本组织方式。约定如下(后续每课示例都以这种结构展开):
bash
my_app/
├── Cargo.toml
└── src/
├── main.rs # crate 根:声明顶层模块
├── models.rs # 顶层模块 models(对应文件 models.rs)
└── services/ # 顶层模块 services(对应目录)
├── mod.rs # services 模块入口:再声明子模块
├── auth.rs # services::auth
└── chat.rs # services::chat
rust
// src/main.rs ------ 用 mod 声明各顶层模块(文件名去 .rs 后缀)
mod models; // 找到 models.rs
mod services; // 找到 services/mod.rs
use models::User; // 之后直接写 User
use services::auth::login; // 引入深路径函数
fn main() {
let u = User::new(String::from("小灵"));
let ok = login(&u);
println!("登录:{ok}");
}
rust
// src/models.rs
pub struct User { // pub:要被 services 用
pub name: String, // 字段也要 pub 才能跨模块读写
}
impl User {
pub fn new(name: String) -> User {
User { name }
}
}
rust
// src/services/mod.rs ------ 目录入口:声明并"转发"子模块
pub mod auth;
pub mod chat;
rust
// src/services/auth.rs
use crate::models::User; // crate:: 表示"从 crate 根"找路径
pub fn login(u: &User) -> bool {
!u.name.is_empty()
}
三种路径写法(务必分清什么时候用哪个):
rust
crate::models::User 绝对路径:从当前 crate 根开始(跨模块引用最稳)
super::models::User super = 父模块,相对父级找(子模块里常用)
use models::User 先用 use 引入,之后直接写 User(日常主力)
习惯约定:
use 引入时不要写整棵长路径,use 到"够用的最近一层"即可,
例如 use services::auth::{login, logout};
💡 Rust 中"声明"和"引用"是两件事:
mod services;是把目录挂进模块树 ,use services::auth::login;是把某个项引入当前作用域 。搞混时看报错:unresolved import通常是use路径写错或mod忘声明。
10.2.3 use 的常用形态
rust
use models::{User, Profile}; // 同一父级多个项:花括号
use services::auth::*; // 通配(谨慎用,会引入未知名字)
use crate::models::User as AppUser; // as 起别名(解决同名冲突)
// use 还可以嵌在函数里,只在局部生效(罕见,但合法)
10.2.4 模块和 privacy:pub struct 但字段私有
一个常见设计模式------"构造方式受限"的领域对象:
rust
// models.rs
pub struct Session { // 结构体对大家可见
id: String, // 字段对外不可见
created_at: u64,
}
impl Session {
pub fn new(id: String) -> Session { // 只能通过构造器创建
Session { id, created_at: now_ms() }
}
pub fn id(&self) -> &str { &self.id } // 只读 getter
}
fn now_ms() -> u64 { 1_700_000_000_000 }
外部无法直接拼 Session { id: ... },只能走 Session::new(...)------模块边界 = 封装边界。实战篇定义领域模型时会大量用到这种"私有字段 + 构造器 + getter",保证非法状态在编译期/构造期就被拦下。
10.3 Cargo 依赖管理
10.3.1 声明依赖与版本语义
第 7 课已经用过 thiserror/anyhow。完整形态:
toml
[package]
name = "my_app"
version = "0.1.0"
edition = "2021" # 见 1.3 的 edition 说明
[dependencies]
serde = { version = "1", features = ["derive"] } # 默认 features
tokio = { version = "1", features = ["full"] } # 第 13 课
reqwest = { version = "0.12", default-features = false, features = ["json"] }
thiserror = "2"
anyhow = "1"
[dev-dependencies] # 仅测试/示例需要的依赖
tempfile = "3"
[profile.release] # 发布构建调优(16 课 FFI 发布前必调)
opt-level = 3
lto = true
codegen-units = 1
版本号 "1.6" 的语义(Cargo 遵循 semver):
ini
"1.6" = 允许 >=1.6 且 <2.0.0 (只会升级小版本/补丁,不会破大版本)
"=1.6.2" = 精确锁定
"1.6.2" = 允许 1.6.2..2.0.0(等价 ^1.6.2)
只要依赖方遵守 semver,写 "1" / "0.12" 这种"大版本号"通常安全;不放心就用 cargo update -p <crate> 精确升级并跑测试。
10.3.2 Cargo.lock:可复现构建
csharp
Cargo.toml 你写"宽泛约束"(如 "1")
Cargo.lock 第一次构建后自动生成,把每个依赖钉到精确版本
------ 可执行程序(bin)要把 lock 提交进 git,保证大家/CI/发布版一致
------ 库项目(lib)通常忽略 lock,让使用方各自解析
- 首次
cargo build会解析依赖生成 lock,之后每次构建都按 lock 来; - 想升级某依赖:
cargo update -p serde(只升 serde)或cargo update(全部按 Cargo.toml 约束升); - 国内网络慢?在
$CARGO_HOME/config.toml配镜像(第 1 课已给样例):把[source.crates-io]的replace-with指向 rsproxy/中科大镜像即可。
10.3.3 bin / lib:包既可以是程序也可以是库
一个 crate 可以同时有可执行入口和库接口:
toml
# 只需在 src/ 加 lib.rs,Cargo 自动识别
[lib]
name = "my_app" # 库名(默认取 package.name)
path = "src/lib.rs"
rust
src/
├── lib.rs # 库根:外部 crate 通过 use my_app::... 使用
├── main.rs # 二进制根:use my_app::... 复用库
└── ...
rust
// lib.rs:把要"对外"的都 pub 出去
pub mod models;
pub mod services;
rust
// main.rs:可执行文件通过包名引入库
use my_app::models::User;
fn main() {
let u = User::new(String::from("cli"));
println!("{:?}", u.name); // (User 记得 derive Debug 或只用 getter)
}
💡 这是本课程项目的标准形态:逻辑全放 lib(可测试、可被上层复用),main 只做入口。实战篇 17 课会把"共享核心"建成 lib crate。
10.3.4 workspace:多个 crate 一个仓库(预告)
第 17 课实战会用 workspace 管理"核心 lib + 不同壳":
toml
# 根 Cargo.toml
[workspace]
members = ["core", "app-cli", "ffi-bindings"]
resolver = "2"
- 一个仓库内多个 crate 共享一个
Cargo.lock、一套依赖缓存; - 本地路径依赖:
core里写[dependencies] my-core = { path = "../core" }可直接互相引用,发布时改成 crates.io 版本即可。细节在实战篇展开,此处先建立概念。
10.4 测试体系:三种测试让代码可回归
10.4.1 单元测试(写在模块里,#[cfg(test)])
惯例:每个模块底部放 #[cfg(test)] mod tests { ... }。#[cfg(test)] 表示"只在跑测试时编译这段"。
rust
// models.rs
pub fn add(a: i32, b: i32) -> i32 { a + b }
#[cfg(test)]
mod tests {
use super::add; // super:: 引用父模块的私有函数------测试能测私有!
#[test]
fn test_add() {
assert_eq!(add(2, 3), 5);
assert_ne!(add(2, 2), 5);
assert!(add(-1, 1) == 0);
}
#[test]
#[should_panic] // 期望 panic(如越界类防御逻辑)
fn test_index_out_of_bounds() {
let v = [1, 2, 3];
let _ = v[10];
}
#[test]
fn test_result_return() -> Result<(), String> { // 测试也能返回 Result
if add(1, 1) == 2 { Ok(()) } else { Err("加错了".into()) }
}
}
常用断言宏:
css
assert!(条件) 表达式为假 → 测试失败
assert_eq!(a, b) 两边相等(要 a、b 实现 PartialEq + Debug 才能打印差异)
assert_ne!(a, b)
assert_eq!(a, b, "附加信息 {}", x) 后面可跟格式串,失败时打印上下文
10.4.2 运行与过滤
bash
cargo test # 跑全部
cargo test test_add # 只跑名字含 test_add 的
cargo test -- --nocapture # 显示 println!(测试默认吞输出)
cargo test -- --test-threads=1 # 串行(有共享状态时)
10.4.3 集成测试(tests/ 目录)
tests/ 目录下的每个文件是独立 crate,只能访问 lib 的公开 API(所以代码要放 lib.rs 才测得到):
bash
src/lib.rs
tests/
└── api_test.rs
rust
// tests/api_test.rs
use my_app::add; // 像外部用户一样用这个库
#[test]
fn it_adds_from_outside() {
assert_eq!(add(40, 2), 42);
}
💡 如果项目只有 main.rs(纯二进制),tests/ 里
use 包名会失败。想被集成测试就用 lib + 薄 main 的形态,这是把逻辑迁进 lib.rs 的又一个理由。
10.4.4 文档测试(doc tests)
代码注释里的 ```rust 代码块会被当测试跑------保证文档示例永不过期:
rust
/// 返回两个数相加。
///
/// ```
/// let s = my_app::add(1, 2);
/// assert_eq!(s, 3);
/// ```
pub fn add(a: i32, b: i32) -> i32 { a + b }
bash
cargo test --doc # 单独跑文档测试
💡 第 16 课 FFI 章节会给每个
#[uniffi::export]的接口写文档测试样例,这一条在"文档即契约"里非常实用。
10.5 代码质量三件套:fmt / clippy / check
bash
cargo fmt # 统一格式(rustfmt),提交前必跑
cargo clippy -- -D warnings # 静态检查(把警告当错误,CI 常用)
cargo check # 只做类型检查,秒级反馈,比 build 快(迭代期主力)
bash
# 本地提交前的标准动作
cargo fmt --check # 没格式化就失败
cargo clippy --all-targets -- -D warnings
cargo test
rustfmt有默认风格,别纠结自己排版------cargo fmt一把梭;clippy会提示大量"更 Rust 的写法"(如该用iter().sum()别手写循环、needless_range_loop等),每次提示都读一读,是免费的进阶老师;- 编辑器里 rust-analyzer 已在做 check 级反馈,跑 clippy 是更深一层。
10.6 基础篇总验收:把前 9 课收进一个工程
作为基础篇收尾,请独立完成一个综合练习工程(本次是"整理 + 测试",不是新知识):
需求:单词统计 CLI 库化
cargo new word_counter;新建src/lib.rs,把models.rs、textproc.rs两个模块挂进去;textproc:把第 9 课的top_words挪进来并pub,入参&str+ 返回Vec<(String, usize)>;models:pub struct WordStat { pub word: String, pub count: usize },并impl From<(String, usize)>便于转换(练 From);src/main.rs改为use word_counter::textproc::top_words;从std::env::args读一个文本路径或字符串参数并打印 Top5;- 写单元测试:空串、纯空白、大小写归一、中文分词(
split_whitespace对中文按空格分,行为要写清楚在测试注释里)、TopN 截断; - tests/ 下加一个集成测试,从外部 crate 调用
top_words验证公开 API; cargo fmt+cargo clippy -- -D warnings+cargo test全部通过。
验收门禁(基础篇毕业自检) :无需翻笔记,能在 30 秒内写出------mod/use/pub 的作用;lib.rs 与 main.rs 的职责分工;cargo test 三兄弟的运行方式;Cargo.lock 对 bin 项目为什么要提交;一条"改动前必跑"的质量命令链。
✅ 本节小结
- 模块 :目录即模块、
mod声明 /use引入 /pub放行;crate::、super::、相对use三种路径;默认私有=封装边界; - 工程结构 :lib + 薄 main;
pubAPI 与私有实现分离; - 依赖 :
Cargo.toml宽约束 +Cargo.lock钉精确版本(bin 提交 lock);semver 与cargo update;[profile.release]、[features]、workspace 概念; - 测试 :单元测试(
#[cfg(test)]+#[test]+ 断言宏 +#[should_panic]+Result返回)、集成测试(tests/ 只能碰公开 API,故逻辑进 lib)、文档测试(注释代码块即用例); - 质量链 :
fmt → clippy -D warnings → test,提交前必跑; - 毕业标准:能独立把一个多文件逻辑库 + 薄 CLI + 三层测试组织成可提交的工程。
基础篇结束语 :到这里你已经拥有 Rust 的完整"语言地基"------变量与类型、所有权与生命周期、struct/enum、trait/泛型、迭代器、错误处理与工程化。下一阶段进入进阶篇,第 11 课从智能指针与 interior mutability 开始,你会逐渐看到"语言特性如何在真实系统设计里被组合使用"。