尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

拒绝过度工程:Caveman 极简命令行笔记工具的设计与实践

拒绝过度工程:Caveman 极简命令行笔记工具的设计与实践 “你确定这个项目要叫 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 带来的内存安全和性能收益。下面这张表是我当时真实做过的对比维度GoPythonRust单文件分发编译后一个二进制随便拷需要解释器和依赖环境编译后一个二进制但体积更大启动速度毫秒级百毫秒级毫秒级标准库能力有 net、os、json、flag 全家桶需要 pip 装第三方包标准库没有好用的 json 命令行交互开发效率中等偏上类型系统够用最快脚本式爽较慢所有权模型要试错长期维护我用得最熟环境迁移头疼学习成本高Python 写起来确实爽但分发问题太致命。给同事分享时人家机器上没有 Python 环境第一道门就卡住了。Go 只需go build出一个可执行文件Windows 上是.exemacOS 上是可执行文件扔到 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 而不是 SQLiteSQLite 是一个伟大的数据库但它对“穴居人”来说过于丰盛。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 存储显示时再转本地时区这个习惯从一开始就定下来避免遇到跨时区数据错乱。存储层就是一层薄薄的Storetype 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 ncaveman add alias ndcaveman 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.jsonWindows 上这其实不能正常工作。解决思路很简单不要手工拼路径用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里现在埋着几十个“当时觉得重要、现在看也无妨”的记录每次回看都像在翻自己的思维化石。如果你也被各种订阅制、云端化、多设备同步搞得有些疲惫不妨给自己写一把“石斧”——也许就是一个纯文本文件加一堆脚本也许就是几百行代码。留下的不仅是工具更是你在复杂系统之外还保留着的、能徒手建立秩序的能力。
返回列表