从零到一构建系统级工具的完整过程:我的第一个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在项目稳定后再配置。

1381

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



