从零到一构建系统级工具的完整过程:我的第一个Rust项目复盘
从零到一构建系统级工具的完整过程:我的第一个Rust项目复盘
一、为什么选择从零构建:教程项目的局限
跟着教程写Rust项目,编译通过了就觉得"学会了"。但真正从零开始——没有教程的步骤指引,没有现成的项目结构,只有自己定义的需求——才发现差距有多大:不知道怎么组织代码、不知道错误处理该用什么模式、不知道测试该怎么写、不知道CI怎么配。
我决定从零构建一个系统级工具:dust——一个磁盘使用分析工具,类似du但带可视化。这个选择的原因:功能明确、涉及文件系统操作、需要递归遍历、有格式化输出——刚好覆盖Rust系统编程的核心场景。
本文复盘整个构建过程,重点记录踩过的坑和学到的经验。
二、项目规划与设计
2.1 开发阶段规划
graph LR A[需求定义] --> B[MVP实现] B --> C[功能完善] C --> D[错误处理] D --> E[性能优化] E --> F[测试与CI] F --> G[发布]2.2 需求定义
MVP(最小可行产品)只需要三个功能:
- 递归扫描目录,计算每个子目录的大小
- 按大小排序输出
- 支持深度限制
// 最初的需求定义,直接写在main.rs里 fn main() { let args: Vec<String> = std::env::args().collect(); let path = args.get(1).unwrap_or(&".".to_string()).clone(); let max_depth: usize = args.get(2) .and_then(|s| s.parse().ok()) .unwrap_or(5); let result = scan_directory(&path, max_depth, 0); match result { Ok(entries) => { for entry in entries { println!("{} {}", entry.size, entry.path); } } Err(e) => eprintln!("Error: {}", e), } }三、MVP实现:先跑起来
3.1 核心数据结构
use std::path::PathBuf; #[derive(Debug)] struct DirEntry { path: PathBuf, size: u64, is_dir: bool, } fn scan_directory( path: &str, max_depth: usize, current_depth: usize, ) -> Result<Vec<DirEntry>, std::io::Error> { let mut entries = Vec::new(); let root = std::path::Path::new(path); if !root.is_dir() { return Err(std::io::Error::new( std::io::ErrorKind::NotADirectory, format!("{} is not a directory", path), )); } scan_recursive(root, max_depth, current_depth, &mut entries)?; Ok(entries) } fn scan_recursive( dir: &std::path::Path, max_depth: usize, depth: usize, results: &mut Vec<DirEntry>, ) -> Result<(), std::io::Error> { if depth > max_depth { return Ok(()); } let mut dir_size: u64 = 0; for entry in std::fs::read_dir(dir)? { let entry = entry?; let metadata = entry.metadata()?; if metadata.is_dir() { let sub_path = entry.path(); scan_recursive(&sub_path, max_depth, depth + 1, results)?; // 子目录大小在递归后累加 if let Some(sub_entry) = results.iter() .find(|e| e.path == sub_path) { dir_size += sub_entry.size; } } else { dir_size += metadata.len(); } } results.push(DirEntry { path: dir.to_path_buf(), size: dir_size, is_dir: true, }); Ok(()) }3.2 MVP的问题
MVP能跑,但问题很多:
- 错误处理太粗糙,
unwrap到处都是 - 递归中查找子目录大小效率很低(O(n)查找)
- 没有权限错误处理(
Permission denied直接panic) - 输出格式不好看
四、重构:从"能跑"到"好用"
4.1 错误处理重构
use anyhow::{Context, Result}; fn scan_directory(path: &str, max_depth: usize) -> Result<Vec<DirEntry>> { let root = std::path::Path::new(path); anyhow::ensure!(root.is_dir(), "{} is not a directory", path); let mut entries = Vec::new(); scan_recursive(root, max_depth, 0, &mut entries)?; Ok(entries) } fn scan_recursive( dir: &std::path::Path, max_depth: usize, depth: usize, results: &mut Vec<DirEntry>, ) -> Result<()> { if depth > max_depth { return Ok(()); } let mut dir_size: u64 = 0; for entry in std::fs::read_dir(dir) .with_context(|| format!("Cannot read dir: {}", dir.display()))? { let entry = match entry { Ok(e) => e, Err(e) => { // 权限错误不中断,跳过并记录 eprintln!("Warning: {}", e); continue; } }; let metadata = match entry.metadata() { Ok(m) => m, Err(e) => { eprintln!("Warning: {} - {}", entry.path().display(), e); continue; } }; if metadata.is_dir() { let sub_path = entry.path(); scan_recursive(&sub_path, max_depth, depth + 1, results)?; } else { dir_size += metadata.len(); } } results.push(DirEntry { path: dir.to_path_buf(), size: dir_size, is_dir: true, }); Ok(()) }4.2 用HashMap替代线性查找
use std::collections::HashMap; fn scan_with_sizes( dir: &std::path::Path, max_depth: usize, ) -> Result<HashMap<PathBuf, u64>> { let mut sizes = HashMap::new(); scan_recursive_v2(dir, max_depth, 0, &mut sizes)?; Ok(sizes) } fn scan_recursive_v2( dir: &std::path::Path, max_depth: usize, depth: usize, sizes: &mut HashMap<PathBuf, u64>, ) -> Result<u64> { if depth > max_depth { return Ok(0); } let mut dir_size: u64 = 0; for entry in std::fs::read_dir(dir) .with_context(|| format!("Cannot read: {}", dir.display()))? { let entry = match entry { Ok(e) => e, Err(_) => continue, }; let metadata = match entry.metadata() { Ok(m) => m, Err(_) => continue, }; if metadata.is_dir() { let sub_size = scan_recursive_v2( &entry.path(), max_depth, depth + 1, sizes )?; dir_size += sub_size; } else { dir_size += metadata.len(); } } sizes.insert(dir.to_path_buf(), dir_size); Ok(dir_size) }4.3 可视化输出
fn display_tree( sizes: &HashMap<PathBuf, u64>, root: &std::path::Path, max_depth: usize, ) { let root_size = sizes.get(root).copied().unwrap_or(0); let bar_width = 40; // 按大小排序 let mut entries: Vec<_> = sizes.iter().collect(); entries.sort_by(|a, b| b.1.cmp(a.1)); for (path, &size) in &entries { if !path.starts_with(root) { continue; } let relative = path.strip_prefix(root).unwrap_or(path); let ratio = if root_size > 0 { size as f64 / root_size as f64 } else { 0.0 }; let filled = (ratio * bar_width as f64) as usize; let bar: String = "█".repeat(filled) + &"░".repeat(bar_width - filled); println!("{:>10} │{}│ {}", format_size(size), bar, relative.display() ); } }五、架构权衡与边界分析
5.1 同步 vs 异步
文件系统遍历用同步API更简单,异步的收益不大(磁盘IO不是网络IO那种高并发场景)。如果后续需要并发扫描多个目录,可以用rayon而非tokio。
5.2 递归 vs 迭代
递归实现简洁,但深度目录可能导致栈溢出。实际使用中,max_depth限制在20以内是安全的。如果需要处理无限深度,应改为迭代实现(用显式栈)。
5.3 精度 vs 性能
metadata()获取的文件大小不是精确的磁盘占用(未考虑块对齐和稀疏文件)。精确计算需要statvfs等系统调用,但MVP阶段用metadata()足够。
六、总结
从零构建系统级工具的关键经验:先实现MVP验证可行性,再逐步重构提升质量。MVP阶段容忍粗糙的错误处理和低效算法,重点是"跑起来"。重构阶段优先解决错误处理和性能瓶颈,最后再打磨输出格式。
踩过的坑:递归中线性查找子目录大小(O(n²))、权限错误未处理导致panic、输出格式在MVP阶段就花太多时间。教训是"先跑通,再优化"。
落地建议:需求定义控制在3-5个核心功能;MVP用最简单的实现,不追求优雅;重构优先解决错误处理;性能优化用benchmark验证效果;CI在项目稳定后再配置。
