
1. 为什么我劝你用Go写一个属于自己的收支记账工具1.1 从一张Excel表说起记账这件事到底难在哪我身边有不少朋友记账这件事前前后后启动过不下五次。第一次是下载某个记账App记了三天嫌广告多、启动慢卸载了。第二次是拉了一张Excel表字段设计得特别复杂收入、支出、分类、备注、账户、币种全都有结果每次记账要填七八个格子坚持了一周就放弃了。第三次是想着“我就记大额支出”结果小额支出累积起来才是真正吃钱的地方记了个寂寞。这里面其实藏着一个很朴素的需求记账工具必须足够轻、足够快、数据必须在自己手里。市面上的记账App功能确实全但问题也很明显——数据存在别人的服务器上导出要会员分类逻辑是别人定的想加一个“给猫买罐头”这种自定义标签还得看产品经理心情。而Excel的问题在于它太自由了自由到你需要自己设计一切反而增加了记账的心理负担。用Go写一个命令行或者本地Web的记账工具恰好能解决这个矛盾。Go编译出来就是一个单文件二进制双击就能跑不需要装运行时不需要联网数据存本地SQLite或者JSON文件想怎么改就怎么改。你不需要成为一个专业后端工程师只要会写基本的增删改查就能拥有一个完全属于自己的记账系统。这篇文章就是把我自己从零写一个Go记账工具的全过程拆开来讲包括数据结构怎么设计、命令行交互怎么做、统计报表怎么算、以及我踩过的那些坑。1.2 这个工具适合谁能解决什么问题先说清楚定位。这个工具不是要替代专业的财务软件它解决的是个人日常流水记录和月度复盘这个场景。适合的人群大概有三类第一类是有一定编程基础、想拿一个小项目练手Go语言的开发者记账工具的逻辑足够简单但涉及文件IO、时间处理、命令行解析、数据聚合麻雀虽小五脏俱全第二类是对数据隐私比较在意、不想把消费记录上传到任何云端的用户第三类是喜欢折腾、想把记账和自己的其他脚本打通的人比如每个月自动生成报表发到自己的邮箱或者和日历数据做关联分析。它能做的事情包括快速记一笔收入或支出按分类和月份查询流水生成月度收支汇总导出CSV做进一步分析。它不需要做的事情包括多用户协作、复杂的复式记账、银行流水自动导入。把边界划清楚代码量能控制在一千行以内一个周末就能写完。提示不要一上来就想着做全功能。我见过太多人写记账工具第一版就设计了几十张表结果连“记一笔”这个核心流程都没跑通就烂尾了。先把最小闭环跑通再迭代。2. 整体设计与技术选型为什么是Go为什么是SQLite2.1 语言选型的真实考量选Go来写这个工具不是因为Go有多“高级”而是因为它在几个具体维度上刚好合适。第一是部署简单go build出来一个二进制文件扔到任何一台机器上都能跑不需要用户装Python环境或者Node运行时。第二是标准库够用flag包做命令行参数解析encoding/json做序列化time包做时间处理database/sql配合SQLite驱动做持久化几乎不需要引入第三方依赖。第三是编译速度快改一行代码重新编译也就一两秒写小工具的体验很流畅。对比一下其他方案用Python写部署的时候要处理虚拟环境和依赖版本换台机器就可能跑不起来用Node写node_modules的体积和依赖安全问题让人头疼用Rust写编译速度和学习曲线对于一个小工具来说有点重。Go在这个场景下是一个很务实的中间选择。2.2 存储方案JSON文件还是SQLite这是我在动手前纠结最久的一个点。JSON文件的优势是零依赖、可读性强、直接用文本编辑器就能改劣势是数据量大了之后查询效率低而且并发写入需要自己做锁。SQLite的优势是支持SQL查询、有事务保证、单文件存储、Go的驱动很成熟劣势是需要引入一个第三方驱动虽然也是纯Go的编译后无额外依赖。我最后的决定是用SQLite。原因很实际记账数据虽然单条很小但一年下来也有几千条按月查询、按分类聚合这些操作用SQL一句就能搞定用JSON就得自己写循环过滤。而且SQLite的database/sql接口是标准库的一部分驱动用纯Go实现的那个编译出来依然是单文件。下面是我用的建表语句字段设计上刻意保持了精简CREATE TABLE IF NOT EXISTS records ( id INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL CHECK(type IN (income, expense)), amount REAL NOT NULL CHECK(amount 0), category TEXT NOT NULL, note TEXT DEFAULT , record_date TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime(now, localtime)) ); CREATE INDEX IF NOT EXISTS idx_records_date ON records(record_date); CREATE INDEX IF NOT EXISTS idx_records_category ON records(category);这里有几个设计决策值得展开说。type字段用CHECK约束限定只能是income或expense避免写入脏数据。amount用REAL而不是整数分是因为个人记账对精度要求没那么高用浮点数写起来更直观但如果你要严谨可以改成整数存“分”。record_date和created_at分开是因为记账日期和实际录入日期可能不是同一天比如你补记昨天的消费。索引加在record_date和category上因为这两个字段是查询和聚合时最常用的。2.3 交互方式命令行还是本地Web命令行和本地Web各有优劣。命令行的优势是启动快、脚本化方便、不需要开浏览器劣势是输入长备注的时候不太方便查看报表也不够直观。本地Web的优势是界面友好、可以用图表展示劣势是要写前端代码项目复杂度直接翻倍。我的选择是命令行优先预留Web扩展。核心操作全部通过子命令完成比如ledger add、ledger list、ledger report。这样既保证了轻量又能在后续想加Web界面的时候把业务逻辑层直接复用。命令行的参数解析用标准库的flag包每个子命令有自己的FlagSet互不干扰。3. 核心模块拆解从记一笔到出报表3.1 数据模型与业务逻辑分层代码结构上我分了三层model层定义数据结构store层负责数据库读写cmd层处理命令行交互。这样分层的好处是如果以后要加Web接口只需要在cmd层旁边加一个http层复用store层的逻辑就行。model层的核心结构体很简单type Record struct { ID int64 Type string Amount float64 Category string Note string RecordDate string CreatedAt string } type Summary struct { TotalIncome float64 TotalExpense float64 Balance float64 ByCategory map[string]float64 }store层封装了AddRecord、ListRecords、DeleteRecord、GetSummary这几个方法。这里有个细节ListRecords支持按月份和分类过滤我用的是动态拼接SQL的方式但参数全部走占位符避免SQL注入。虽然这是个本地工具但养成好习惯没坏处。3.2 命令行子命令的设计与实现命令行工具的用户体验很大程度上取决于子命令的设计是否直观。我参考了git和docker的风格主命令是ledger子命令包括ledger add添加一笔记录ledger list列出记录支持--month和--category过滤ledger report生成月度报表ledger delete删除指定ID的记录ledger export导出CSV每个子命令用独立的flag.FlagSet这样ledger add --help和ledger list --help显示的内容是分开的。add子命令的参数设计上我做了简化--type默认是expense因为日常记账中支出占大多数--date默认是今天格式是2006-01-02--category是必填的因为分类是后续统计的基础。func runAdd(args []string) { fs : flag.NewFlagSet(add, flag.ExitOnError) recordType : fs.String(type, expense, record type: income or expense) amount : fs.Float64(amount, 0, amount, must be positive) category : fs.String(category, , category name) note : fs.String(note, , optional note) date : fs.String(date, time.Now().Format(2006-01-02), date in YYYY-MM-DD) fs.Parse(args) if *amount 0 { fmt.Println(amount must be positive) return } if *category { fmt.Println(category is required) return } // ... 调用 store.AddRecord }这里有个小技巧flag包默认的-h行为是打印用法并退出但退出码是2如果你在脚本里调用需要处理这个退出码。另外flag包不支持--long-optionvalue和--long-option value两种写法混用它只支持前者这点和很多现代CLI工具不一样用的时候要注意。3.3 月度报表的聚合逻辑报表是这个工具的价值所在。我实现的report子命令输入一个月份比如2024-06输出这个月的总收入、总支出、结余以及按分类的支出明细。聚合逻辑用SQL的GROUP BY实现SELECT category, SUM(amount) as total FROM records WHERE type expense AND record_date LIKE ? GROUP BY category ORDER BY total DESC这里用LIKE 2024-06%来匹配月份虽然不够优雅但在数据量不大的情况下完全够用。如果你追求性能可以把record_date存成Unix时间戳用范围查询。报表输出的时候我加了一个简单的百分比计算让每个分类的占比一目了然。比如“餐饮 1200元 占比35%”这样一眼就能看出钱花在哪了。注意浮点数累加会有精度问题。如果你记了很多笔0.1元、0.2元的账累加结果可能是0.30000000000000004。解决办法是在展示的时候用fmt.Sprintf(%.2f, total)格式化或者干脆用整数分存储。我个人选择前者因为记账场景对精度的容忍度比较高。4. 实操全流程从零到跑通第一笔账4.1 环境准备与项目初始化先确认你本地装了Go版本建议1.21以上因为用到了min和max内置函数虽然这个项目里没用到但保持版本新一点没坏处。然后创建项目目录初始化模块mkdir ledger cd ledger go mod init github.com/yourname/ledger接着引入SQLite驱动。这里我用的是纯Go实现的modernc.org/sqlite不需要CGO编译出来的二进制可以直接跨平台复制。如果你用mattn/go-sqlite3需要CGO环境交叉编译会麻烦一些。go get modernc.org/sqlite项目文件结构如下ledger/ ├── main.go ├── go.mod ├── go.sum ├── model/ │ └── record.go ├── store/ │ └── sqlite.go └── cmd/ ├── add.go ├── list.go ├── report.go ├── delete.go └── export.go4.2 数据库初始化与连接管理store层的初始化逻辑我放在一个InitDB函数里程序启动时调用一次。数据库文件默认放在用户主目录下的.ledger/ledger.db这样不管你在哪个目录下执行命令数据都是同一份。func InitDB() (*sql.DB, error) { home, err : os.UserHomeDir() if err ! nil { return nil, err } dir : filepath.Join(home, .ledger) if err : os.MkdirAll(dir, 0755); err ! nil { return nil, err } dbPath : filepath.Join(dir, ledger.db) db, err : sql.Open(sqlite, dbPath) if err ! nil { return nil, err } if err : createTables(db); err ! nil { return nil, err } return db, nil }这里有个坑sql.Open并不会真正建立连接它只是验证参数。真正的连接是在第一次执行查询时建立的。所以如果你把建表语句放在sql.Open之后立即执行是没问题的但如果你把db.Ping()漏掉了某些错误可能要等到第一次查询才暴露。我的做法是在InitDB里显式调用一次db.Ping()确保数据库文件可写。4.3 添加记录的完整流程与参数校验add子命令的完整流程是解析参数、校验参数、构造Record结构体、调用store.AddRecord、打印成功信息。参数校验这块我做了几层金额必须大于0类型必须是income或expense日期格式必须符合2006-01-02分类不能为空。日期校验用time.Parse如果解析失败就报错。if _, err : time.Parse(2006-01-02, *date); err ! nil { fmt.Printf(invalid date format: %s, expected YYYY-MM-DD\n, *date) return }实测下来这个校验能拦住大部分手误。比如你把日期写成2024/06/01它会直接报错而不是默默存进去导致后续查询出问题。分类这块我没有做枚举限制因为每个人的分类体系不一样有人用“餐饮”有人用“吃饭”强制枚举反而不好用。但我在report里做了一个分类合并的提示如果检测到相似分类比如“餐饮”和“餐费”会提醒你是否要统一。4.4 查询与过滤的实际使用场景list子命令支持按月份和分类过滤实际使用中最高频的场景是“看看这个月花了多少”。命令是ledger list --month 2024-06输出格式我设计成表格样式用text/tabwriter包对齐ID DATE TYPE AMOUNT CATEGORY NOTE 1 2024-06-01 expense 35.50 餐饮 午饭 2 2024-06-01 expense 12.00 交通 地铁 3 2024-06-02 income 8000.00 工资 六月工资tabwriter的用法很简单创建一个tabwriter.NewWriter(os.Stdout, 0, 0, 2, , 0)然后用\t分隔字段最后Flush。注意tabwriter的对齐是基于制表符的如果你的字段里有中文对齐可能会乱因为中文字符的宽度和英文字符不一样。解决办法是手动计算显示宽度或者干脆接受轻微的不对齐。我个人选择后者因为记账工具不需要像素级完美。4.5 导出CSV与数据备份export子命令把全部记录导出成CSV方便你在Excel里做进一步分析。CSV的编码我用的是UTF-8带BOM因为Excel在Windows上打开不带BOM的UTF-8 CSV会乱码。这个细节很多人会忽略导致导出的中文分类名变成乱码。func runExport(args []string) { // ... 查询全部记录 w : csv.NewWriter(os.Stdout) w.Write([]string{ID, Date, Type, Amount, Category, Note}) for _, r : range records { w.Write([]string{ strconv.FormatInt(r.ID, 10), r.RecordDate, r.Type, strconv.FormatFloat(r.Amount, f, 2, 64), r.Category, r.Note, }) } w.Flush() }如果你想把导出结果重定向到文件直接ledger export backup.csv就行。我建议每个月导出一次做备份虽然SQLite文件本身也可以直接复制但CSV更通用万一哪天你想换工具数据迁移也方便。5. 踩坑记录与常见问题排查5.1 时间处理时区和格式的那些坑Go的time包设计得很严谨但严谨的另一面是啰嗦。time.Now()返回的是本地时间但如果你用time.Now().Format(2006-01-02)得到的是本地日期这通常没问题。但如果你把时间存成UTC展示的时候又忘了转回本地就会出现“明明今天记的账显示成昨天”的情况。我的做法是全程用本地时间存储和展示都不做时区转换因为个人记账不涉及跨时区协作。另一个坑是time.Parse的布局字符串。Go用的是2006-01-02 15:04:05这个参考时间而不是YYYY-MM-DD。我第一次写的时候用了YYYY-MM-DD结果解析一直失败查了文档才发现这个设计。记住这个参考时间2006是年01是月02是日15是小时24小时制04是分钟05是秒。5.2 SQLite并发写入的锁问题SQLite默认的日志模式是DELETE在并发写入时会锁整个数据库文件。对于个人记账工具并发场景很少但如果你同时开了两个终端执行add可能会遇到database is locked错误。解决办法是开启WAL模式PRAGMA journal_modeWAL;WAL模式下读和写可以并发只有写和写之间会互斥。对于单用户工具这个设置能显著减少锁冲突。另外database/sql的连接池默认是无限大的对于SQLite来说连接数太多反而容易锁我建议设置db.SetMaxOpenConns(1)强制串行写入简单可靠。5.3 浮点数精度问题的实际影响前面提过浮点数累加的精度问题这里展开说一下实际影响。假设你记了三笔支出0.1元、0.2元、0.3元累加结果是0.6000000000000001。在报表里显示的时候如果不格式化就会看到这个奇怪的小数。解决办法有两个一是展示时用%.2f格式化二是存储时用整数分。我选的是前者因为记账场景下分以下的精度没有意义格式化后用户看到的就是0.60完全可接受。但如果你要做严格的财务计算比如算利息、算分摊那就必须用整数分或者decimal库。个人记账工具没必要上decimal但你要知道这个边界在哪。5.4 常见问题速查表问题现象可能原因解决办法执行命令报database is locked并发写入冲突开启WAL模式设置SetMaxOpenConns(1)日期解析失败布局字符串写错用2006-01-02而不是YYYY-MM-DD导出的CSV中文乱码缺少UTF-8 BOM在CSV开头写入\xEF\xBB\xBF报表金额显示一长串小数浮点数精度问题展示时用%.2f格式化命令找不到二进制没加入PATH把编译出的文件复制到/usr/local/bin或加入PATH分类统计不准确分类名不统一定期用list检查分类手动统一命名提示如果你在Windows上编译记得用GOOSlinux GOARCHamd64 go build交叉编译出Linux版本方便扔到服务器上跑。纯Go的SQLite驱动让交叉编译变得很简单不需要CGO环境。6. 后续扩展方向与个人经验这个工具写完第一版之后我又陆续加了一些小功能。一个是ledger budget子命令可以设置每个月的分类预算超支的时候在报表里标红。实现方式是在数据库里加一张budgets表记录分类和月度限额报表生成时对比实际支出和预算。另一个是ledger recurring用来处理固定支出比如房租、订阅费每个月自动生成记录。这个功能需要配合定时任务我用的是系统的cron每天跑一次ledger recurring --apply检查有没有到期的固定支出。还有一个我觉得很实用的扩展是和日历打通。我把每天的记账记录导出成ics格式导入到日历应用里这样在日历视图上就能看到每天的消费情况直观程度比表格高很多。实现上就是遍历记录生成VEVENT每个事件的时间设为当天中午描述里放金额和分类。我个人在实际操作中的体会是记账工具的核心价值不在于功能多全而在于记录的成本足够低。如果记一笔账需要超过十秒你大概率坚持不下来。所以我在add命令上做了很多优化默认类型是支出默认日期是今天分类支持简写比如-c 餐自动匹配到“餐饮”金额支持不带小数点的整数输入。这些细节看起来不起眼但累积起来能显著降低记账的心理门槛。最后分享一个小技巧如果你用zsh或bash可以给常用命令设别名。比如alias laledger add、alias llledger list --month $(date %Y-%m)这样记一笔账只需要敲三个字符。工具是死的人是活的怎么顺手怎么来。