今年上半年我一直在用一个自己写的微型任务 API,名字就叫 Nano Banana Tasks API。名字听起来像个实验品,实际上它就是个实验品——只不过这个实验品我现在天天都在用。它的定位特别简单:一个用 Go 写的、只做任务增删改查和状态流转的轻量接口服务,不需要登录、不需要网页前端、不需要消息队列,编译出来就是一个十来兆的二进制文件,扔到哪都能跑。
如果你也遇到过这种情景——明明只想快速记一个“下午三点前把报销单交了”,结果打开项目管理工具要先加载半分钟、再点三个菜单、还得犹豫该放哪个项目组——那这个项目的思路应该对你有点用。它不是要做下一个全功能任务平台,它要解决的是我自己“任务管理工具过度复杂”这个真实痛点。这篇文章会把 Nano Banana Tasks API 从动机、选型、设计、实现到实测踩坑的完整过程写出来,适合那些想自己动手写轻量工具、或者对 Go 后端 API 开发感兴趣的开发者参考,就算你完全没写过 Go,也能看懂我在每个关键点上做的取舍和理由。
1. 为什么是“Nano Banana”:从“任务管理焦虑”到极简API
1.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八个字段,后来在写处理器的时候突然意识到,优先级这个字段在我的真实用法里从来没被消费过。我从来没按优先级排过序,也没有按优先级过滤的诉求,它纯粹是“表里应该有”的惯性思维产物。砍掉之后表变得清爽很多。最终字段如下:
| 字段 | 类型 | 约束与说明 |
|---|---|---|
| id | INTEGER | 自增主键 |
| title | TEXT | 任务标题,必填,长度 1 到 200 |
| description | TEXT | 补充描述,可空 |
| status | TEXT | 枚举值 todo / doing / done,默认 todo |
| due_date | TEXT | RFC3339 格式截止时间,可空 |
| created_at | TEXT | RFC3339 创建时间,由服务端填充 |
| updated_at | TEXT | RFC3339 更新时间,每次修改自动刷新 |
status 字段没有用 INTEGER 映射,而是直接存人类可读的字符串。原因很简单:这个系统没有任何性能压力,存字符串让每次 curl 查询结果都能直接看懂,我不需要再写一份“状态码 1 代表什么”的文档。对个人工具而言,可读性优先于理论上的空间节省。
3.2 路由与JSON契约:够用的 REST,不求满分
API 路由我遵循的是一眼就能猜到行为的风格。四个动词,配上一个集合资源和一个单资源:
| 方法 | 路径 | 作用 |
|---|---|---|
| POST | /api/tasks | 创建任务 |
| GET | /api/tasks | 获取任务列表,支持?status=doing过滤 |
| GET | /api/tasks/{id} | 获取单个任务详情 |
| PATCH | /api/tasks/{id} | 部分更新,可改 title、description、status、due_date |
| DELETE | /api/tasks/{id} | 删除任务 |
我不追求 RESTful 满分,比如没有用 PUT 做整体替换,因为 PATCH 更符合“我只想改一个状态”的实际场景。创建任务的 JSON 请求长这样:
{ "title": "给传感器模块写测试用例", "description": "重点覆盖断线重连分支", "status": "doing", "due_date": "2024-12-20T18:00:00+08:00" }服务端返回的唯一 ID 和创建时间,让客户端不用自己生 ID,因为并发环境下自己生成 ID 很容易撞车。任务状态的合法流转我也做了约束:todo 可以变成 doing,doing 可以变成 done,done 状态的文档我允许直接改回 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错误。我首次连跑两个并发请求创建任务,其中一个直接就失败了——因为两个写事务互斥,后一个在锁等待超时后报错。
解决方式不是引入连接池,而是两手一起抓。第一,数据库开启 WAL(Write-Ahead Logging)模式,它让读操作和写操作不再互相阻塞,多个读跟一个写可以并发进行;第二,设置busy_timeout,让写事务在遇到锁时等待而不是立即失败。这两行代码非常关键:
db, err := sql.Open("sqlite", dbPath) db.SetMaxOpenConns(1) // SQLite 文件级锁,多连接反而增加锁冲突 _, _ = db.Exec("PRAGMA journal_mode=WAL;") _, _ = db.Exec("PRAGMA busy_timeout=5000;")注意上面的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控制监听端口,默认 8080;DB_PATH控制 SQLite 文件位置,默认是当前目录下的tasks.db。没有配置文件解析、没有命令行参数绑定,两个环境变量已经覆盖了我百分之百的使用场景。启动日志也很朴素:
export PORT=9090 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:00+08:00"}'响应:
{ "id": 1, "title": "给Nano Banana写使用文档", "description": "", "status": "todo", "due_date": "2024-12-25T12:00:00+08:00", "created_at": "2024-12-18T09:21:00+08:00", "updated_at": "2024-12-18T09:21:00+08:00" }再查列表,加状态过滤:
curl "http://localhost:9090/api/tasks?status=doing"把任务标记为进行中:
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:00+08: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 这个名字里藏着当初的野心——要像香蕉一样随手可得,要像纳米一样小到不占地方。它做到了,而我希望这篇文章能让你在下一个项目里,也拥有同样的修剪勇气。