Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

实战案例 - CLI 工具开发

这一章不把目标设成“做一个可以直接替代现有命令行工具的完整产品”,而是把重点放在:

如何把一个小而完整的命令行需求,拆成清楚的模块、明确的接口和可验证的最小原型。

为了和本部分的整体定位保持一致,本章更适合被理解为:

  • 一个最小可讨论的 CLI 原型
  • 一个把参数解析、文件系统遍历、过滤逻辑和输出组织起来的教学案例
  • 一个练习“如何把 Zig 基础能力组合成小项目”的入口

而不是:

  • 生产级 find 替代品
  • 完整的 glob 引擎
  • 完整覆盖平台差异、符号链接、权限、错误恢复和输出规范的成熟工具

本章的学习目标

读完这一章后,你应该重点掌握下面这些能力:

  1. 把 CLI 工具拆成几个职责清楚的模块
  2. 围绕“输入 → 处理 → 输出”组织主流程
  3. 理解参数解析和错误处理如何影响用户体验
  4. 理解文件搜索为什么看起来简单,实际上有很多边界条件
  5. 知道什么叫“教学原型”和“生产工具”的差别

如果你能在本章结束后说清楚:

  • 参数是怎么被解析成结构化选项的
  • 搜索逻辑为什么要和输出逻辑分开
  • 哪些行为是本章故意简化的
  • 如果继续扩展,这个原型下一步应该往哪里演进

那么这一章就已经达到目标。

先定义案例范围:我们到底要做什么?

本章要实现一个最小文件搜索工具,暂时叫它 zfind
它支持下面几类能力:

  • 指定搜索起点目录
  • 按名称模式做简单匹配
  • 按文件类型过滤
  • 按文件大小过滤
  • 限制递归深度
  • 以简单文本、详细文本或“接近 JSON 的文本行”输出结果

这里要特别强调两点。

1. 这是“最小搜索工具原型”,不是完整 find

虽然案例借用了常见命令行搜索工具的思路,但它并不追求完整兼容真实工具的所有行为。
例如本章不会覆盖:

  • 复杂 glob 语法
  • 正则表达式
  • 排除规则
  • 跟随或不跟随符号链接的完整策略
  • 精细的权限错误汇总
  • 平台差异下的全部文件类型
  • 严格的 JSON 文档输出格式

2. 这里最重要的是“结构和边界”,不是功能越多越好

CLI 案例最容易出现的误区之一,是一上来就拼命加选项和功能。
结果往往是:

  • 主函数越来越长
  • 逻辑边界不清楚
  • 错误处理越来越混乱
  • 输出格式和搜索逻辑耦合在一起
  • 代码看起来“功能很多”,但实际上很难维护

因此,本章反而会故意收缩范围,让你看清楚一个小工具最基本的组织方式。

教学约束:这一章刻意简化了什么?

为了让案例保持清晰,本章采用了几项明确的教学约束:

  1. 名称匹配只支持非常有限的通配模式

    • 例如 *suffixprefix**middle*、完全匹配
    • 它不是完整 glob 语法实现
  2. “json” 输出只是逐行输出 JSON 风格对象

    • 它更接近“方便机器读取的行格式”
    • 不是一个严格意义上的单一 JSON 数组文档
  3. 搜索过程优先强调结构清晰

    • 不会为了极限性能一开始就引入并行遍历、工作队列或复杂缓存
  4. 错误处理以“教学上能看懂”为优先

    • 不会构建完整的错误汇总系统
    • 某些目录或文件出错时,可能直接跳过
  5. 文件系统行为不追求生产级完整性

    • 比如目录、符号链接、元信息获取等问题,这里只展示最小可理解路径

这些约束并不是缺点,而是这章作为“案例教学”的前提条件。

先从用户视角看:这个工具怎么使用?

在进入代码之前,先把目标交互想清楚。

我们希望用户能写出类似这样的命令:

zfind .
zfind -n "*.zig" .
zfind -t dir /usr/local
zfind -s +1048576 .
zfind -d 3 -f detailed .

从这些命令出发,可以反推出程序至少需要回答几个问题:

  • 搜索路径是什么?
  • 有没有名字模式?
  • 要过滤哪种文件类型?
  • 有没有大小约束?
  • 递归深度是否有限制?
  • 结果应该怎么打印?

于是,我们就有了一个很自然的设计方向:

先把命令行输入解析成一个清晰的配置结构,再把这个结构交给搜索逻辑。

这比在搜索过程中不断去问“用户传了哪个选项”要清楚得多。

模块划分:先把职责拆开

一个很适合 CLI 原型的拆分方式是下面这样:

zfind/
├── build.zig
├── build.zig.zon
└── src/
    ├── main.zig
    ├── cli.zig
    ├── filter.zig
    ├── search.zig
    └── output.zig

每个模块的职责分别是:

  • main.zig

    • 程序入口
    • 初始化分配器
    • 获取参数
    • 调用解析逻辑
    • 串起搜索和输出流程
  • cli.zig

    • 命令行参数解析
    • 帮助信息和版本信息
    • 将命令行参数转换成结构化选项
  • filter.zig

    • 名称匹配
    • 类型过滤
    • 大小过滤
  • search.zig

    • 目录遍历
    • 递归搜索
    • 收集和输出结果
  • output.zig

    • 不同格式的输出逻辑

💡 这里的重点不是“一定要拆成 5 个文件”,而是:

  • 参数解析不要和遍历逻辑混在一起
  • 搜索逻辑不要和输出细节绑死
  • 过滤规则最好能独立测试

第一步:把命令行参数变成结构化选项

对于 CLI 工具来说,最关键的第一步不是“开始搜文件”,而是先把用户输入转成程序可理解的状态。

一个最小选项结构可以类似这样:

const std = @import("std");

pub const FileType = enum {
    all,
    file,
    dir,
    symlink,
};

pub const SizeComparator = enum {
    greater,
    less,
    equal,
};

pub const SizeFilter = struct {
    comparator: SizeComparator,
    size: u64,
};

pub const OutputFormat = enum {
    simple,
    detailed,
    json,
};

pub const CliOptions = struct {
    search_path: []const u8 = ".",
    name_pattern: ?[]const u8 = null,
    file_type: FileType = .all,
    size_filter: ?SizeFilter = null,
    max_depth: ?u32 = null,
    output_format: OutputFormat = .simple,
    show_help: bool = false,
    show_version: bool = false,
};

这段结构设计有几个教学上的好处:

  1. 主流程不再关心原始参数细节
  2. 默认值清楚
  3. 搜索逻辑只依赖结构化配置
  4. 后续写测试会更方便

也就是说,参数解析的真正价值不是“把字符串拆开”,而是:

把混乱的命令行输入,整理成一个具有明确语义的数据结构。

第二步:限制模式匹配的野心

很多人做文件搜索工具时,最容易低估的部分就是“模式匹配”。

如果你说自己支持:

  • *.zig
  • foo*
  • *bar
  • src/**/test*.zig
  • 字符类
  • 转义
  • 路径分段规则

那你其实已经在往“做一个真正的 glob 引擎”走了。

而本章不想把精力花在这里。
所以这里故意只做一个非常有限的匹配器,例如:

  • *:全部匹配
  • *suffix:后缀匹配
  • prefix*:前缀匹配
  • *middle*:包含匹配
  • 其他情况:精确匹配

示意实现可以像这样:

const std = @import("std");

pub fn matchesPattern(name: []const u8, pattern: []const u8) bool {
    if (std.mem.eql(u8, pattern, "*")) return true;

    if (std.mem.startsWith(u8, pattern, "*") and std.mem.endsWith(u8, pattern, "*")) {
        const middle = pattern[1 .. pattern.len - 1];
        return std.mem.indexOf(u8, name, middle) != null;
    } else if (std.mem.startsWith(u8, pattern, "*")) {
        const suffix = pattern[1..];
        return std.mem.endsWith(u8, name, suffix);
    } else if (std.mem.endsWith(u8, pattern, "*")) {
        const prefix = pattern[0 .. pattern.len - 1];
        return std.mem.startsWith(u8, name, prefix);
    } else {
        return std.mem.eql(u8, name, pattern);
    }
}

这里一定要对读者说清楚

这不是完整 glob。
更准确地说,它只是一个教学用的最小通配匹配器

因此,像下面这些说法都应该避免:

  • “支持通配符匹配”
  • “兼容 shell glob”
  • “可替代常见模式匹配工具”

更准确的表达是:

本章支持少量最常见的名称模式,用来支撑 CLI 原型,而不是实现完整模式语法。

第三步:把搜索逻辑和过滤逻辑分开

一旦参数已经解析成 CliOptions,接下来最自然的做法,就是让搜索逻辑专注于:

  • 遍历目录
  • 判断是否递归进入子目录
  • 对当前条目应用过滤规则
  • 将结果交给输出逻辑

一个很典型的接口可以长这样:

pub fn search(
    allocator: std.mem.Allocator,
    options: CliOptions,
    writer: anytype,
) !usize

这个签名背后的设计含义很值得注意:

  • allocator
    • 搜索过程中可能需要构造路径、临时字符串
  • options
    • 所有用户输入都已经被整理好
  • writer
    • 搜索逻辑不关心结果是打印到终端、缓冲区,还是测试用内存输出

这就是一个典型的 Zig 风格设计点:

通过显式传入依赖,让逻辑更容易组合、替换和测试。

递归搜索的最小思路

一个教学用的递归搜索流程,通常可以概括成下面几步:

  1. 打开当前目录
  2. 遍历条目
  3. 构造当前条目的相对路径
  4. 应用名称、类型、大小等过滤条件
  5. 如果匹配,则输出
  6. 如果条目是目录且未超过最大深度,则递归进入

示意伪代码如下:

search(dir, current_depth):
    if 超出最大深度:
        return

    for 每个目录项:
        构造路径
        判断是否匹配过滤条件
        如果匹配:
            输出结果

        如果是目录:
            递归进入

这部分最重要的,不是“递归”本身,而是几个边界判断:

  • 深度什么时候停止?
  • 构造路径时谁负责释放内存?
  • 获取文件信息失败时怎么办?
  • 对目录和文件是否采用相同处理路径?

这些问题比“怎么写 while 循环”更值得关注。

一个很重要的现实问题:文件类型和元信息并不总是好拿

从教学角度看,“遍历目录 → 判断类型 → 判断大小”似乎很顺。
但在真实文件系统里,这一步其实很容易碰到复杂情况,例如:

  • 某些条目无法访问
  • 某些条目是符号链接
  • 某些平台下元信息行为不同
  • 目录和普通文件获取 size 的语义并不完全一样

因此,本章中如果你看到某些逻辑采取了“获取不到就跳过”的策略,不要把它理解成“这是最完善的行为”,而要把它理解成:

这是为了保持案例主线清晰而做的简化。

真实项目里,你通常还要继续思考:

  • 是否把错误记录下来
  • 是否在最终汇总里告诉用户哪些路径失败了
  • 是否允许部分失败而继续执行
  • 是否区分“用户无权限”和“路径不存在”等错误

输出格式:为什么要单独抽出来?

CLI 工具里一个非常常见的坏味道是:

  • 搜索逻辑里直接 print
  • 每种格式都在主循环里分支
  • 结果一多,搜索和输出完全缠在一起

这会导致两个问题:

  1. 搜索逻辑变得很难读
  2. 输出格式一改,核心流程也要跟着改

因此,更合理的方式是把输出逻辑单独整理成一个函数,例如:

fn printResult(
    writer: anytype,
    path: []const u8,
    stat: ?std.fs.File.Stat,
    format: OutputFormat,
) !void

关于 “json” 输出,一定要说实话

如果你的实现只是逐行打印:

{"path":"a","type":"file","size":10}
{"path":"b","type":"file","size":20}

那么它更接近:

  • JSON Lines 风格文本
  • 行分隔对象流
  • “方便后处理”的近似格式

而不是一个严格的 JSON 文档数组:

[
  {"path":"a","type":"file","size":10},
  {"path":"b","type":"file","size":20}
]

所以本章更合适的说法是:

  • “提供一种 JSON 风格的行输出”
  • 或者“提供便于机器读取的逐行对象输出”

而不要直接说“支持 JSON 输出”,否则读者会默认它是严格 JSON。

主程序入口:主函数真正该做什么?

一个清楚的 main 通常只需要做这几件事:

  1. 初始化分配器
  2. 获取参数切片
  3. 调用参数解析
  4. 处理 --help / --version
  5. 创建输出 writer
  6. 调用搜索逻辑
  7. 打印汇总信息

也就是说,主函数应该像一个流程编排器,而不是逻辑垃圾场。

示意结构:

pub fn main(...) !void {
    初始化分配器
    获取参数
    解析 options

    如果需要帮助或版本信息:
        输出并返回

    创建输出 writer
    调用 search(...)
    输出搜索结果统计
}

如果主函数开始承担:

  • 模式匹配
  • 文件系统遍历
  • 输出格式化细节
  • 各种过滤判断

那通常就说明模块边界已经开始失控了。

这个原型的价值到底在哪里?

本章真正想教给你的,并不是“怎么写一个半成品 find”。
它的价值主要体现在下面几个方面。

1. 它展示了小工具的典型组织方式

你可以清楚看到:

  • 输入如何结构化
  • 搜索如何独立成模块
  • 输出如何解耦
  • 错误如何在边界上处理

2. 它让你开始面对真实工程里的“不完美”

CLI 工具看起来简单,但其实很快就会碰到:

  • 文件系统边界
  • 路径构造和释放
  • 平台差异
  • 输出格式真实性
  • 用户输入错误处理

这正是系统编程里很有代表性的现实问题。

3. 它适合写测试

这个原型里有很多地方都很适合写单元测试:

  • parseSizeFilter
  • matchesPattern
  • matchesSize
  • 参数解析的边界情况

这比一上来写大型集成测试更适合作为学习起点。

这个原型不适合被误解成什么?

为了避免教学预期错位,这里要明确列出来。

本章的实现不应被理解为

  • 生产级文件搜索器
  • 完整 glob 工具
  • 严格 JSON 输出工具
  • 完整跨平台文件系统抽象示例
  • 大目录高性能搜索方案

尤其在下面这些点上,读者应主动保持警惕:

名称匹配被明显简化

它只覆盖少量模式,不应过度外推。

文件系统错误处理被明显简化

很多地方采取“跳过继续”的策略,更接近教学演示而非成熟产品决策。

输出格式被明显简化

尤其是“json” 模式,教学上可以接受,但产品上需要更严格定义。

性能并不是这一版的主线

这一章优先追求“结构清楚、便于理解”,而不是“海量目录下的极限吞吐”。

如果继续演进,这个项目下一步该做什么?

如果你想把这个原型继续推进,可以考虑下面这些方向。

1. 先把输出格式做扎实

比起继续加筛选条件,更值得优先做的是:

  • 真正输出合法 JSON 数组
  • 处理字符串转义
  • 明确机器可读格式和人类可读格式的边界

2. 重新定义模式匹配能力

决定到底要支持:

  • 最小通配
  • 真正 glob
  • 正则表达式

不要让“看起来好像支持一点”长期停在模糊状态。

3. 补更清楚的错误汇总

例如:

  • 哪些路径访问失败
  • 为什么失败
  • 最终是否允许部分成功

4. 为真实目录结构写集成测试

尤其是:

  • 多层目录
  • 空目录
  • 深度限制
  • 文件类型过滤
  • 大小过滤

5. 如果性能真的成为问题,再考虑并行

在这之前,不要急着把并发加进来。
因为对于本章来说,先把边界写清楚比“并行搜索”更重要。

小结

这一章更合适的定位是:

一个围绕参数解析、目录遍历、过滤和输出组织起来的最小 CLI 原型。

它的教学重点不是“实现了多少功能”,而是:

  • 让命令行输入先变成结构化配置
  • 让搜索逻辑和输出逻辑分开
  • 让过滤规则可以独立测试
  • 让读者明确知道哪些是故意简化的边界

如果你带着这个视角来读,那么本章最重要的收获通常不是“我做了一个小工具”,而是:

我开始知道一个小工具应该如何被组织,哪些地方需要诚实地承认它只是原型。