☰
用Go+SQLite写一个零依赖命令行待办工具:caveman的设计与实现
2026/10/29 13:57:33 网站建设 项目流程

年前我给自己定了个规矩:新一年的项目,工具链能少一样是一样。结果绕了一圈,反而写了个命令行待办工具,名字就叫caveman——原始人。起这个名字倒不是自嘲,是我突然想明白一件事:现代项目管理工具越做越重,打开要 3 秒、同步要登录、插件装一大堆,我实际需要的功能一只手数得过来,那为什么不用最朴素的方式把它做出来?

caveman就是这么诞生的:一个零依赖、本地优先、纯键盘操作的极简待办/习惯追踪命令行工具。不联网、不开服务、不弹通知,运行起来只有一个二进制文件和一个 SQLite 库文件,回车就能录入任务,每天干了什么一屏看得完。折腾了大概三周,从原型到发布 v0.1.0,整个过程里踩的坑比写的新功能还多。这篇文章就把完整的设计思路、核心实现、排查过程都记录下来,给想自己动手做终端工具、或者正在纠结“要不要用重型项目工具”的朋友一个参考。

1. 被“全家桶”工具折腾够了,才想起回归原始人

1.1 我的项目工具换了好几轮,问题不在功能

先说清楚我为什么放着 Notion、Todoist、Obsidian 这些现成工具不用,非要自己造轮子。不是它们不好,是把它们“伺候好”很累。那时候我用 Notion 搭了一个看起来很完整的个人任务库:数据库视图、标签、日历、关系表、公式一应俱全。录一个任务至少要点四次鼠标,还要顺手套个模板;在手机上打开更是噩梦,卡片拖来拖去,稍不留神就改错了字段。Todoist 倒是很快,可它默认云端同步,我把任务写进去,数据不在自己手里,总有点不踏实。

折腾了几个月,我心里渐渐冒出一个反问:一个“记接下来要干什么”的工具,凭什么需要后台服务、多端同步、实时协作?这些功能对个人用户来说,其实都是过度设计。真正高频的动作就是三个——录入、查看、勾选。顶多加一个分组。所以我决定自己写一个,规则定死:启动时间不能超过 100ms,录入一个任务不能超过 3 次按键,数据文件必须能用文本编辑器直接打开查看。

1.2 需求拆解:三个核心原则

写代码之前先把需求收拢成几条硬约束,后续所有取舍都围绕它们展开。

  1. 零依赖:运行时只有一个可执行文件,不需要 Python 环境、不需要 Node、不需要外挂解释器。最好还是一个静态编译的二进制,拷到任何 Linux 服务器上都能直接跑。
  2. 本地优先:所有数据落在本地一个文件里,绝不自动上传。同步这件事后续如果要,也应该走主动导入导出,而不是默认联网。
  3. 纯键盘操作:整个界面不需要鼠标,方向键和几个快捷键覆盖全部操作。终端里能做到这件事,效率上限是很高的。

这三条约束直接框定了技术选型。我的目标是:不用改配置文件,不用额外部署,下载下来就能用。谁也不想为了一个待办工具还得先初始化环境。项目起名caveman,多少也有点自我提醒的意思——把工具做得像原始人的石斧那样,纯粹、趁手、没有多余的装饰。

2. 技术选型:为什么最终选了 Go + SQLite + 手写 ANSI

2.1 语言对比:Go 胜出的三个理由

最开始我并不是直接选 Go。第一版原型其实是 Python 写的,因为sqlite3标准库直接可用,写起来快。Python 版大概两百行就打通了核心流程,但一细想不对:用户为了跑我的工具,电脑上得有 Python 3.10+,还得自己处理依赖。这违背了“零依赖”的第一原则。随后我做了一轮对比评估:

技术方案单文件分发交叉编译难度终端渲染生态上手成本
Go静态编译,天然单文件低,一条命令生成多平台二进制标准库加控制码够用低
Rust单文件较容易,但编译慢中等,需要配合 target生态更好(ratatui),但偏重高
Python需要解释器,打包成单文件很麻烦几乎不可行依赖第三方库低
C单文件容易,但开发效率太低中等太原始高

结论很清楚:Go 是“单文件分发 + 开发效率”两者权衡最佳的点。它编译出来就是纯静态二进制,扔到usr/local/bin就能跑;交叉编译一条GOOS=windows GOARCH=amd64 go build就完事。更重要的是,Go 的标准库对终端交互支持足够,不需要为了一个小工具引入一整套复杂框架。

2.2 存储层:SQLite 而不是 JSON 文件

数据存储我直接排除了 JSON 文件方案。原因很简单:待办数据的结构一旦复杂起来(任务、子任务、标签、截止日期、循环规则),JSON 的读写就会变得很煎熬。每次改动都要把整个文件读进来、改完再全量写回去,数据量小没事,但一旦任务上千条,一次git status都会拖慢。我更想要的是:可以按条件查、可以增量更新、有事务保护。

SQLite 完美满足需求。它也是单文件,不需要服务器进程,官方 C 库几十年前就开始被各种应用使用,稳定性非常可信。为了获得更好的并发表现,我把连接参数设为 WAL 模式。在 WAL 模式下,读操作不会阻塞写操作,多个 goroutine 并发读也没问题。这一点在后面踩坑时起到了关键作用。

数据库建表也很直接,核心就一张tasks表加一张可选的tags关联表。为了支持子任务,表里放了一个parent_id外键指向自己,组织起树形结构。日期存储用TEXT格式的YYYY-MM-DD,排序、比较都方便,不搞花里胡哨的时间戳。

2.3 界面层:手写 ANSI 转义码而不是引入 TUI 框架

这是被问得最多的问题:“你为什么不直接用 bubbletea 或者 tview?”说实话,我用过 bubbletea 写别的项目,它的消息循环模型设计得的确优雅。但放到caveman上,我做了个保守决定:界面只有四五个区域,交互也只有方向键和快捷键,引入一个框架反而要处理事件循环的调度、样式的抽象,出了 bug 排查得更费劲。手写 ANSI 转义码看起来原始,却完全够用。

所谓 ANSI 转义码,本质上就是往终端输出一些以\x1b[开头的控制序列,用来移动光标、改颜色、清屏。比如\x1b[2J清屏,\x1b[H把光标移到左上角,\x1b[31m把前景色设为红色。这套东西是终端的原生语言,不依赖任何库,任何 POSIX 终端都支持。写起来也谈不上复杂:

const ( reset = "\x1b[0m" bold = "\x1b[1m" green = "\x1b[32m" yellow = "\x1b[33m" red = "\x1b[31m" clearScreen = "\x1b[2J" homePos = "\x1b[H" )

这里有个关键观念:终端程序不一定要做一个“图形界面”,它完全可以像写文档一样,用 println 把当前状态打给用户。而我只需要每次状态变化时,把“整帧”重新绘制一遍就行。既然待办列表通常就几十行,这种全量重绘根本不会造成性能问题。

3. 核心实现:从数据模型到键盘交互的完整拼图

3.1 数据模型:一张自引用的表撑起整个项目

项目数据层没有用 ORM,直接写 SQL。建表语句如下:

CREATE TABLE IF NOT EXISTS tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open', -- open / done priority INTEGER NOT NULL DEFAULT 0, -- 0: 普通 1: 低 2: 高 parent_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE, due_date TEXT, -- YYYY-MM-DD tags TEXT, -- 逗号分隔 created_at TEXT NOT NULL, completed_at TEXT ); CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status); CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date);

这张表设计成自引用,主要是为了支持任务拆解。举例来说,主任务“整理博客文章”下面可以挂子任务“重写第三章”“画架构图”,在 SQL 里只需要查WHERE parent_id = ?就能把子任务全部捞出来。ON DELETE CASCADE让父任务删除时子任务也跟着删除,省了几个DELETE FROM tasks WHERE parent_id = ?的代码分支。

状态字段只用open和done两种值,没搞doing/pending/waiting那一套。因为我统计过自己真实的使用习惯:一个任务不是没开始,就是干完了,中间态反而容易让列表变脏。这也符合“原始人”原则——二分法最可靠。

3.2 存储层:Go 里的 sqlite 驱动与 WAL 设置

Go 语言本身没有内置 SQLite 驱动,我选的是纯 Go 实现的modernc.org/sqlite,而不是依赖 CGO 的mattn/go-sqlite3。原因非常实际:CGO 方案在交叉编译时需要目标平台的 C 编译器,太麻烦。纯 Go 方案没有这个限制,GOOS=linux go build就直接出静态二进制。代价是性能比 CGO 慢一点点,但对一个待办工具来说,数据量最多几万行,慢的那点基本无感。

连接数据库时,我设置两个关键的 pragma:

db, err := sql.Open("sqlite", filepath.Join(dataDir, "caveman.db")) db.Exec("PRAGMA journal_mode=WAL;") db.Exec("PRAGMA foreign_keys=ON;")

journal_mode=WAL前面说过,是为了读写不互相阻塞。foreign_keys=ON很重要,SQLite 默认不检查外键约束,不打开的话ON DELETE CASCADE根本不会生效,子任务就不会跟着父任务一起删。这是很多 SQLite 新手最容易忽略的坑。

3.3 TUI 渲染:不用增量刷新,整帧重绘反而最省心

界面渲染我把整块屏幕拆成四个区域,从上到下依次是:标题行、统计栏、任务列表、底部快捷键提示。每次按键触发状态变化后,直接调用一次render()方法,整个界面重新输出一遍。逻辑很朴素:

func (app *App) render() { var b strings.Builder b.WriteString(homePos) // 光标回到左上角 b.WriteString(clearScreen) // 绘制标题 b.WriteString(bold + " caveman " + reset) // 绘制统计栏 fmt.Fprintf(&b, " 总任务:%d 已完成:%d 高优先级:%d\n", ...) // 绘制任务列表 for i, task := range app.tasks { if i == app.cursor { b.WriteString(">" + highlightStyle) } else { b.WriteString(" " + normalStyle) } // 输出任务标题、优先级、截止日期 } // 绘制底部快捷键提示 b.WriteString("\n按 [a] 添加 [d] 完成 [x] 删除 [q] 退出") fmt.Print(b.String()) }

这里最重要的三个转义序列是:\x1b[H(光标到左上角)、\x1b[2J(清屏)、\x1b[<n>A(光标上移 n 行)。它们组合使用就能做到“看起来像是整个界面原地刷新了”。其实严格来说,每次clearScreen会先清一次屏,终端多少会有一点点闪烁,这在后面踩坑章节我会专门讲怎么优化。

3.4 快捷键交互:用 syscall 读原始输入,一行命令搞定

读取键盘输入不需要特别复杂的库。Go 里标准做法是把终端设置为 raw 模式,然后逐字节读 stdin。raw 模式下终端不会自动回显字符,也不会等用户按回车才返回。配合golang.org/x/term库里的MakeRaw函数,代码大概是这样的:

oldState, err := term.MakeRaw(int(os.Stdin.Fd())) defer term.Restore(int(os.Stdin.Fd()), oldState) reader := bufio.NewReader(os.Stdin) for { r, _, err := reader.ReadRune() switch r { case 'j', 0x1b: // j 或 ESC 序列 // 处理方向键 case 'a': // 进入添加模式 case 'd': // 标记完成 case 'q': fmt.Print("\x1b[?25h") // 恢复光标显示 return } }

方向键在终端里不是单个字符,而是一串字节,通常以\x1b[A(上)、\x1b[B(下)、\x1b[C(右)、\x1b[D(左)表示。所以我用strings.HasPrefix判断读到的输入是否以\x1b[开头,再根据尾部字母区分方向。读完整的转义序列有个细节:一个转义序列可能会有多个字节,需要连续读取,不能只看第一个字符就决定,否则上下左右会被识别不准确。这算是我实战里调试最多的一个点。

添加任务的交互也保持极简:按a后,终端切回 cooked 模式,提示用户输入标题,回车确认后写库,再切回 raw 模式。整个过程不会超过 5 秒。这就是我在需求里提的“回车就能录入任务”。

4. 三个杀伤力巨大的实战坑:闪烁、锁库、换行错位

4.1 终端闪烁问题:从逐行清屏到光标定位

第一个版本跑起来后,最大的问题是界面肉眼可见地闪。每按一次键,整个屏幕黑一下再重新画出来,眼睛非常累。我一开始以为是clearScreen用得太频繁,于是决定把它们拆开排查。

我做了个最小测试:循环输出一行文字,交替使用\x1b[2J和\x1b[H,观察终端行为。结果发现,\x1b[2J会把整个屏幕缓冲区清掉,如果你的输出是在清屏之后才逐行写的,终端需要重新绘制所有行,自然就会闪。在慢速终端或者通过 SSH 远程连接时,这种闪烁会被放大得非常明显。

最后的方案是:不再清屏,只把光标移到左上角,然后用“整帧覆盖写入”的方式重绘界面。也就是说,先输出\x1b[H回到顶部,然后逐行覆盖旧内容;同时终端窗口的高度固定,如果新帧的行数少于旧帧,就在底部补上空行把残留清掉。这样一来,终端不需要经历“全黑再重画”,只是原地更新已有行,闪烁消失,手感丝滑不少。优化后的绘制代码大概是这样:

func (app *App) render() { var b strings.Builder b.WriteString(homePos) // 不再 clearScreen b.WriteString(bold + " caveman " + reset + "\n") b.WriteString(renderStats(app.stats) + "\n") for i, task := range app.tasks { b.WriteString(renderTaskLine(i, task, app.cursor)) b.WriteString("\n") } // 覆盖尾部残留:把光标移到最后,清到行尾 b.WriteString("\x1b[J") fmt.Print(b.String()) }

\x1b[J的作用是从光标位置清空到屏幕底部,正好可以抹掉旧帧比新帧多出来的那些行。这套组合拳里,homePos负责把光标挪回原点,整帧内容用缓冲一次性输出,旧内容自然就被覆盖了,还没有闪屏。

4.2 SQLite 被锁:小工具也会碰到 database is locked

发生得很突然。我为了给界面增加一个“后台自动统计”的功能,起了 goroutine 每隔几秒查一次任务数,然后并发更新界面。结果跑了几分钟,控制台直接报database is locked。我当时第一反应是“怎么可能,普通 SQLite 单文件并发读不应该锁写”。

后来排查才发现,问题出在连接上。database/sql的连接池默认可能开多个连接,而 SQLite 在同一个进程里允许多个连接同时读,但只允许一个连接写。我的两个 goroutine 同时拿到了两个连接,一个准备写,一个也在写,后者的写请求就撞上了。更隐蔽的是,有些INSERT操作触发自动事务后,如果长时间没提交,其他连接会一直等。

修复思路有两个方向。第一,把db.SetMaxOpenConns(1)设成 1,强制所有操作走同一个连接,这样彻底避免锁竞争。第二,只保留一个 goroutine 做所有写操作,其他 goroutine 通过 channel 发消息给它,这就是经典的“单一写者模型”。我两个措施都上了,即写操作全在一个专门的 goroutine 里跑,数据库连接也限制为单连接。

db.SetMaxOpenConns(1) type writeRequest struct { query string args []any errCh chan error } writeCh := make(chan writeRequest) go func() { for req := range writeCh { _, err := db.Exec(req.query, req.args...) req.errCh <- err } }()

走这个模型之后,database is locked再也没出现过。对个人小工具来说,这比引入复杂的重试退避机制可靠得多。

4.3 跨平台踩坑:Windows 下表格全乱,只剩换行符背锅

发布 Beta 版时,有朋友在 Windows 的 PowerShell 里跑了一次,截图发过来,界面惨不忍睹:所有行叠在一起,状态标记和日期完全错位。第一反应是所谓 ANSI 转义码在 Windows 老终端不被支持,但实测新版 Windows Terminal 已经支持 ANSI 了,所以问题还更深。

我拿一台 Windows 虚拟机复现,用一个十六进制工具把输出管道里的字节打出来,马上看到原因:我往输出里写了\n(LF,0x0A),但在 Windows 默认控制台上 stdout 会把\n自动转成\r\n(CRLF)。如果我的渲染逻辑里某些地方以为一个\r\n占了两个字符宽度,行号计数就错位了,后续所有 ANSI 光标移动指令都跟着偏。

解决办法是:写代码时不直接依赖换行符,而是“显式管理行”。凡是要换行,要么统一走\r\n,要么干脆只用转义序列挪光标,比如\x1b[1E(光标移到下一行开头)。经过优化,我的每行渲染都以 ANSI 序列为主,换行符只用于文本流,不再参与排版计算。实测 Windows Terminal、Ubuntu 22.04、macOS iTerm2 三种终端都正常了。

这个坑给我的经验是:终端程序的排错,先把字节流 dump 出来再下结论。肉眼盯着源码看两个小时,不如一次十六进制打印来得清楚。

5. 打磨与发布:从能用到好用,还差一个 README 的距离

5.1 版本规划与自动化:v0.1.0 该有哪些功能

写完核心功能,我开始做发布前的收敛。v0.1.0 我给自己划定了一个功能边界:支持任务增删改查、支持显示已完成任务、支持按优先级排序、支持导出为纯文本清单。额外的东西一律不塞。确定了边界之后,剩下三件事:加自动化测试、写文档、做多平台构建。

自动化测试重点落在数据层,因为 TUI 部分本质上是“逻辑喂数据,数据触发渲染”,把 SQL 操作验证好,界面就不会出大岔子。测试用例包括:添加任务后能查出来、完成父任务后子任务状态更新、删除父任务级联删除子任务。为了测试时不污染真实库,我让数据库路径支持环境变量传入,测试里用临时目录,测完直接删除。

多平台构建我用了一行脚本配合 GitHub Actions:

GOOS=linux go build -o release/caveman-linux GOOS=darwin go build -o release/caveman-macos GOOS=windows go build -o release/caveman.exe

在 CI 上把这组命令串起来,每次打 tag 就自动跑一遍,产物直接挂在 Release 页。这套流程跑起来后我再也不用手动交叉编译了。

5.2 README 怎么写:演示动画代替 500 字功能介绍

文档这块我想多说一点。第一版 README 我写得很“正经”:项目简介、安装说明、使用教程、快捷键表格,洋洋洒洒写了六百多字。发出去之后朋友们的反馈是:“看起来很强,但我还是不知道实际用起来什么感觉。”

后来我把 README 重构了。最重要的一个改动就是录了一段三分钟的屏幕录制动画,放在文档最前面。终端工具是最适合用动画展示的,因为它的核心价值就是“快”:启动快、录入快、切换快。一段录屏顶得上一千字介绍。然后在动画下面才跟一个两行的简介:这是什么、为什么做它。安装命令用 code block 放在第三眼位置。快捷键表放到最后,等用户真的安装完再回来看。实测效果非常明显:好几个看完动画当天就装上了,因为他们发现“启动只要 50ms,比开会打草稿还快”。

5.3 用户反馈带来的新思考:不用为了同步而同步

发布后收到不少有意思的反馈。有人问为什么不支持跨设备同步,我说核心设计就是本地优先,不能背叛这个原则。但也有人提出一个新思路:同步未必是后端服务,可以是“把 SQLite 文件放进网盘目录”或“导出一份 Markdown 清单自己同步”。这个思路一下点醒我。于是我在 v0.1.1 里加了一个export子命令,可以把所有任务输出成 Markdown 格式文件。用户想同步就自己导出去,不想同步就留在本地,选择权真正还给用户。

还有人建议加日历事件支持,但那是很遥远的事情了。现阶段我认为caveman最大的价值不是功能全,而是它证明了一件小事:个人工具完全可以做到“不联网、不弹窗、不消耗注意力”。后续的方向如果有,应该是围绕“导入导出”和“批量操作”做深,让数据自由流入流出,而不是圈进一个封闭的同步服务。

6. 尾声:一个小技巧与我的真实体会

最后分享一个实战里很有用的小技巧,那也是在用caveman管理我这篇文章的时候发现的。因为全键盘操作,输入中文任务标题时终端输入法会有点别扭,所以我给自己设定了一个“英文 tag + 中文标题”的混合录入习惯,比如[article] 重写第三章,既能在列表里快速扫出类别,又不影响中文可读性。

做这个项目最大的体会是:别迷信工具的功能数量,先把最影响你的高频动作打磨到极致。我本来是重度笔记工具用户,结果发现真正让我坚持用下来的不是那些插件市场,而是“回车就记录、方向键就切换、一秒不等待”的原始快感。caveman让我把管理任务这件事的时间成本压到了几乎为零,也重新找回了那种“我为工具定义规则”的主动权。如果你也被各种现代工具的信息噪音困扰,不妨也试着退一步,想清楚自己真正需要的操作是什么,然后用最朴素的方案把它实现出来。石斧虽然丑,但砍树是真的好用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询