
我到现在还记得第一次在 sed 里为了替换一个带斜杠的路径写出一长串反斜杠转义时的那种崩溃。从那以后我就一直在找一个只需要替换的工具不想背正则语法也不想去跟 sed 的引号规则较劲。后来在 GitHub 上撞见一个叫 caveman 的小项目名字很直白作者的意思就是像山洞人一样简单只做一件事把字符串 A 替换成字符串 B其他什么都不管。这篇文章就聊聊我研究、复刻并使用这个小工具的全过程以及它带给我的那些关于极简工程的思考。如果你是经常跟日志、配置文件、批量文本打交道的开发或运维或者你对把一个工具做到极致简单这件事感兴趣那这篇应该对你有用。1. 从 sed 转义地狱到 caveman一个只会替换的小工具1.1 我为什么需要一个比 sed 更笨的工具说句实话sed 是个好工具功能强大到几乎无所不能但恰恰是这份无所不能让它在某些场景下变得特别难用。就拿最简单的替换来说我想把配置文件里的/usr/local/bin改成/opt/custom/bin在 sed 里要写成sed -i s|/usr/local/bin|/opt/custom/bin|g app.conf看着还行那如果路径里出现了或者\或者要替换的字符串里本身带单引号你就得开始和转义搏斗了。更麻烦的是 sed 的正则语法圆括号要转义、加号要转义、花括号也要转义每次写完都要对着屏幕自查半天。我印象最深的一次是要批量替换几百个文件里的版本号。版本号里有小数点有连字符还有\d这样的占位符。我用 sed 写了一个自以为正确的正则跑完之后发现有一批文件没匹配上另一批文件被替换错了位置。从那之后我就彻底明白了一个道理很多场景下我根本不需要正则我需要的就是把这个字面文本换成那个字面文本连通配符都不需要。1.2 caveman 的功能边界与基本用法caveman 这个工具就是冲着这个需求去的。它不是一个新奇的算法也不是一个庞大的框架它就是一个很纯粹的命令行小工具从标准输入读入文本把指定的旧字符串替换成新字符串然后写到标准输出。在我常用的版本里它的用法非常直白不需要记任何参数项echo hello world | caveman world caveman # 输出hello caveman或者处理文件的时候配合重定向caveman v1.2.3 v1.3.0 app.conf app.conf.new mv app.conf.new app.conf没有-i原地修改没有正则标志没有分组捕获没有延迟匹配。它甚至连同时替换多个不同字符串的能力都没有要替换多个就得串联多个调用。第一次看到这种设计我是愣了一下的心想这也太原始了吧。但用了几次之后我开始理解作者为什么这么坚持。因为它把复杂度挡在了外面。工具本身不需要维护复杂的正则状态机调用方不需要记忆各种转义规则两边都轻松。就像你家里只需要一把螺丝刀的时候没必要把一整套电动工具箱摆在桌上。1.3 一张表格看清 sed 和 caveman 的差异为了把这两者的差别说清楚我整理过一张对照表拿去给团队里的小朋友讲过对比维度sedcaveman匹配规则正则表达式字面字符串学习成本中高需要懂元字符和转义几乎为零特殊字符处理容易踩转义坑除了 Shell 引号外无需处理原地修改支持-i不支持需配合重定向处理二进制文件视实现而定容易损坏原样替换理论上更适合代码规模庞大自带正则引擎极小只做子串搜索和替换适合场景复杂文本处理管道简单、无歧义的批量替换你可能会问那 sed 能做的 caveman 都做不了何必还要用它我的回答是工具不是越强大越好而是越可预测越好。sed 的能力边界很大但它的行为边界也很大你在用它之前必须清楚它在这个文本上会怎么解释你的表达式。而 caveman 的行为边界小到你闭着眼睛都能说出来这本身就是一种价值。2. 砍掉正则引擎速度从哪来2.1 正则引擎的开销到底在哪很多人觉得正则引擎只是处理起来麻烦一点性能上应该没什么差别吧这个想法我一开始也有直到我拿大文件实测之后才发现差别的确不小尤其是在你只是做一个字面替换的情况下。正则引擎的工作方式本质上是在文本流上维护一个自动机状态。哪怕只是匹配一个最简单的字符串abc大多数通用正则引擎也要做编译表达式、构建内部状态节点、逐字符驱动状态转移这一整套流程。如果表达式里带了捕获组、回溯或者惰性匹配那计算量就更是按倍数往上涨了。更麻烦的是正则引擎为了支持各种复杂规则会引入大量的分支判断。每读一个字符都要做一次当前状态集合的更新。这种做法在匹配任意正则这个通用需求下是合理的但如果你最终想要的只是找到这个子串把它换成另一个子串那这些开销就变成了纯粹的浪费。我见过一个生产事故有个定时任务用 sed 在一个很大的日志文件上做逐行替换原本以为几分钟就跑完结果跑了将近半小时把下游任务全堵了。后来把那条规则改成纯字符串替换执行时间压缩到了一个零头。不是 sed 烂而是我们用错了工具。2.2 源码级拆解纯字符串匹配的一次扫描我看过一些类似的极简替换工具的实现它们的核心逻辑通常都长得很像基本可以浓缩成三件事找子串、拼新串、继续找。caveman 这类工具的典型实现不会去用任何正则库而是直接调用底层的内存搜索函数比如 C 语言里的strstr或者系统底层的快速子串搜索算法。整个处理流程其实是一次标准的两阶段扫描第一阶段遍历整个输入统计旧字符串出现的次数计算出替换完成后的目标缓冲区总长度。这一步很关键因为它避免了动态扩容的反复 realloc也让内存分配只发生一次。第二阶段再次遍历输入遇到旧字符串就跳过它把新字符串写入目标缓冲区遇到普通字符就原样拷贝。两个阶段各自走一遍时间复杂度就是 O(n m)其中 n 是输入长度m 是匹配次数带来的额外拷贝量没有回溯没有多余的状态分支。我第一次自己写这类代码的时候最开始用的是很笨的逐字节比对跑起来也没问题但总觉得有点浪费。后来换成内存搜索函数之后性能有了明显提升。C 标准库里的实现很多都经过了指令集级别的优化在小字符串上可能看不出差距在大文件上差距就很可观了。你可能注意到这个思路的关键在于两次扫描。第二次扫描的时候已经知道了新缓冲区的大小所以可以放心地直接memcpy不需要边写边判断要不要扩容。这个优化策略不只是这个工具在用我后来写很多解析器都用上了同样的思路先算好结果规模再一次性落地。2.3 不支持正则不是偷懒是边界设计我前面说了第一次看到 caveman 不支持正则我的第一反应是功能残缺。但在实际使用了一段时间之后我开始意识到这不是作者没能力做而是刻意做的边界设计。正则表达式本质上是另一种编程语言它有自己的语法规则、执行模型和坑。一旦工具引入了正则它就必须为正则的错误处理负责为不同风格的正则流派负责甚至为正则表达式的性能灾难负责。你只需要\d这种匹配能力但这背后牵出来的一整套复杂度全都堆到了工具本身和使用它的人身上。caveman 的做法是我不打算解决所有问题我只解决字面替换这个问题。如果你真的需要正则请你用别的工具。这样反而把责任的边界划清楚了。工具的维护者可以拍胸脯说我的工具行为是确定的使用者也可以放心地把它用在自动化脚本里不用担心某个特殊输入会让匹配行为变得不可预测。我自己在做组件设计的时候从这学到的经验是给工具划边界比给工具加功能难得多了。拒绝一个需求比实现一个需求难得多因为拒绝意味着你必须非常清楚这个工具的核心价值到底是什么。3. 复刻一个最小可用版本3.1 语言选型考虑编译体积还是开发速度在分析完原理之后我当时的第一反应是这玩意儿我也能写一个。正好手头有几个脚本项目需要频繁做替换操作我就花了一个下午的时间复刻了这么一个小工具出来。关于语言选型我纠结了一下最后做了两个版本一个用 C追求最小依赖和极致性能另一个用 Python方便我在各种没有编译环境的机器上临时用。如果你也想自己写一个我的建议是如果目标是学习底层原理选 C 或者 Rust 这类系统语言如果目标是日常脚本里自用选 Python 或 Go 都行。核心替换逻辑的思路完全一致只是系统的内存管理方式不同。Go 因为天生对并发和管道处理友好写这类工具也特别顺手。编译出来是一个静态二进制扔到服务器上就能跑连 libc 的版本都不用担心。我当时没有选 Go 是因为目标机器太旧C 的兼容性最稳但如果你没有这个包袱Go 会省不少事。3.2 核心替换逻辑逐字节比较与重建缓冲区先讲一下设计思路方便你看后面的代码。第一步读入全部输入。你可能觉得为什么不逐行处理对于替换需求来说逐行会带来一个问题如果旧字符串跨行出现逐行处理就漏掉了。所以为了行为完整我会把整个输入一次性读入内存。当然这也意味着你要预估一下输入的大小别拿一个几十 GB 的文件直接往内存里怼。对于替换这个动作一次读入是最稳妥的做法。第二步第一次遍历统计匹配数。这一趟的目的是算出最终输出的长度。如果旧字符串长度大于新字符串最后结果变短反之变长。有了这个长度我就可以一次性分配好输出缓冲区。第三步第二次遍历执行替换。这一步需要维护两个指针一个指向读取位置一个指向写入位置。每当找到旧字符串就把前面的部分拷贝到输出里然后写入新字符串再跳过旧字符串继续。整个过程是线性的不需要回头。边界情况里最重要的是旧字符串为空。这种匹配理论上每个位置都算匹配处理不好就会死循环。我的做法是遇到空模式直接原样返回输入并且在文档里明确标注不支持空模式。3.3 完整的参考实现代码下面是我用 C 写的精简版去掉错误处理细节之后总共就几十行#include stdio.h #include stdlib.h #include string.h static char *read_all(FILE *fp, size_t *len) { size_t cap 4096, n 0; char *buf malloc(cap); if (!buf) return NULL; int c; while ((c fgetc(fp)) ! EOF) { if (n 1 cap) { cap * 2; char *tmp realloc(buf, cap); if (!tmp) { free(buf); return NULL; } buf tmp; } buf[n] (char)c; } buf[n] \0; *len n; return buf; } static char *replace_all(const char *input, size_t len, const char *old, const char *new) { size_t old_len strlen(old), new_len strlen(new); if (old_len 0) { char *out malloc(len 1); if (!out) return NULL; memcpy(out, input, len 1); return out; } size_t count 0; const char *p input, *end input len; while ((p strstr(p, old)) ! NULL p end) { count; p old_len; } size_t out_len len count * (new_len - old_len); char *out malloc(out_len 1); if (!out) return NULL; char *dst out; const char *cur input; while ((p strstr(cur, old)) ! NULL p end) { memcpy(dst, cur, p - cur); dst p - cur; memcpy(dst, new, new_len); dst new_len; cur p old_len; } memcpy(dst, cur, end - cur); dst end - cur; *dst \0; return out; } int main(int argc, char **argv) { if (argc ! 3) { fprintf(stderr, usage: caveman OLD NEW\n); return 2; } size_t len; char *input read_all(stdin, len); if (!input) return 1; char *out replace_all(input, len, argv[1], argv[2]); if (!out) { free(input); return 1; } fputs(out, stdout); free(out); free(input); return 0; }编译命令很简单cc -O2 -o caveman caveman.c如果是在 Windows 上用 MSVC把cc换成cl也一样代码本身不依赖任何平台特性。3.4 测试用例把边缘情况逼出来代码写完不能直接上生产我当时的做法是先列一组测试用例把容易出问题的边缘情况都过一遍测试场景输入命令预期输出基本替换hello worldcaveman world cavemanhello caveman无匹配hello worldcaveman foo barhello world连续匹配aaaacaveman aa bbb替换为空串abc defcaveman abcdef空模式abccaveman xabc不变跨行匹配a\nbcaveman a\nb cc注意这时需要 Shell 传实际的换行符特殊字符100% donecaveman 100% 50%50% done这里面最容易出问题的是连续匹配。拿aaaa和aa来说正确的处理方式是匹配完一个之后从被替换文本的后面继续找这样得到的两个匹配是[0,2)和[2,4)输出是bb。如果实现不小心在匹配完之后从当前匹配的第二个字符继续找就会得到三个匹配输出就错了。这种细节不写测试根本注意不到。还有一个我在实际使用中发现的点如果你打算处理二进制文件最好在测试用例里加入\x00字节的替换测试。因为我这个 C 版本用的是strstr它对\0是敏感的输入一旦包含空字节搜索就会提前结束。处理纯文本没问题处理二进制就没那么保险了。如果你真有二进制需求得把代码改成用显式长度的内存搜索函数而不是依赖字符串函数。4. 实测对比哪些场景该用它哪些场景别碰4.1 三组实测热替换、大文件、批量修改我复刻完这个小工具之后专门针对三个典型场景做了对比测试。测试对象是 sed 和我的 caveman 复刻版跑的机器只是一台普通的 Linux 虚拟机所以具体数字不能代表所有环境但几轮测试下来的趋势很稳定。第一组是热替换在一个约 200MB 的日志文件里把某个固定的错误码从E123替换成E456。这一组是 sed 和 caveman 差距最明显的一组。sed 的表现也不差但在这类纯字面替换、不需要正则的场合它花在正则引擎状态维护上的时间就变成纯开销了。caveman 的优势在 CPU 时间上可以明显感知到。第二组是无匹配同样的大文件替换一个文件中根本不存在的字符串。这组很有意思两者都不会输出任何变化过的东西但 sed 仍然需要走一遍正则匹配流程。caveman 同样也要全量扫一遍因为必须确认没有匹配。差距依然存在但比第一组要小。第三组是批量修改对几十个小配置文件逐个执行替换。这种场景下文件本身很小工具启动速度和 I/O 开销是主要成本两者差距可以忽略不计。真正影响体验的反而是 sed 的转义规则会不会导致我写错命令。4.2 结果解读简单替换场景下的真实收益综合来看我的结论是在大规模、纯字面替换这个特定场景里极简替换工具确实有真实收益而且收益不只是心理上的简单。它减少了正则引擎的 CPU 开销减少了调用者思考转义规则的时间也减少了出错概率。但我也要强调这不是说 sed 不行。sed 在文本处理界的位置是综合工具你用它做这件事的时候其实是用它 10% 的能力在处理一个需求剩下 90% 的复杂度被白白背负在了每次调用上。而 caveman 这样的工具相当于把一个高频小需求单独拆了出来让专业工具做专业的事。有一类场景我会明确建议别用 caveman字符串里带有实际需要正则表达的匹配逻辑时比如你想匹配任意以err_开头的单词或者所有包含数字的变量名这种需求用极简替换工具就是自找麻烦老老实实用 sed、perl 或者你熟悉的脚本语言。极简工具的边界就是它只接受我知道我要把这段字面文本变成那段字面文本的场景。4.3 我踩过的三个坑既然是自己写工具自己用踩坑是免不了的。这里分享三个我实际遇到过的坑希望能帮后面的人省点时间。第一个坑是没有意识到没有原地修改。我一开始在自动化脚本里直接写caveman old new app.conf app.conf你猜发生了什么输出重定向会在命令执行前把文件截断结果就是读进去的是空文件替换完输出也是空的配置文件原地蒸发。我当时盯着空文件愣了好几秒才反应过来。正确做法是写到临时文件确认成功后再mv回去或者用一个临时后缀。第二个坑是 Shell 引号问题。caveman 的参数是字面字符串所以如果你想换的内容里包含空格必须记得加引号。这个和 sed 的转义问题是两个方向的反面sed 是不需要引号的地方容易被转义规则坑caveman 是参数一多容易忘记加引号导致被 Shell 拆词。所以写脚本时有一个习惯很重要所有参数不管是什么一律用双引号包起来。第三个坑是把不支持正则记成了不支持复杂匹配。有一次我在替换版本号时想顺手把带有\d的写法也过滤掉结果那个字符串当然没有被替换因为\d在极简替换工具里就是两个普通字符。这类问题通常不会造成破坏性错误但会让你的脚本结果跟你预期不一致在自动化流程里这种静默失败往往最麻烦。5. 极简工具给我的后续启发5.1 单一职责在真实项目里的落地研究完这个工具之后我最大的收获其实不在工具本身而是它让我重新审视了自己项目里的那些多功能模块。我之前写过一个小工具用来处理部署时的环境变量模板功能包括解析 YAML、支持多级变量、带条件判断、还能在模板里写循环。功能听着很全但每次改需求我都得小心翼翼生怕动了一个功能影响到另一个。后来我做了一次重构按照 caveman 的精神把它拆成了三个独立的工具第一个只负责把${VAR}替换成环境变量值第二个负责读取一份简单的键值配置文件第三个负责把多份文件拼接输出到指定位置。每个工具都只有一个明确职责组合起来反而比原来一个大工具更好维护。那次重构的结果是把核心代码从三百行减到了不到一百行而且测试变得极其简单。因为每个小工具的输入输出都是确定的我不需要构造一大堆复杂的 YAML 用例来验证分支逻辑只需要测最核心的数据流。这个经验后来被我反复用在别的项目上宁可用三个小工具拼出一个大功能也不写一个什么都能干的大工具成了我的一个默认原则。5.2 少依赖带来的部署自由另一个我很深的体会是零依赖的价值。这个复刻的 caveman我把它编译成了一个静态二进制大小也就几 KB 或者说几十 KB 量级。我把它扔在服务器上、扔在容器镜像里、扔在 CI 的临时环境里都没问题。它不需要运行时不需要 Python 解释器不需要装任何依赖包。对比一下我之前的很多脚本工具哪怕只是一个小功能也要带上一堆依赖声明。在开发机上没什么感觉一旦要部署到客户的隔离环境、或者跑在各种精简容器里依赖问题就会变成最大的敌人。有一个项目甚至出现过因为基础镜像里没有某个动态链接库导致整个工具链跑不起来的尴尬局面。从那以后我写内部小工具时会有意识地控制依赖数量。能用标准库解决的就不用第三方库能静态编译的就静态编译。如果这个工具注定了要陪着我到处搬家那它最好轻装出行。5.3 极简的边界什么时候不要硬简当然我也得说说极简的边界在哪里免得你把这篇文章看完之后把所有工具都拆成只会做一件事的玩具。有些场景下极简反而是不负责任的。一个典型的例子是如果你处理的数据格式本身是结构化的比如 JSON 或者 YAML正确的做法是用对应的解析库去操作结构而不应该用字符串替换来硬改。字符串替换只知道内容不知道结构一旦数据换行格式变化、转义方式不同、字段嵌套层级调整你的替换逻辑就可能产生语法上合法但含义上完全错误的结果。这种时候只做一件事并不能保护你反而会让你对危险操作掉以轻心。另一个极简导致的陷阱是隐藏的条件。当你把一个大工具拆成很多小工具之后每个小工具内部的逻辑确实简单了但你的调用方代码会变复杂需要记住工具之间的组合规则和参数顺序。工具简单了系统不简单。所以在设计时要平衡把复杂度放在哪个层面更合理是从工具内部挪出去还是留在工具内部更安全。就我个人而言如果一个工具的核心逻辑可以在一屏代码里讲清楚那它就该被拆成极简工具如果它需要跨多个状态去协调数据那它还是老老实实用一个能表达复杂流程的语言去写吧。最后分享一个小技巧我在写 Makefile 的时候经常要用替换来生成不同的构建产物名字。以前我都是用sed或者写一段 shell 来做现在直接调这个复刻的 caveman配合变量传参整条规则变得异常清晰。你如果平时经常做类似的批量文本处理不妨也花个把小时动手写一个属于自己的极简替换工具它不只是给你一个工具更重要的是它会改变你对工具设计这件事的思考方式。