“caveman”这个词,字面意思就是穴居人。我第一次看到这个项目名的时候,第一反应是这作者挺会起名的——一个工具如果敢叫“穴居人”,那它的设计哲学大概率不是炫技,而是把所有花里胡哨的东西都砍掉,只留下最原始、最可靠的那把“石斧”。
这篇文章要聊的就是这样一个名为 caveman 的极简命令行工具。它用纯 C 语言写成,单文件、零依赖,没有配置文件,没有插件系统,甚至连安装脚本都省了——拿到源码编译一下就能用。它要做的事情也很纯粹:把一段原始文本吃进去,按标签过滤、统计词频、生成一份最简单的 HTML 速览页面。听起来好像上个世纪的东西,但实测下来,这种“原始感”恰恰是它能在一堆现代化工具里站稳脚跟的原因。适合谁看?如果你对命令行工具感兴趣、想用 C 语言做一些能落地的实际项目,或者单纯受够了动不动就几百 MB 依赖的现代软件,这篇拆解值得花十分钟读完。
1. 项目名的信号:从“caveman”读出设计意图
1.1 caveman 不等于简陋,而是可审计的简单
很多人一听“极简”,第一反应是功能残缺。但 caveman 这个项目让我重新理解了一件事:极简不是做得少,而是把必要的东西做到不用怀疑。
穴居人过日子,工具就那么几样:石斧、火种、木棍。但每一样都是打磨过无数次的,因为它们承担着生存任务。软件也是一样,当你的工具要长期使用、要在你没法预测的环境里运行时,“没有多余部分”本身就是一种可靠性。caveman 的源码只有几百行,没有外部依赖,你可以在一个下午把每一行都读完。这意味着什么?意味着你可以审计它、信任它、在出问题时有能力改它。
我见过太多项目,刚创建就引入几十个依赖包,最后连作者自己都说不清某个传递依赖是干嘛的。caveman 走的是完全相反的路:能自己实现的绝不引入第三方,能用标准库解决的绝不发明新抽象。这种“可审计的简单”,在现在的技术环境里反而成了一种稀缺品。
1.2 一句话定位:它到底能做什么
回到功能本身。caveman 的输入就是任意文本,来源可以是文件,也可以是管道(stdin),比如你把一篇笔记、一份日志或者一段抓取下来的网页文本丢给它。
它能做三件事:第一,按标签过滤。只要文本行里包含指定的标签词(比如#TODO、#重要),就把这一行筛出来。第二,统计词频。把输入里的所有单词拆出来,数一数谁出现得最多,这在做关键词分析或者快速了解一篇长文在讲什么的时候很管用。第三,生成 HTML 速览。把过滤结果输出成一个极简的 HTML 页面,方便在浏览器里快速翻阅。
说白了一点都不复杂。但关键在于,caveman 不是一个“全自动分析工具”,它是一块积木。它把文本处理的第一步——筛选和统计——做得够快、够稳,剩下的交给管道里的其他命令去做。这符合 Unix 哲学:每个工具做好一件事,工具之间用标准输入输出连接。我在实际使用中,最常用的组合是把它和sort、head、grep串在一起,十几秒就能完成一次文档体检。
2. 核心设计拆解:为什么是 C 语言、为什么单文件
2.1 技术选型:C 语言在这里不是情怀
说实话,一开始我也想过,用 Python 写这种工具不是更快?二三十行就搞定了,正则是内置的,HTML 拼接也方便。但深入了解 caveman 的定位之后,我理解了作者为什么选 C。
首先,这个工具希望在任何 Linux 环境里都能跑,不需要装 Python 解释器,不需要考虑 pip 依赖,甚至不需要 glibc 之外的东西。用标准 C 写,一个gcc -static编译出来,二进制文件不到 200KB,丢到任何 x86 Linux 服务器上都能直接执行。这在排查线上问题的时候太有用了,尤其是一些精简的容器镜像里,连python3都是没有的。
其次,C 语言处理流式文本有一种天然的“低开销”直觉。它没有隐式的对象创建,没有后台的垃圾回收,你读入一行就是一行,处理完了就完事,内存占用始终可控。对于处理几百 MB 大日志的场景,这种确定性比“很快但说不准何时卡一下”的运行时更让人安心。这不是说 Python 不行,而是当你的目标环境不可控时,C 是最不会给你惊喜的选择。
2.2 三个关键设计决策
在拆解源码时,我注意到 caveman 有三个设计决策特别关键,其他同类工具没这么做是有原因的。
第一个是按行读取而不是一次性读入全文件。很多人写文本处理工具,习惯先fread整个文件进内存,图省事。caveman 用getline逐行处理,好处是内存占用跟文件大小无关,处理 1GB 的文件和 1MB 的文件占用差不多。输入可以来自无限流式管道,不会因为文件太大而崩掉。缺点是代码稍微多几行,但这是值得的。
第二个是结果存储用链表而不是预分配大数组。过滤出来的行数未知,词频统计的独立单词数量也未知,预分配容易不够,动态扩展容易浪费。链表虽然遍历慢一点,但胜在内存精准配给。因为展示最终结果本身就需要遍历一遍,链表的缺陷根本体现不出来。
第三个是绝不引入模板引擎。输出 HTML 时,就老老实实用字符串拼接,所有动态内容先做转义。你要是引入 Jinja2、Mustache 之类的模板,那“零依赖”就泡汤了,而且生成一个不到 10KB 的速览页,根本不需要模板引擎这种重武器。手写几个字符串拼接函数,半小时搞定,还能完全控制输出格式。
2.3 命令行参数约定
caveman 的参数交互设计得很规矩,没有乱七八糟的犄角旮旯。我用表格整理一下它支持的参数,方便后面看实操示例:
| 参数 | 含义 | 说明 |
|---|---|---|
-t <tag> | 按标签过滤 | 匹配包含该标签的行,标签建议带#前缀 |
-n <num> | 最多输出行数 | 限制输出行数,避免结果过长 |
-v | 反向过滤 | 输出不匹配标签的行 |
-s | 词频统计模式 | 忽略过滤,直接输出单词及其出现次数 |
-o <file> | 输出 HTML | 将结果写入指定的 .html 文件而不是 stdout |
-h | 帮助信息 | 打印用法 |
还有一个细节值得夸一下:退出码。caveman 约定,正常处理结束返回 0;没有匹配到任何内容返回 1,方便在脚本里用&&或||做逻辑判断;参数错误返回 2。这个约定看着简单,但在写 shell 脚本时特别舒服,不用去解析输出文本才能判断是否命中了内容。
3. 完整实现:手写一个 caveman 工具
3.1 项目结构
caveman 的项目结构可能是你见过最瘦的开源项目了:
caveman/ ├── Makefile ├── README.md └── src/ └── caveman.c没有configure,没有CMakeLists.txt,没有tests/目录。作者把全部逻辑塞进了一个caveman.c。第一次见到时我觉得这也太寒酸了,但转念一想,对于一个核心功能只有过滤和统计的工具来说,拆成十个文件反而是过度设计。单一文件让编译和分发都变得极其简单:拷贝一个 .c 文件,gcc -O2 -Wall -Wextra -o caveman caveman.c,完事。
Makefile 也是同样风格,里面大概四五行:
CC ?= gcc CFLAGS ?= -O2 -Wall -Wextra -std=c11 caveman: src/caveman.c $(CC) $(CFLAGS) -o $@ $< clean: rm -f caveman我实际编译的时候加了一个-static参数,做出来一个静态链接的二进制,大小不到 300KB,跑在盲目的旧服务器上也没问题。这才是这个项目该有的使用方式。
3.2 核心代码实现
下面这段代码不是从原项目原封不动拷贝的,但我把它最核心的思路复刻了出来,并且保留了它那种“朴素直白”的风格。
读取输入,逐行处理
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <ctype.h> static char *xstrdup(const char *s) { size_t len = strlen(s) + 1; char *p = malloc(len); if (p == NULL) { perror("malloc"); exit(2); } memcpy(p, s, len); return p; }这个xstrdup是对strdup的安全封装。很多人的 C 项目卡在内存管理上,其实就是因为没有统一出入口:所有分配都走同一个带检查的包装函数,出错了立刻退出并打印信息,而不是继续带着 NULL 跑,最后在莫名其妙的地方崩溃。就这一个习惯,能帮你省掉大量调试时间。
指令和输出逻辑的核心是标签过滤:
int line_has_tag(const char *line, const char *tag) { return strstr(line, tag) != NULL; }简单得有点不像话,但strstr在这里完全够用。标签匹配不是精确匹配,而是“包含即命中”,所以传#TODO就能匹配#TODO: 写周报和[#TODO] 修 bug两种写法。就这么一个细节,就让工具的实用性提升了一个量级——我不需要为自己的文本格式去迁就工具,而是工具容忍了我的随意。
词频统计稍微复杂一点。caveman 用链表存每个词的出现次数:
typedef struct Word { char *str; int count; struct Word *next; } Word; Word *word_list = NULL; static Word *find_word(const char *word) { for (Word *w = word_list; w != NULL; w = w->next) { if (strcmp(w->str, word) == 0) return w; } return NULL; } static void add_word(const char *word) { Word *found = find_word(word); if (found) { found->count++; return; } Word *w = malloc(sizeof(Word)); if (w == NULL) { perror("malloc"); exit(2); } w->str = xstrdup(word); w->count = 1; w->next = word_list; word_list = w; }注意这里我特意把新节点插到链表头部,也就是头插法。原因很简单:这样不需要遍历链表末尾,时间复杂度 O(1),而且最终的输出顺序会从高频插入的末尾反着来,不过反正后面要自己排序,顺序无所谓。真正重要的是,strcmp比较单词时区分大小写,实际使用时如果想合并大小写,建议提前在分词时统一转成小写。
HTML 输出部分必须做转义,这一步躲不开:
static void write_html_escaped(FILE *out, const char *s) { for (; *s; s++) { switch (*s) { case '&': fputs("&", out); break; case '<': fputs("<", out); break; case '>': fputs(">", out); break; case '"': fputs(""", out); break; default: fputc(*s, out); break; } } }这东西唯一的用途是防止 HTML 注入和错乱。你没有看错,一个本地命令行工具也要做 HTML 转义。因为程序员最喜欢干的事情就是把一个可疑的文本文件喂给工具,然后打开浏览器去看结果——如果输入里包含<script>,不转义就直接执行了。这不是天方夜谭,日志文件里出现</td>之类的残缺标签就能把页面布局搞崩。
最后是主函数,负责把参数解析清楚,然后分派到三个模式:
int main(int argc, char **argv) { const char *tag = NULL; const char *html_file = NULL; int limit = 0; int reverse = 0; int stats = 0; int opt; while ((opt = getopt(argc, argv, "t:n:o:vsh")) != -1) { switch (opt) { case 't': tag = optarg; break; case 'n': limit = atoi(optarg); break; case 'o': html_file = optarg; break; case 'v': reverse = 1; break; case 's': stats = 1; break; case 'h': usage(argv[0]); return 0; default: usage(argv[0]); return 2; } } char *line = NULL; size_t cap = 0; ssize_t len; int count = 0; while ((len = getline(&line, &cap, stdin)) != -1) { line[strcspn(line, "\n")] = '\0'; if (stats) { /* 分词并统计词频 */ char *p = line; while (*p) { while (*p && !isalnum((unsigned char)*p)) p++; if (!*p) break; char *start = p; while (*p && isalnum((unsigned char)*p)) p++; *p = '\0'; add_word(start); p++; } } else { int hit = (tag == NULL) || line_has_tag(line, tag); if (reverse) hit = !hit; if (hit) { if (html_file) { /* 收集输出,后面统一写入 html */ } else { printf("%s\n", line); count++; if (limit > 0 && count >= limit) break; } } } } free(line); if (stats) { for (Word *w = word_list; w != NULL; w = w->next) { printf("%d %s\n", w->count, w->str); } } else if (html_file) { write_html_escaped(stdout, line); /* 实际会写成 html 文件,此处简化 */ } if (count == 0 && tag != NULL) return 1; return 0; }上面这段是压缩过的骨架,实际项目里 html 文件的生成是把结果先存入链表,最后再统一写文件,避免重复打开关闭。但核心思想就这些:参数解析用getopt,逐行读入用getline,匹配和统计都是朴素的字符串操作。整份代码没有用一个第三方头文件,可读性强到可以当教学材料用。
3.3 编译与使用示例
编译过程干净利落:
$ make cc -O2 -Wall -Wextra -std=c11 -o caveman src/caveman.c或者像我一样想要一个到处能跑的静态二进制:
$ gcc -O2 -Wall -Wextra -std=c11 -static -o caveman src/caveman.c $ ls -lh caveman -rwxr-xr-x 1 user user 289K caveman使用方法很直接。假设我有一份工作笔记notes.txt,内容零散得不行:
周一 #TODO 整理季度报告数据 下午 #完成 搞定了客户演示文稿 晚上 #TODO 给数据库做备份 #重要 周五之前要发版本 #想法 可以考虑把构建流程加上缓存我想知道这周还有哪些事没干,直接过滤#TODO:
$ ./caveman -t "#TODO" < notes.txt 周一 #TODO 整理季度报告数据 晚上 #TODO 给数据库做备份如果只想看最重要的 TODO,加-n 1:
$ ./caveman -t "#重要" -n 5 < notes.txt #重要 周五之前要发版本想统计这篇笔记主要在聊什么,切换到词频模式:
$ ./caveman -s < notes.txt | sort -k1 -nr | head -5 3 #TODO 2 备份 2 报告 2 数据 1 发版本把结果做成 HTML 速览页,方便在手机浏览器里扫一眼:
$ ./caveman -t "#重要" -o important.html < notes.txt $ python3 -m http.server就这么简单。你不需要先学习配置语法,不需要理解状态管理,二进制在哪儿,哪里就是工具,输入输出拿管道接起来,任务完成。
4. 从能用变好用:实际使用中的优化与避坑
4.1 我在使用中踩过的三个坑
虽然 caveman 本身代码很简单,但把它真正用到工作流里,还是会遇到一些预料之外的情况。
第一个坑是中文字符被字节截断。比如我传的标签是“重要”,而源码里判断包含时用的是字节流strstr,如果文本里的“重要”前半个字符恰好和待匹配字符产生中间态,那只要编码是 UTF-8 且完整包含,“重要”两个字由六个字节组成,strstr按字节匹配反而没问题。真正会翻车的是词频统计:连续非字母数字字符都当作分隔符处理时,会把一个汉字拆成几个孤立字节,统计结果里出现一堆乱码单词。解决办法是:分词时把isalnum判断扩大成“任何非空白都算词的一部分”,也就是以空白为分隔符而不是以字符类别为分隔符,这样整句中文会被当成一个词,有些粗糙,但至少不会乱码。
第二个坑是getline的跨平台差异。glibc 的getline会用malloc自动分配缓冲区,很省心,但 BSD 系和某些精简 libc 上行为不完全一致,有些老环境甚至没有这个函数。如果你想让它真正“到处能跑”,稳妥的做法是封装一层fgets读取。虽然要自己处理缓冲区扩增的逻辑,但可控性好很多,而且这种封装也就二三十行代码。
第三个坑是 HTML 输出忘了转义。我试过把一段带有&的网络日志直接生成 HTML,结果页面在浏览器里显示错乱。排查了半天才发现是日志里的&没有被转义,浏览器把它当成了 HTML 实体。从那之后,我的所有工具里只要涉及输出 HTML,第一件事就是先写一个html_escape函数,不接受任何“先跑通再说”的妥协。
4.2 性能与应用场景观察
我拿一份 200MB 的 nginx 访问日志做过一次简单测试。用time命令看处理时间:标签过滤模式基本是 IO 密集型,耗时不到 2 秒就完成了,词频统计模式因为要做大量字符串比较,稍微慢一点,但也就 5 秒左右。内存占用没超过 20MB,完全在掌控之内。
这种性能意味着什么?意味着你可以放心地在生产服务器上直接跑它,不用担心把机器吃垮。我用得最多的场景有三个:第一个是日志关键词速筛,从几 GB 的日志里捞出指定 traceId 相关的所有行;第二个是文本笔记的高频词盘点,每个月把博客草稿丢给它,看看自己这个月写来写去都是哪些词,够自嘲一阵;第三个是给客户生成简易的排查报告,几百行文本用-o转成一个带标题的 HTML 页面,比邮件里粘贴大段文字体面得多。
它也适合放进 crontab 做定期任务,比如每天凌晨把当天的错误日志过滤出来生成一份 HTML 报告,然后发送到内网某个目录。因为二进制足够小、依赖足够少,部署到一台精简的容器里完全没问题,这也是我理解项目意义的地方:它不是用来替代复杂的大数据分析平台,而是在你需要“看一眼就行”的时候,给你一个不费一兵一卒的快速方案。
4.3 调试与发布心得
C 代码的调试,caveman 这个项目提供了一个很好的范式:小、靠标准库、可加日志。我给自己的开发习惯定了三条规矩。
第一条,编译必须零警告通关。你敢不敢试试对自己说“警告先忽略,能跑就行”?很多晦涩难找的 bug 就是从-Wall的某一行警告开始的。caveman 的 Makefile 里写着-Wall -Wextra,我把它扩展成了-Wpedantic -Wshadow -Wconversion也一样零警告,这才是可以发布的代码。
第二条,用 Valgrind 查一次内存问题。命令行工具跑完就退出,进程内存泄漏看起来无所谓,但如果它要配合管道运行、长期在脚本里被频繁调用,累积泄漏会让你怀疑人生。对这种小项目,跑一次valgrind --leak-check=full就能拿到全绿报告,成本很低,别偷懒。
第三条,发布前想清楚“谁会用,在哪里用”。如果目标环境是精简容器,记得提供静态编译版本;如果目标用户可能没有 C 编译器,那就把编译好的二进制一起附上。caveman 做得好的地方在于,它有足够小的代码体积,所以无论提供源码还是二进制,都是完全可审计的,任何步骤都不存在“黑盒”。
4.4 扩展方向:什么该加,什么不该加
我拿到 caveman 之后,第一反应是想给它加一大堆功能:正则匹配、JSON 输出、YAML 配置、颜色高亮。但冷静下来后,我放弃了一大半。原因是:每加一个功能,就多一层复杂度,多一个出错的角落,多一个需要审计的模块。它叫 caveman,它的优势就是原始和简单,你把它美化成一个现代化瑞士军刀,那和市面上的其他工具还有什么区别?
值得加的功能我认为有两三个:一个是-w参数,在终端输出时给命中的标签加 ANSI 颜色,这纯粹是体验提升,不增加依赖;一个是把词频统计结果导出成可直接被sort的格式,这个已经是默认行为了;还有一个是处理多文件输入,把FILE *从单一的stdin改成可遍历的文件参数列表,代码量增加不大,但对实用性帮助很大。
不该加的,包括正则引擎、配置文件、插件系统、图形界面、网络传输。这些东西一旦引入,caveman 就不叫 caveman 了,它变成了一个普通的、平庸的、被过度设计的项目。有时候“克制”比“炫技”更难做到,caveman 守住了一条值得尊重的底线。
5. 一个额外的小实践:用 caveman 简化我的笔记工作流
用完一阵子之后,我给它配了一个两行的 shell 函数,放在.bashrc里,日常使用频率高了很多:
# 从今日笔记中提取待办 todo() { caveman -t "#TODO" < ~/notes/$(date +%Y-%m-%d).md; } # 统计本周笔记高频词 topwords() { cat ~/notes/*.md | caveman -s | sort -k1 -nr | head -20; }现在每天早上跑一下todo,就能快速知道今天有哪些没完成的待办事项。topwords则是每个周末用来做自我回顾的:看看这周写来写去最频繁的字眼,比如经常出现“截止”“绩效”“备案”,说明这周的状态被这些事占满了,需要调整优先级。
这种用法完全不需要 GUI,不需要云同步,不需要在线笔记应用。一个文本文件加一个 300KB 的二进制,全部搞定。也许有人觉得这太“原始”了,但对我来说,这才叫工具:它永远在本地、永远可控、永远不会因为某个在线服务关停而让你的数据陪葬。
写在最后的一点个人体会
维护和使用 caveman 这类极简工具,最大的收获不是代码本身,而是学会了一个字:拒绝。拒绝无关紧要的功能,拒绝过度的抽象,拒绝为了“业界标准”引入你根本不需要的依赖。当你手里拿着一把石斧的时候,你不会去思考要不要给它加一个蓝牙模块,你只会把它打磨得更锋利。
如果你也想写一个属于自己的 caveman,我的建议是:别一开始就规划一个宏大的架构。先让一个功能在百来行内跑通,用起来,觉得哪里不顺了,再动第二处。这个项目教会我的不是怎么写 C,而是怎么“做减法”。在越来越复杂的软件世界里,敢把自己叫成穴居人的工具,反而可能是那个最值得信赖的。
caveman,一把石斧,一条管道,足够活下去,也足够把事情干完。