从零到一的系统工具开发复盘:需求、设计、实现、发布四个阶段
📅 2026/7/25 6:28:59
👁️ 阅读次数
📝 编程学习
从零到一的系统工具开发复盘:需求、设计、实现、发布四个阶段
一、需求阶段:从"我想要"到"别人也要"
最开始的想法很简单:我是一个后端开发,每天要 grep 十几 GB 的日志文件查错误信息。传统grep是单线程 IO + 单线程匹配,读 10GB 文件需要 90 秒。我想要的只是一个"快 10 倍的 grep"。
但我没有马上开始写代码。我先做了两件事:
- 确认这不是一个已被解决的需求:调研了
ripgrep、ag、ugrep,发现它们都非常优秀,但在大文件流式搜索和结构化日志过滤上都不太理想。 - 和 3 个同事聊了他们的痛点:发现不只是我一个人受罪——运维同事每天要跨 10+ 台机器搜日志,测试团队在 CI 日志里找失败原因也要花大量时间。
于是需求变成了:
二、设计阶段:架构决策的三个关键时刻
决策 1:内存映射还是流式读取?
大文件读取有两种方案:
- mmap(内存映射):让操作系统把文件映射到虚拟内存,按需加载页。随机访问快,但 10GB+ 文件可能触发 OOM。
- 流式读取:读一段处理一段,内存可控,但需要自己管理缓冲区。
我选择了一个折中方案——分段 mmap:
// ============================================================ // 分段内存映射:兼顾速度与内存安全 // ============================================================ use memmap2::Mmap; use std::fs::File; /// 大文件分段读取器 /// 将文件分成多个固定大小的段,用 mmap 逐段处理 pub struct ChunkedFileReader { file: File, file_size: u64, /// 每段的大小(默认 64MB) chunk_size: u64, /// 段间重叠大小(防止关键词被截断在边界) overlap: usize, } impl ChunkedFileReader { pub fn new(path: &str, chunk_size_mb: u64) -> std::io::Result<Self> { let file = File::open(path)?; let file_size = file.metadata()?.len(); Ok(Self { file, file_size, chunk_size: chunk_size_mb * 1024 * 1024, overlap: 4096, // 4KB 重叠区,确保不会截断行 }) } /// 迭代器:每次返回一个可独立处理的数据段 pub fn chunks(&self) -> impl Iterator<Item = std::io::Result<Chunk>> + '_ { let mut offset = 0u64; let chunk_size = self.chunk_size; let file_size = self.file_size; std::iter::from_fn(move || { if offset >= file_size { return None; } // 计算实际要读的段大小(含重叠区) let read_size = chunk_size.min(file_size - offset) as usize + 4096; // 创建该段的 mmap 视图 let mmap = unsafe { Mmap::map(&File::open("").unwrap()) // 简化示例 }; offset += chunk_size; Some(Ok(Chunk { offset, data: vec![] })) }) } } /// 文件的一个数据段 pub struct Chunk { pub offset: u64, pub data: Vec<u8>, }决策 2:用线程池还是 Tokio?
并行处理有两个方案:
rayon线程池:简单粗暴,适合纯 CPU 任务。- Tokio:适合 IO 密集型任务。
这个工具的核心瓶颈在 IO(读文件)和 CPU(正则匹配),所以我用了混合方案:
- IO 层用 Tokio:处理多文件并发读取、SSH 连接。
- 计算层用 rayon:正则匹配、JSON 解析。
三、实现阶段:最磨人的两周
难点 1:正则匹配的边界处理
分段读取后,一个关键词可能刚好跨越两个段的边界。处理方案:段之间留 4KB 的重叠区,重叠区的结果去重。
/// 正则匹配器,处理段边界去重 pub struct DedupMatcher { /// 已匹配行的哈希集合,用于去重 seen: HashSet<u64>, /// 上一段的最后几行(用于检测边界匹配) tail_buffer: Vec<String>, } impl DedupMatcher { /// 对一段数据进行匹配,自动处理边界去重 pub fn match_chunk(&mut self, chunk: &[u8], regex: &Regex) -> Vec<Match> { let text = String::from_utf8_lossy(chunk); let lines: Vec<&str> = text.lines().collect(); // 把上一段的尾部和当前段的头部拼接,检查边界处的完整行 let boundary = format!("{}{}", self.tail_buffer.join("\n"), lines.first().unwrap_or(&"")); let mut results = vec![]; for line in &lines { let hash = calculate_hash(line); if regex.is_match(line) && !self.seen.contains(&hash) { self.seen.insert(hash); results.push(Match { /* ... */ }); } } // 保存当前段的尾部供下一段使用 self.tail_buffer = lines.iter().rev().take(3).map(|s| s.to_string()).collect(); results } }难点 2:压缩文件的流式处理
.gz文件不能直接 mmap,需要用flate2流式解压。但解压是 CPU 密集型操作,要和正则匹配做流水线化:
// ============================================================ // 流水线:解压 → 正则匹配 → 输出 // ============================================================ use std::io::Read; use flate2::read::GzDecoder; pub fn search_gzip(path: &str, pattern: &str) -> anyhow::Result<Vec<String>> { let file = File::open(path)?; // GzDecoder 包装了文件读取器,自动流式解压 let decoder = GzDecoder::new(file); let reader = std::io::BufReader::new(decoder); let regex = Regex::new(pattern)?; let mut results = vec![]; for line in reader.lines() { let line = line?; if regex.is_match(&line) { results.push(line); } } Ok(results) }四、发布阶段:从"能跑"到"有人用"
工具开发完成后,我做的不是扔到 GitHub 上等 star,而是:
- 写了一个 60 秒的演示 GIF:展示 10GB 文件搜索从 90 秒降到 3.2 秒。
- 写了一份 benchmark README:和 ripgrep、ag 做了系统对比(公平起见,只比较纯文本搜索场景)。
- 在公司内部先推广:让运维团队的 3 个同事试用了一周,根据反馈改了 6 个 issues。
- 打包发布:用 GitHub Actions 自动编译 Linux/macOS/Windows 的二进制包。
# ============================================================ # GitHub Actions 多平台编译配置 # ============================================================ name: Release on: push: tags: ['v*'] jobs: build: strategy: matrix: target: - x86_64-unknown-linux-gnu - x86_64-apple-darwin - aarch64-apple-darwin - x86_64-pc-windows-msvc runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: rustup target add ${{ matrix.target }} - run: cargo build --release --target ${{ matrix.target }} - uses: softprops/action-gh-release@v1 with: files: target/${{ matrix.target }}/release/logsearch*第一次发版就被坑了:忘了加--target构建,macOS 用户下载后发现跑不了——因为我用 M1 Pro 构建的二进制是 arm64,Intel Mac 用户根本打不开。后来老老实实配了三个 target 的 matrix build,再也没收到"打不开"的 issue。
cross 编译是工具发布的第一道坎,别跳过。
五、总结
从零到一开发一个系统工具的四个阶段,我学到的最重要的东西:
- 需求阶段先问"这个轮子真的没人造过吗?"我的工具虽然和 ripgrep 有重叠,但在大文件场景有差异化优势。
- 架构决策要做实验,不要凭感觉。我测试了 mmap vs 流式、rayon vs Tokio 之后才做的选择。
- 边界处理才是实现的难点。核心逻辑可能 100 行搞定,但段边界去重、压缩文件处理、错误恢复等占了 70% 的代码量。
- 发布不等于完成。工具的价值在于有人用、有人反馈、持续迭代。
v0.1.0 发了之后,公司内部已经有 30+ 人在用它。v0.2.0 正在开发中,计划加入正则表达式的可视化调试功能。
编程学习
技术分享
实战经验