Rust模块系统与crate发布实践:从私有项目到开源分享

一、模块系统的困惑:mod、use、pub到底怎么组织
Rust的模块系统是我学Rust时最困惑的部分之一——不是概念难,而是"怎么做"不清晰。mod.rs和文件名的关系、use的路径规则、pub的可见性层级、mod声明和文件系统的对应——每个单独看都懂,组合起来就乱。
更困惑的是,什么时候该拆模块,什么时候该拆crate?模块和crate的边界在哪里?这些问题在教程里往往一笔带过,但实际写项目时天天遇到。
本文梳理Rust模块系统的核心规则,并记录我发布第一个crate的完整过程。
二、模块系统核心规则
2.1 模块声明与文件系统
graph TB
A[src/lib.rs] --> B[mod scanner]
A --> C[mod output]
A --> D[mod config]
B --> E[src/scanner.rs]
C --> F[src/output.rs]
D --> G[src/config.rs]
E --> H[src/scanner/mod.rs 或 src/scanner.rs]
B --> I[mod deep]
I --> J[src/scanner/deep.rs]
2.2 模块声明的两种方式
// src/lib.rs
// 方式1:内联模块(小模块适合)
mod utils {
pub fn format_size(bytes: u64) -> String {
if bytes < 1024 {
format!("{} B", bytes)
} else {
format!("{:.1} KB", bytes as f64 / 1024.0)
}
}
}
// 方式2:外部文件模块
mod scanner; // 对应 src/scanner.rs 或 src/scanner/mod.rs
mod output; // 对应 src/output.rs
mod config; // 对应 src/config.rs
2.3 可见性规则
// 默认私有,需要pub才能被外部访问
mod internal {
fn private_fn() {} // 仅本模块可见
pub fn public_fn() {} // 本模块及父模块可见
pub(crate) fn crate_fn() {} // 整个crate可见
pub(super) fn parent_fn() {} // 仅父模块可见
}
// 结构体字段也是私有的
pub struct Config {
pub path: String, // 公开字段
max_depth: usize, // 私有字段,外部不能直接访问
}
impl Config {
pub fn new(path: String, max_depth: usize) -> Self {
Self { path, max_depth }
}
pub fn max_depth(&self) -> usize {
self.max_depth // 通过方法暴露私有字段
}
}
2.4 use与路径
// 绝对路径从crate根开始
use crate::scanner::FileScanner;
// 相对路径从当前模块开始
use super::config::AppConfig; // 父模块
use self::utils::format_size; // 当前模块
// 惯用法:函数用完整路径,类型用短路径
use std::collections::HashMap;
use std::fs::read_dir; // 函数可以完整路径
// 重命名避免冲突
use std::io::Result as IoResult;
use anyhow::Result;
三、从模块到crate:拆分决策
3.1 何时拆成独立crate
graph TD
A{是否被多个项目复用?} -->|是| B[拆成独立crate]
A -->|否| C{是否需要独立版本?}
C -->|是| B
C -->|否| D{模块是否>500行?}
D -->|是| E[拆成子模块]
D -->|否| F[保持当前结构]
3.2 crate发布准备
Cargo.toml配置
[package]
name = "dust-scanner" # crate名称,全局唯一
version = "0.1.0" # 语义化版本
edition = "2021"
authors = ["Chen Yiming <yiming@example.com>"]
license = "MIT" # 必须指定license
description = "A disk usage scanner library"
repository = "https://github.com/example/dust-scanner"
keywords = ["disk", "scanner", "filesystem"]
categories = ["filesystem"]
[dependencies]
anyhow = "1.0"
serde = { version = "1.0", features = ["derive"] }
[dev-dependencies]
tempfile = "3.8" # 测试用临时目录
文档注释
/// 扫描指定目录,返回每个子目录的大小
///
/// # Arguments
///
/// * `path` - 要扫描的根目录路径
/// * `max_depth` - 最大递归深度
///
/// # Examples
///
/// ```
/// use dust_scanner::scan_directory;
///
/// let entries = scan_directory(".", 5).unwrap();
/// for entry in &entries {
/// println!("{}: {} bytes", entry.path.display(), entry.size);
/// }
/// ```
///
/// # Errors
///
/// 当路径不存在或不是目录时返回错误
pub fn scan_directory(
path: &str,
max_depth: usize,
) -> Result<Vec<DirEntry>> {
// ...
}
3.3 测试组织
// 单元测试:和代码放在一起
pub fn format_size(bytes: u64) -> String {
match bytes {
0..1024 => format!("{} B", bytes),
1024..1048576 => format!("{:.1} KB", bytes as f64 / 1024.0),
_ => format!("{:.1} MB", bytes as f64 / 1048576.0),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_size_bytes() {
assert_eq!(format_size(512), "512 B");
}
#[test]
fn test_format_size_kb() {
assert_eq!(format_size(2048), "2.0 KB");
}
#[test]
fn test_format_size_mb() {
assert_eq!(format_size(3 * 1048576), "3.0 MB");
}
}
// 集成测试:tests/目录下
// tests/integration_test.rs
use dust_scanner::scan_directory;
use tempfile::TempDir;
#[test]
fn test_scan_empty_directory() {
let tmp = TempDir::new().unwrap();
let entries = scan_directory(
tmp.path().to_str().unwrap(), 5
).unwrap();
assert!(entries.is_empty());
}
#[test]
fn test_scan_with_files() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("test.txt"), "hello").unwrap();
let entries = scan_directory(
tmp.path().to_str().unwrap(), 5
).unwrap();
assert!(!entries.is_empty());
}
四、发布流程
4.1 发布前检查清单
# 1. 运行所有测试
cargo test --all
# 2. 检查文档
cargo doc --open
# 3. 运行Clippy
cargo clippy -- -D warnings
# 4. 检查格式
cargo fmt --check
# 5. 干跑发布(不实际发布)
cargo publish --dry-run
# 6. 检查包大小
cargo package --list
4.2 发布命令
# 首次登录crates.io
cargo login <api-token>
# 发布
cargo publish
# 版本更新后重新发布
# 修改Cargo.toml中的version
cargo publish
4.3 版本号规则
0.1.0 → 0.1.1 修复bug,不改变API
0.1.0 → 0.2.0 新增功能,可能改变API(0.x阶段不保证兼容)
1.0.0 → 1.1.0 新增功能,向后兼容
1.1.0 → 2.0.0 破坏性变更
五、架构权衡与边界分析
5.1 模块 vs crate
模块是编译单元内的代码组织,crate是独立的编译和发布单元。模块间零开销访问,crate间有API边界。建议:项目内用模块组织,跨项目复用才拆crate。过早拆crate会增加编译时间和维护成本。
5.2 文档注释的投入
文档注释写起来费时间,但对crate的可用性至关重要。建议:公开API必须有文档注释和示例代码,私有函数不需要。cargo doc生成的文档质量取决于注释的投入。
5.3 0.x版本的承诺
0.x版本意味着"API不稳定,随时可能变"。不要害怕在0.x阶段做破坏性变更,但要在CHANGELOG中记录。1.0之后再做破坏性变更就要慎重。
六、总结
Rust模块系统的核心规则:mod声明对应文件系统,pub控制可见性,use引入路径。模块是代码组织的基本单位,crate是发布和复用的基本单位。拆分决策的关键是"是否需要跨项目复用"。
发布crate的流程:写好文档注释→运行测试→Clippy检查→dry-run验证→正式发布。版本号遵循语义化版本规范,0.x阶段允许破坏性变更。
落地建议:项目初期用模块组织代码,稳定后再考虑拆crate;公开API必须写文档注释和示例;发布前用cargo publish --dry-run验证;0.x阶段大胆迭代,1.0之后再保证兼容性。

1388

被折叠的 条评论
为什么被折叠?



