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.rs2.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 --list4.2 发布命令
# 首次登录crates.io cargo login <api-token> # 发布 cargo publish # 版本更新后重新发布 # 修改Cargo.toml中的version cargo publish4.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之后再保证兼容性。