从Go到SQLite:我用500行代码写了一个极简任务API
发布时间:2026/10/19 15:15:55 锦皓数字建站

今年上半年我一直在用一个自己写的微型任务 API名字就叫 Nano Banana Tasks API。名字听起来像个实验品实际上它就是个实验品——只不过这个实验品我现在天天都在用。它的定位特别简单一个用 Go 写的、只做任务增删改查和状态流转的轻量接口服务不需要登录、不需要网页前端、不需要消息队列编译出来就是一个十来兆的二进制文件扔到哪都能跑。如果你也遇到过这种情景——明明只想快速记一个“下午三点前把报销单交了”结果打开项目管理工具要先加载半分钟、再点三个菜单、还得犹豫该放哪个项目组——那这个项目的思路应该对你有点用。它不是要做下一个全功能任务平台它要解决的是我自己“任务管理工具过度复杂”这个真实痛点。这篇文章会把 Nano Banana Tasks API 从动机、选型、设计、实现到实测踩坑的完整过程写出来适合那些想自己动手写轻量工具、或者对 Go 后端 API 开发感兴趣的开发者参考就算你完全没写过 Go也能看懂我在每个关键点上做的取舍和理由。1. 为什么是“Nano Banana”从“任务管理焦虑”到极简API1.1 先说说我为什么厌烦重型任务工具过去几年我换过好几轮任务管理工具。早期用那些带看板、带燃尽图、带文件上传的一体化产品功能确实不少但我的使用频率反而不高。原因很尴尬打开它们需要先过一遍登录、然后选择工作区、再看一堆我根本没有配置过的视图。明明是个“记待办”的需求硬生生被加上了“理解这个工具怎么组织任务”的前置步骤。到后来我基本退回用手机备忘录。后来我意识到一个不算新鲜的事实我的任务数据量级根本没到需要重型工具的地步。工作日一天少的时候三五条多的时候也就三四十条一个月的任务量连一千条都不到。这个量级用一个纯文本文件都够管理了却要为一个企业级多人协作场景设计的东西买单本身就是一种浪费。某次我连不上网想查一个任务的截止时间才发现那些工具离了云服务就是一块废铁——这彻底让我动了写一个本地优先的迷你 API 的念头。1.2 “极小可用”原则什么功能该砍、什么功能该留Nano Banana Tasks API 在设计之初就定了一条铁律只保留一个任务在其生命周期里最必要的动作。我摊开一张纸写下任务这东西从诞生到消失到底要经历什么创建、查看、改状态、改描述、删掉。完了。没有指派、没有标签系统、没有优先级看板、没有子任务、没有评论、没有提醒铃声。所有被我砍掉的功能都不是因为难做而是因为“加上去之后我要不要维护它”的成本超过了它给我带来的便利。这里有个经验想分享定义一个工具“不做什么”比定义它“要做什么”难得多。每次你砍一个看起来顺理成章的功能都得扛住“万一以后要用呢”这种心理压力。我的取舍标准只有一条——如果这个功能能用三行 shell 命令或一个外部小脚本替代就不进核心。标签系统可以用 SQL 查询 title 关键字凑合提醒可以用系统的 cron 去扫描这些都不值得放进首版。2. 选型的心路Go标准库SQLite能少一个依赖就少一个2.1 为什么选Go而不是Python或Node我在动手前真实比较过三条技术路线Python 的 FastAPI、Node 的 Express、以及 Go 标准库。FastAPI 写起来是真的快定义个模型再声明一个路径函数就完事了但最后让我放弃它的原因是部署形态——一个 Python 服务要么虚拟环境要么容器反正不是“一个文件丢过去就能跑”。Node 稍微好一点可以打包成单文件但内存占用又让我心里不舒服一个什么逻辑都没有的 Express 应用跑起来也要占用一百多兆内存这跟“nano”这个名字实在不搭。Go 在这三项里是折中最优解。它编译出来就是一个静态链接的二进制没有运行时依赖部署的时候连拷带跑两步结束。内存占用上我这个 Nano Banana Tasks API 跑起来大概也就十几兆。另一个加分的点是 Go 的并发模型后面讲 SQLite 锁的时候你会感受到它既是福利也是坑。简单的看法如果你也认同“个人工具应该轻到可以被忽略”Go 几乎是最省心的选择。2.2 为什么嵌入式SQLite就够用选 SQLite 而不是 MySQL 或 PostgreSQL完全是被一个朴素的需求推动的我想要“备份即复制文件”。SQLite 把整个数据库放在单个文件里我每天晚上把这个文件往移动硬盘里一拖备份就做完了不需要 mysqldump不需要考虑服务组件的依赖。个人任务这个数据量SQLite 绰绰有余我前面说过一个月撑死一千条记录这种规模对 SQLite 来说连热身都算不上。它的另一个优势是零网络监听。没有任何端口暴露给外部更好。我不需要操心数据库账号密码、防火墙规则、连接池配置。每次启动我的 API 时它只是打开一个本地文件读写数据。对于个人工具来说“少一个需要操心的环节”比“多一个看起来很专业的组件”重要得多。2.3 依赖控制第三方库一个都不加整个项目我最初的目标是第三方依赖数量为零但实际操作下来还是要屈服于数据库驱动。Go 标准库自带 net/http在新版本里甚至支持了带方法的路径注册比如mux.HandleFunc(POST /api/tasks, ...)这让我连 gorilla/mux 都不用引了。唯一躲不开的坑是 SQLite 驱动。这里我特意踩了一遍两个驱动之间的区别mattn/go-sqlite3是老牌驱动需要 CGO意味着你得提前装好 gcc交叉编译的时候格外痛苦而modernc.org/sqlite是纯 Go 实现的不需要 CGO编译的时候省掉一整个麻烦。我最后选的是纯 Go 的实现代价是编译的二进制稍微大一点但换来的是在任何平台都能直接go build太值了。算上它我的go.mod里一共就这一个外部依赖整个项目保持了一个很舒服的“裸”状态。3. 数据模型与API契约一个任务从生到死只留五个字段的状态机3.1 字段表设计砍掉优先级之后发生了什么首版数据模型我做过一次减法原先设计了id, title, description, status, priority, due_date, created_at, updated_at八个字段后来在写处理器的时候突然意识到优先级这个字段在我的真实用法里从来没被消费过。我从来没按优先级排过序也没有按优先级过滤的诉求它纯粹是“表里应该有”的惯性思维产物。砍掉之后表变得清爽很多。最终字段如下字段类型约束与说明idINTEGER自增主键titleTEXT任务标题必填长度 1 到 200descriptionTEXT补充描述可空statusTEXT枚举值 todo / doing / done默认 tododue_dateTEXTRFC3339 格式截止时间可空created_atTEXTRFC3339 创建时间由服务端填充updated_atTEXTRFC3339 更新时间每次修改自动刷新status 字段没有用 INTEGER 映射而是直接存人类可读的字符串。原因很简单这个系统没有任何性能压力存字符串让每次 curl 查询结果都能直接看懂我不需要再写一份“状态码 1 代表什么”的文档。对个人工具而言可读性优先于理论上的空间节省。3.2 路由与JSON契约够用的 REST不求满分API 路由我遵循的是一眼就能猜到行为的风格。四个动词配上一个集合资源和一个单资源方法路径作用POST/api/tasks创建任务GET/api/tasks获取任务列表支持?statusdoing过滤GET/api/tasks/{id}获取单个任务详情PATCH/api/tasks/{id}部分更新可改 title、description、status、due_dateDELETE/api/tasks/{id}删除任务我不追求 RESTful 满分比如没有用 PUT 做整体替换因为 PATCH 更符合“我只想改一个状态”的实际场景。创建任务的 JSON 请求长这样{ title: 给传感器模块写测试用例, description: 重点覆盖断线重连分支, status: doing, due_date: 2024-12-20T18:00:0008:00 }服务端返回的唯一 ID 和创建时间让客户端不用自己生 ID因为并发环境下自己生成 ID 很容易撞车。任务状态的合法流转我也做了约束todo 可以变成 doingdoing 可以变成 donedone 状态的文档我允许直接改回 todo但不允许直接跳回 doing。这个约束的目的不是制造麻烦而是避免“已完成的任务被随手改成进行中”这种语义混乱。实际执行时用一段很小的状态机代码来校验只允许以下转换todo - doing - done todo - done done - todo (重新打开)3.3 错误处理约定统一信封结构我的错误响应没有采用一长串 RFC 标准错误码而是约定一个简单的信封结构{ error: { code: TASK_NOT_FOUND, message: task 42 does not exist } }HTTP 状态码仍然正确使用404 对应不存在400 对应参数错误405 对应方法不支持500 对应服务端异常。但具体原因放在 code 里让客户端可以精确判断message 是给人看的。这样做的理由比复杂错误码体系实在我的调用端只有两类——我自己敲 curl和后续可能写的几十行脚本。简单的结构在脚本里解析最容易。4. 核心实现拆解路由、处理器与SQLite并发这一关4.1 目录结构四个文件支撑全部逻辑整个项目的代码量很小我刻意分成了四个文件而不是一个超大 main.go这样每个文件职责单一读起来不用上下翻nb_tasks/ ├── go.mod # 模块定义只有一个外部依赖 ├── main.go # 启动、路由注册、优雅关停 ├── store.go # SQLite 初始化、建表、数据访问函数 ├── handlers.go # HTTP 处理器函数JSON 编解码与状态机校验 └── task.go # Task 结构体、校验逻辑、状态转换表task.go里定义的是纯数据结构不依赖任何网络与数据库包这样可以单独对状态转换逻辑做单元测试。store.go只负责数据的持久化不关心 HTTP 层。handlers.go负责把 HTTP 请求翻译成 store 调用。这个分层不新鲜但在四文件项目里依然值得坚持因为未来如果我要加认证或接 Webhook只需要在 handler 外围处理数据库层完全不用动。4.2 处理器实现要点从Decode到状态校验的串联逻辑以创建任务为例处理器做了三件事限制 JSON 字段、校验必填项、填充服务端字段。这里有一个细节我推荐每个人都养成习惯Decoder 要调DisallowUnknownFields()。如果客户端传了个我根本不认识的字段比如把due_date拼成了dueDate标准解码器会悄悄忽略它然后你的任务就在没有截止日期的情况下被创建排查起来非常浪费时间。打开这个选项后这类错误会直接变成 400客户端立刻发现自己拼错了字段省去一次无声的数据丢失。状态字段的处理逻辑在处理器里只负责“要不要拒绝”真正的合法性判断落在 task.go 一个独立函数里var allowedTransitions map[string][]string{ todo: {doing, done}, doing: {done}, done: {todo}, } func validateStatusTransition(current, next string) bool { targets, ok : allowedTransitions[current] if !ok { return false } for _, t : range targets { if t next { return true } } return false }这个函数很小但它解决了一个真实问题。没有它客户端可以把一个 done 的任务直接 PATCH 成 doing看起来似乎没毛病但统计“当前实际进行中任务数”的时候就会多出脏数据。我也在回复区见过有人问“为什么要限制这个”我的回答是当你的 API 不限制状态语义时统计报表就永远不可信。4.3 SQLite并发WAL模式与busy_timeout如何配合Go 的 HTTP 服务天然是并发处理请求的每个请求一个 goroutine。SQLite 默认的 rollback journal 模式在并发写入时非常容易撞上database is locked错误。我首次连跑两个并发请求创建任务其中一个直接就失败了——因为两个写事务互斥后一个在锁等待超时后报错。解决方式不是引入连接池而是两手一起抓。第一数据库开启 WALWrite-Ahead Logging模式它让读操作和写操作不再互相阻塞多个读跟一个写可以并发进行第二设置busy_timeout让写事务在遇到锁时等待而不是立即失败。这两行代码非常关键db, err : sql.Open(sqlite, dbPath) db.SetMaxOpenConns(1) // SQLite 文件级锁多连接反而增加锁冲突 _, _ db.Exec(PRAGMA journal_modeWAL;) _, _ db.Exec(PRAGMA busy_timeout5000;)注意上面的SetMaxOpenConns(1)这是我后来加上去的。最初我以为设大连接数能提高并发实测反而增加了锁冲突概率。SQLite 本身是单写者的架构连接池开再大也没用一个连接排队写反而最稳定。这个体会也许反直觉但确实是 SQLite 部署里最实在的一条经验。5. 从构建到验证一条curl命令跑通完整任务流5.1 构建单二进制一台没有Go环境的机器也能跑编译指令简单到不需要解释go build -o nbt . ls -lh nbt我实测的二进制大小在 12MB 到 15MB 左右取决于目标平台和 Go 版本。因为选择了纯 Go 的 SQLite 驱动没有 CGO所以我在 Linux 上交叉编译 Windows 版本时也是直接一条命令完成不需要装任何额外工具链。把二进制拷到一台没有任何 Go 环境的干净虚拟机里./nbt直接就能跑这是因为 Go 静态编译把运行库都打进包里了。这个特性对我这种“可能换电脑、可能跑在不同服务器上”的人来说是最大的安全感来源。5.2 环境变量与启动只留PORT和DB_PATH两个开关配置项我砍到只剩两个环境变量PORT控制监听端口默认 8080DB_PATH控制 SQLite 文件位置默认是当前目录下的tasks.db。没有配置文件解析、没有命令行参数绑定两个环境变量已经覆盖了我百分之百的使用场景。启动日志也很朴素export PORT9090 export DB_PATH/home/me/data/tasks.db ./nbt启动后输出两行监听地址和数据库路径。这就够了。我不需要健康检查端点因为进程活着就说明服务可用也不需要优雅关停的复杂流程但我在项目里还是加了监听退出信号后关库的操作主要是为了确保 WAL 文件能正常合并回主库避免数据文件残留。5.3 curl实测链创造、查询、流转、清理一条龙服务跑起来之后我习惯用一组 curl 模拟真实使用。创建一个任务curl -X POST http://localhost:9090/api/tasks \ -H Content-Type: application/json \ -d {title:给Nano Banana写使用文档,status:todo,due_date:2024-12-25T12:00:0008:00}响应{ id: 1, title: 给Nano Banana写使用文档, description: , status: todo, due_date: 2024-12-25T12:00:0008:00, created_at: 2024-12-18T09:21:0008:00, updated_at: 2024-12-18T09:21:0008:00 }再查列表加状态过滤curl http://localhost:9090/api/tasks?statusdoing把任务标记为进行中curl -X PATCH http://localhost:9090/api/tasks/1 \ -H Content-Type: application/json \ -d {status:doing}做完删除curl -X DELETE http://localhost:9090/api/tasks/1整个链路不到一分钟就验证完。这也是我一直喜欢这个项目的理由它没有让“验证一个想法”这件事变得比“执行这个想法”更费时。很多项目的问题出在 API 写完了想测一下还得先折腾 Swagger 文档和前端口子这边直接 curl 就是最完整的测试工具。6. 实测量出来的三个坑WAL、时间格式和连接泄漏6.1 SQLite锁问题从只读压测到写入并发第一次搭好模型后我只用单连接 curl 测什么问题都没有。直到我写了一个简单的并发脚本同时往 API 扔 20 个 CREATE 请求瞬间就出现了database is locked。当时第一反应是调整SetMaxOpenConns但调大反而更糟。查了一轮资料才明白问题核心SQLite 默认在旧 journal 模式下写事务会锁整个数据库文件读事务也可能被写事务阻塞。开启 WAL 之后读者通过读旧快照继续工作写者之间依然互斥但配合 busy_timeout 的等待20 个并发请求可以排队完成而不是报错。这是我排查时间最长的一个问题最后根因极简单——不是代码写错了是 SQLite 的工作模式没有针对并发配置好。我的经验是SQLite 用于 Web 服务不是不行但必须在初始化阶段就把 WAL 和 busy_timeout 设置好并把连接数限制到一个否则并发一上来就会怀疑人生。如果你嫌这条经验不够具体可以直接照抄上面的四行初始化语句它能在大多数场景下解决问题。6.2 时间字段的JS与Go差异RFC3339 vs 时间戳一个让我哭笑不得的坑来自时间格式。Go 的time.Time默认 JSON 序列化输出是 RFC3339 格式类似2024-12-18T09:21:0008:00。而 JavaScript 的Date构造函数和多数前端库也能解析这种格式所以在 Web 场景下毫无问题。但某次我写脚本偏要用 Python 消费它随手转成 Unix 时间戳时才发现这个 API 的时间字段格式被别人隐式地当成了“人类可读”而不是“机器友好”。我的方案是保持 RFC3339 不动但在接口注释和 README 里醒目地写清楚“时间字段统一为 RFC3339带时区偏移”。为什么不用 Unix 时间戳因为 curl 直接看结果时RFC3339 可读性高太多我不想为了脚本解析方便牺牲调试体验。时间格式这件事没有绝对最优但 API 文档必须写明否则就是给别人埋地雷。6.3 响应体泄漏Go并发下最容易忽略的资源释放最后一个坑是标准得不能再标准的 Go 客户端错误——响应体不关闭。我服务端写得很干净但早期一段用来写测试的 Go 客户端代码里漏了resp.Body.Close()导致每次请求后文件描述符数量稳步爬升。跑了小半天后发现进程占用了几千个 fd才发现这个低级失误。这里要说明的是如果你自己也在写调用 Nano Banana Tasks API 的 Go 客户端请一定记住这个操作要出现在分支判断之后、任何提前 return 之前resp, err : http.Get(http://localhost:9090/api/tasks) if err ! nil { return err } defer resp.Body.Close() // 读取 body...defer放在成功取得响应体之后就注册上而不是等到所有业务处理完再关。这件事很小但它是并发程序里最常见的资源泄漏来源之一排查看不到只会在负载上来之后突然变成“文件打开过多”。7. 挂在TODO里的扩展有些功能我就不急着加7.1 我明确不做的功能清单每过一段时间就有人问我“你什么时候给它加个认证”“要不要加个前端界面”。我的态度是现阶段全都不做。不做用户系统是因为我连“第二个用户”都没有加登录就是在给唯一用户制造麻烦不做标签和项目分组是因为我把任务 title 写清楚一点就能实现同样的检索效果不做容器化是因为单个二进制已经比容器镜像更轻再加 Dockerfile 反而是负担。我之所以敢把这些写出来是因为“个人工具”的边界就是个人使用。等到真的出现第二个用户或者我自己的任务量爆炸式增长那意味着这个工具该重写了而不是在现在这个骨架上不断打补丁。一个只有 500 行的任务 API如果被外围需求撑大到 3000 行它就不再是 Nano Banana 了。7.2 如果未来要加我会优先考虑这两个方向两个功能真的进入过我的考虑列表。第一个是“到期扫描”即启动一个 goroutine 每秒扫一次数据库把 due_date 在今天之前且状态不是 done 的任务找出来通过外部命令或 webhook 通知我。这个功能不破坏现有 API 语义只增加一个后台只读循环很安全。第二个是“快照备份”在每次启动的时候把数据库文件复制到一个带日期的备份路径下相当于给数据加了一层廉价保险。这两个功能都有一个共同特点——不改变 API 的基本形状只是在周边补一层服务。如果哪天我真加了也一定以同样的极简思路去做而不是把它们聊成什么复杂模块。其实写了这么多我最想表达的一点是Nano Banana Tasks API 技术含量不高它的价值全在于克制。做个人工具的时候最稀缺的资源不是时间也不是技术能力而是“知道自己不需要什么”的判断力。从某个项目管理软件迁移到这套 API 之后我的任务管理习惯反而更好了因为我能用任何语言、任何脚本、任何终端去触达它所有操作都是一次 HTTP 调用简单到没有任何借口不去更新任务状态。最后分享一个我常用来检验工具设计是否过度的办法如果这个功能一个月都用不上一次删掉它你的工具不会变差只会变轻。Nano Banana 这个名字里藏着当初的野心——要像香蕉一样随手可得要像纳米一样小到不占地方。它做到了而我希望这篇文章能让你在下一个项目里也拥有同样的修剪勇气。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。