“你确定这个项目要叫 Caveman?”这是我最初把仓库发给一个朋友时收到的第一句反馈。他以为我在做一个远古生存模拟器,结果发现只是一个命令行记账本。但名字倒是歪打正着——后来整个项目在设计方向上变得越来越贴切:不依赖任何云服务,不加花哨的插件体系,不用数据库,就把所有事情用最笨、最直接、最耐糙的方式做出来。它像一个住在山洞里的工具,但每天都能准时出现在我的终端里。
Caveman 是我业余时间写的一个极简命令行笔记工具,核心只有六个命令:新增、列出、完成、删除、搜索、统计。数据全部存储在本地一个 JSON 文件里,没有任何外部依赖,编译出来就是一个十几兆的二进制文件。这篇文章写的东西不光是“我做了个什么”,而是把从起名字到砍功能、从选型到踩坑的完整过程拆开,希望能对正在琢磨自己做工具的朋友有点参考价值。如果你也遇到过“想记个东西却不想打开任何笔记软件”的瞬间,那这个项目大概就能说到你心坎上。
1. “穴居人”这个名字背后的设计与三个原则
1.1 为什么给一个工具起名叫 Caveman
起名字这事占了整个项目前期大概三分之一的时间。最初叫过 Note-cli、simple-notes,都很准确,但没有任何记忆点。一次和朋友聊到“最原始状态下你还能不能用电脑干活”,我突然意识到:如果有一天网络断了、Notion 进不去、Syncthing 停了、各家笔记软件的同步协议挂了,我是不是还能快速把脑子里的零散想法落到本地文件里?
答案是可以,只要有 vim 和文件系统就够了。这个想法让我决定用“穴居人”作为项目代号:不是嘲讽原始人,而是回到工具最本源的生存形态——能用就行、坏了能修、离线也能跑。一个灵感如果能用一条命令在 0.1 秒内落到磁盘,它就远不需要那些十层包裹的架构。
1.2 三个设计原则:原始、简单、可靠
Caveman 的代码在重构时反复被三个词拉扯,最后成了明确的设计基准:
- 原始:只用 Go 标准库,不走第三方框架,不接外部服务。命令交互就是最朴素的
flag解析,输出就是普通的文本和 JSON。你随便打开一个文件目录,很快就能知道整条数据流是怎么回事。 - 简单:用户只面对六个动词。没有配置向导,没有初始化引导,安装完直接
caveman add "待办事项"就能用。选项参数能省就省,宁可在代码里写死默认值,也不给用户堆十来个下划线开关。 - 可靠:笔记数据必须是人类可读、随时可备份、工具崩了也能手工恢复的格式。因此 JSON 就是首选,而不是某种二进制私有格式。文件写入走“先写临时文件再改名”的原子替换策略,防止写到一半断电导致整个数据文件损毁。
这三个原则不是一开始就有的。最早一版我也想过要不要加 Web UI、要不要做远程同步,后来每一次犹豫都比对这三个词,答案自动就出来了。
1.3 用“穴居人视角”对抗过度工程
Caveman 这种项目本质上是对一组氛围的反抗:你打开 GitHub,随处可见待办工具套了 Kubernetes 一样的依赖树,仓库里 node_modules 比源码大几百倍,改一行配置还要等构建系统跑半天。
我见过一个“记笔记”的服务端项目,光中间件就用了六个,数据库迁移文件堆了六十多版,结果核心功能就是 CRUD。这种过度工程在真实世界里造成的伤害不是“占用内存”,而是让维护人疲惫不堪,最终主人自己都不敢动自己的系统。“穴居人视角”要求你先问一句:如果把所有花架子去掉,最核心的产物到底是什么?对 Caveman 来说,核心产物就是那个 JSON 文件和六个命令。所以它就坚决不碰任何多余的东西。
2. 需求边界:开工之前我砍掉了哪些功能
2.1 我先想清楚谁会用它、什么时候用
很多个人项目失败,是因为想满足所有人。我在动工前写了一份很随意的“用户场景”清单:
- 我在代码调试途中突然想起一个思路,想立刻记下来,但不能打开 APP、过两道验证码、等三个网络请求。
- 我有一堆零散书名、电影名、购物清单,不需要分类树,只需要一个
grep能搜到的地儿。 - 我想把已经完成的任务留存成一条时间线,方便每周回顾,数据要放在我自己能绝对控制的路径里。
- 我希望它能在 Windows 和 macOS 上都跑,不装解释器、不依赖 Python 环境。
这就是全部。Caveman 不需要做到像 Notion 那样能画数据库图表,不需要像 Todoist 那样有重复任务提醒。它更像一个“思维草稿纸”,定位是:愿意被你随时丢掉,又能在你需要时立刻捞起来。
2.2 最终保留的功能清单
六个子命令构成全部功能:
| 命令 | 作用 | 说明 |
|---|---|---|
caveman add | 新增笔记 | 支持-t指定标题、-b正文、-g标签 |
caveman list | 列出笔记 | 默认按创建时间倒序,--all显示已完成项 |
caveman done | 标记完成 | 按 ID 或关键词哈希标记,用时间戳记录完成时间 |
caveman rm | 删除笔记 | 支持按 ID 精确删除,避免误伤 |
caveman search | 关键词搜索 | 对标题、正文、标签做不区分大小写的子串匹配 |
caveman stats | 简单统计 | 总条目、完成率、本月新增数量 |
每个命令的输出都很克制,不做彩色高亮以外的多余效果。彩色也只是给状态字段上点颜色,方便扫一眼。
2.3 我特意不做的清单,以及为什么
明确“不做什么”比“做什么”更重要。Caveman 的不做清单很长,挑几个代表:
- 不做云同步:如果同步只靠我自己的 Git 仓库和 crontab 就完成,那为什么要内置一套只会把问题搞复杂的同步模块?数据躺在本地,拷走整个
notes.json就是备份。 - 不做富文本和附件:Markdown 的纯文本正文足够好。附件意味着要管 MIME、二进制存储、文件路径映射,这些全是给工具增加脆性。
- 不做标签树和分类系统:标签只是字符串数组,搜索时按字符串匹配就够了。为分类建树是在强迫用户“先设计再记录”。
- 不做 TUI 面板:交互式界面很酷,但一旦做出来,用户就失去用管道、脚本操作数据的灵活性。Caveman 要的是“可以被
grep、awk、xargs继续处理”,而非一个固化操作方式的界面。
这些砍掉的每一项,原本都可能让界面截图更好看,但都会让代码和使用的门槛同步上涨。砍完这些,Caveman 才真的开始好用。
2.4 每次取舍背后的逻辑:给价值排序
要不要做同步、做日历提醒、做附件拖拽,判断标准只有一条:它会不会让“快速记录”这个核心动作变慢?只要答案是“会”,哪怕反方向是“另一个功能很有前途”,也照样砍。如果一个功能不是每天高频被用到,它就不配住进一个叫“穴居人”的家里。这个取舍思路同样适合你考虑自己的任何工具项目:先定义一条绝不能触碰的核心路径,路径之外的东西全部低优先级。
3. 技术选型:为什么用 Go、为什么只用标准库
3.1 Go、Python、Rust 的对比分析
敲下第一行代码之前,我在 Go 和 Python 之间犹豫了挺久。Rust 被我快速划掉,不是因为 Rust 不好,而是这个项目的复杂度根本配不上 Rust 带来的内存安全和性能收益。下面这张表是我当时真实做过的对比:
| 维度 | Go | Python | Rust |
|---|---|---|---|
| 单文件分发 | 编译后一个二进制,随便拷 | 需要解释器和依赖环境 | 编译后一个二进制但体积更大 |
| 启动速度 | 毫秒级 | 百毫秒级 | 毫秒级 |
| 标准库能力 | 有 net、os、json、flag 全家桶 | 需要 pip 装第三方包 | 标准库没有好用的 json 命令行交互 |
| 开发效率 | 中等偏上,类型系统够用 | 最快,脚本式爽 | 较慢,所有权模型要试错 |
| 长期维护 | 我用得最熟 | 环境迁移头疼 | 学习成本高 |
Python 写起来确实爽,但分发问题太致命。给同事分享时,人家机器上没有 Python 环境,第一道门就卡住了。Go 只需go build出一个可执行文件,Windows 上是.exe,macOS 上是可执行文件,扔到 PATH 里就能用。这跟 Caveman 的“原始哲学”高度匹配:我宁愿多写几行代码,也不愿意让用户花十分钟配环境。
3.2 放弃 Cobra,只用标准库 flag 的考虑
Go 生态里做 CLI 一个很出名的库是 Cobra,很多知名工具都在用它。Cobra 提供子命令注册、快速帮助文档、shell 补全,功能非常丰富。但 Caveman 只有六个子命令,Cobra 的抽象层级反而成了负担:
- 引入 Cobra 意味着多一层命令树的概念,一个纯 hoc 子命令的需求用
flag.FlagSet就能表达清楚。 - Cobra 的自动补全依赖会生成 shell 脚本,这些东西分发时要额外考虑。
- 我对维护的信心更多建立在“看源码就能懂”的标准库层面。
用标准库flag并不是一种道德优越,而是具体规模下的理性判断。如果你准备做一个大而全的 CLI 工具,Cobra 完全正确;但做 Caveman 这种尺寸,标准库就是最明确的答案。FlagSet 的解析依然支持-t、-b、-g这些参数,只多写几行注册代码而已。
3.3 数据文件:为什么用 JSON 而不是 SQLite
SQLite 是一个伟大的数据库,但它对“穴居人”来说过于丰盛。Caveman 的体量停留在“几兆笔记文件以内”,这个区间 JSON 完全能打。用 JSON 的好处非常具体:
- 你可以用任意文本编辑器直接打开
notes.json修改,容错极高。 git diff能看到每一次内容变更,这对做个人时间线回溯极其有用。- 零初始化、零迁移,文件在哪数据就在哪,删除文件就等于重置。
代价是读写要整文件加载,超过 5 万条笔记后性能会开始变钝。但个人笔记的日常规模远低于这个量级,所以这个代价完全可控。真到了需要 SQLite 那一步,我会重新评估整个项目,而不是提前给自己加码。
3.4 最终的技术栈和目录结构
整个项目几乎没有“技术栈”可说,这是它最得意的地方。运行时只有三方:用户命令、Caveman 二进制、一个 JSON 文件。目录结构也平铺到极致:
caveman/ ├── main.go # 命令分发和 flag 解析 ├── store.go # 读取/写入 JSON 文件,含原子替换 ├── model.go # Note 结构体和时间戳处理 ├── search.go # 子串搜索和排序 └── notes.json # 数据文件,位于 $HOME/.caveman/ 下四个核心源码文件解决一个完整应用,我相信任何一个有 Go 基础的人,花一个小时就能把整个项目读通。对一个业余项目来说,这种可读性本身就是最大的长期收益。
4. 核心实现:从数据结构到原子写入再到搜索
4.1 数据结构:一个 Note 和它的存储层
Caveman 的核心数据结构非常简单:
type Note struct { ID string `json:"id"` Title string `json:"title"` Body string `json:"body,omitempty"` Tags []string `json:"tags,omitempty"` Status string `json:"status"` // pending | done CreatedAt time.Time `json:"created_at"` DoneAt *time.Time `json:"done_at,omitempty"` }ID 用时间和随机数拼成一个短字符串,避免用户需要记数字序号。时间戳统一用 UTC 存储,显示时再转本地时区,这个习惯从一开始就定下来,避免遇到跨时区数据错乱。存储层就是一层薄薄的Store:
type Store struct { path string mu sync.Mutex Notes []Note `json:"notes"` }Store只有两个核心方法:Load()读取整个文件,Save()原子写入。其它逻辑全部在切片上操作,简单到不需要 ORM。整文件的内存模型很简单,保证程序崩溃时至少不会出现文件内部结构错乱。
4.2 原子写入与并发保护的现实问题
最早一版我就是os.WriteFile一把梭,直到一次终端卡死让我丢了半天的笔记。原因是我打开两个终端操作同一个文件,后写入的数据把前一次操作覆盖掉了。修复方式是两个层面的:先加互斥锁,再做临时文件替换。
func (s *Store) Save() error { s.mu.Lock() defer s.mu.Unlock() data, err := json.MarshalIndent(s.Notes, "", " ") if err != nil { return err } tmp := s.path + ".tmp" if err := os.WriteFile(tmp, data, 0600); err != nil { return err } return os.Rename(tmp, s.path) }临时文件加Rename的思路来自许多成熟工具:先保证写入成功,再把临时文件替代原文件,这样哪怕进程在写入中途挂了,原文件也不会有残缺的半成品。0600的权限位是为了保护笔记隐私,在 Linux 和 macOS 上都适用。加载的时候也做了防御:如果存在.tmp文件,大概率上一次写入没完成,我选择忽略它并提示用户检查,避免把脏数据带进来。
4.3 命令分发:用 flag 包也能写得很清爽
main.go里的分发逻辑很直接,就是逐个判断第一个参数,然后切出剩余参数交给对应处理函数:
func main() { if len(os.Args) < 2 { usage() os.Exit(2) } switch os.Args[1] { case "add": addCmd(os.Args[2:]) case "list": listCmd(os.Args[2:]) case "done": doneCmd(os.Args[2:]) case "rm": rmCmd(os.Args[2:]) case "search": searchCmd(os.Args[2:]) case "stats": statsCmd(os.Args[2:]) default: usage() } }看起来像是“远古代码”,但它的行为完全透明,出错时一行代码就能定位。每个子命令内部再用flag.NewFlagSet注册属于自己的参数。好处是子命令之间的选项不会互相干扰,即便将来要加嵌套子命令,结构也足够清晰。FlagSet 默认对-h已经能自动打印帮助,这段代码只写了十几行,已经收获了一个完整的帮助系统。
4.4 搜索功能的朴素实现
Caveman 的搜索没做倒排索引、没做 TF-IDF,就是朴素子串匹配:
func (s *Store) Search(q string, includeDone bool) []Note { var out []Note q = strings.ToLower(q) for _, n := range s.Notes { if n.Status == "done" && !includeDone { continue } if strings.Contains(strings.ToLower(n.Title), q) || strings.Contains(strings.ToLower(n.Body), q) || tagContains(n.Tags, q) { out = append(out, n) } } sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) }) return out }对个人笔记规模来说,线性扫描的性能绰绰有余。比起引入更复杂的搜索实现,它换来了极低的理解成本。有些东西不是越高级越好,找到符合规模的方案才最舒服。我见过不少工具把简单场景硬做成 Elasticsearch,最后只是平白增加运维义务而已。
5. 真实使用体验:工作流迁移之后的变化
5.1 一个典型的半天工作流
以一个普通工作日上午为例。我收到的临时信息可能是这样的:调试一个偶发超时问题、需要查一个函数库的 API、收到一本书的推荐、要记下晚上买牛奶。用 Caveman 的话,四个操作:
caveman add -t "检查 order 服务偶发超时" -b "看 gateway 日志,确认 Nginx 超时时间" -g bug caveman add -t "读 Go 标准库 net/http 的 Transport 部分" -g read caveman add -t "打开《设计数据密集型应用》 pdf" -g read caveman add -t "买牛奶和鸡蛋" -g shopping等到中午整理时,用caveman list就能看到全部挂起任务,并按创建的先后排好。不像某些任务管理器强迫你把所有事情分门别类放好才开始记,Caveman 允许你“先扔进来再说”。这种零摩擦对捕捉碎片想法太重要了。
5.2 用 shell 脚本进一步整合
由于 Caveman 是标准输入友好型工具,它可以很轻地嵌进各种脚本。比如我做了两个 shell 别名:
alias n='caveman add' alias nd='caveman done'配合系统 cron 或计划任务,还可以每周日晚自动生成回顾:
caveman list --all | grep "$(date +%Y-%m)" | awk '{print}'更实用的是把搜索结果直接喂给后续命令。比如我今天想清理一个标签下所有已完成笔记:
caveman search -g shopping | caveman rm这不依赖任何 API,也不依赖图形窗口,只要有一个终端,任何运行环境都能串起来。
5.3 和“大而全”工具比,它真正赢在哪
我并不是劝所有人都抛弃 Notion 或 Obsidian。这些工具在知识整理、双链、协同上有无可取代的价值。Caveman 赢的场景是:当你只有 3 秒、你的手已经在键盘上、你的脑子只想着把它记下来。打开浏览器要 2 秒、打开客户端要 5 秒、找到对应文件夹再点新建更是遥遥无期,而caveman add只要一次击键加一句话。肌肉记忆一旦形成,它就变成了你大脑的快捷缓存。很多想法的质量取决于你捕获它的即时性,这一层价值远远大过功能数量。
6. 维护期间踩过的三个坑
6.1 第一个坑:Windows 和类 Unix 的路径差异差点让我放弃跨平台
最初我只在 macOS 上测试,一切正常。后来给 Windows 同事打包,Caveman 一执行就崩。排查发现os.UserHomeDir()在两个系统上都应该返回用户主目录,但 Windows 上如果用户配置过HOME环境变量,Go 的标准实现有时会拿到一个不期望的值。更隐蔽的是,路径拼接时我用/硬拼接了"$HOME/.caveman/notes.json",Windows 上这其实不能正常工作。
解决思路很简单:不要手工拼路径,用filepath.Join。改用os.UserConfigDir()做基础目录可以更规范,但为了保持“用户一个文件夹管所有”的直觉,我最终选择filepath.Join(home, ".caveman", "notes.json")。这是一个再基础不过的教训,但只有真跑到目标平台上手才会有记忆。
6.2 第二个坑:中文乱序让列表分页不对
另一个实战中的意外:我把笔记按标题排序时,中文的显示顺序和预期明显不一样。原因不复杂,Go 的默认字符串比较是基于字节的,而中文汉字没有统一的排列规则被字节序完美覆盖。好在我平时交互里根本不需要“按标题排序”,真实频率最高的是“按创建时间倒序”。于是我把默认排序直接锁死为CreatedAt倒序,彻底避开中文排序这个无底洞。
这一点给我提了个醒,尤其在做个人工具时:不要给你不需要的功能设计语法,更不要因为排序库顺手就以为用户需要它。中文排序的坑很典型,动不动就是 ICU 级别的复杂度,对一个笔记工具完全是“狱中绣花”。
6.3 第三个坑:并发写入的静默丢失
前面提到的原子写入修复,过程是这样的:我在两个终端里分别执行了caveman add和caveman done,两个进程都先读了同一个旧文件,然后在内存里各自操作,最后各自写回。后写回的那个把先写回的结果覆盖了,记录丢失得悄无声息。最初我甚至没发现,直到我数了数笔记数量才知道少了内容。
修好靠两层:第一,Store.Save()内加sync.Mutex保证单进程内并发安全;第二,写入走临时文件加原子替换,避免半途崩溃损坏数据。但真实的多进程冲突靠这两个还不彻底,所以我打印提示:如果两个终端同时写入,后启动的进程会接到“检测到数据文件已被修改”的警告。对个人工具来说,这个提示已经足够让人停手检查,毕竟同时编辑同一个小笔记文件的概率非常低。
7. 从“穴居人”到“智人”:项目后记与下一步
7.1 现在 Caveman 到底处于什么状态
Caveman 不是一个大项目,但它是个“活的”项目。目前它能完成我应该做的所有事情,日常使用频率至少每天十几次。代码在很多地方还能更好:比如错误处理不够统一、测试覆盖不全、没有 CI。但一个个人工具的边界就在这里:它不需要为了展示工程能力而显得工业级,它需要的是让我自己用起来顺手、想改哪一行都知道去哪改。
项目中我学到的最大一点是:守住“最小可用”比追逐“丰富功能”更难。每次想加功能,我都要想一圈它会不会让核心路径变臃肿。这种克制的收获非常直接——我没有维护负担,所以我愿意持续维护它。
7.2 下阶段我想做的事和不想做的事
未来如果继续往下走,我会优先考虑这些方向:
- 支持从 stdin 读取正文,例如
echo "临时想法" | caveman add,让管道用法更顺。 - 增加简单的
--export命令,输出成 Markdown 或 CSV,方便带出数据去别处。 - 尝试把核心逻辑拆成独立包,允许其他人基于 Store 做自己的前端,比如一个网页查看器。
但我不会去做的事更多:不加账户系统、不加多人协作、不搞插件机制。这些方向每走一步都离“穴居人”这个名字更远。一个工具如果为了讨好所有人而长成所有人都不熟悉的样子,那它就失去存在的理由了。
7.3 最后的小技巧:请留给未来的自己一扇窗
写完这个项目,我最大的体会是:工具不是越强越好,而是越贴手越好。Caveman 的notes.json里现在埋着几十个“当时觉得重要、现在看也无妨”的记录,每次回看都像在翻自己的思维化石。如果你也被各种订阅制、云端化、多设备同步搞得有些疲惫,不妨给自己写一把“石斧”——也许就是一个纯文本文件加一堆脚本,也许就是几百行代码。留下的不仅是工具,更是你在复杂系统之外,还保留着的、能徒手建立秩序的能力。