资讯动态

用Go打造本地个人记账工具:数据自主与高效实践

发布时间:2026/10/14 11:11:59 来源:尧图企业网站定制
1. 为什么我劝你用 Go 写一个属于自己的收支记账工具市面上的记账 App 我前前后后用过七八款从功能堆到天花板的专业财务软件到极简到只有一个输入框的小工具最后都逃不过同一个结局要么被广告和会员弹窗烦到卸载要么数据存在别人的服务器上哪天服务停了、账号丢了几年的账目说没就没。更别提那些需要你把每一笔消费手动分类、还要忍受同步延迟的体验记着记着就放弃了。后来我干脆自己动手用 Go 写了一个跑在本地、数据完全归自己的个人收支记账工具。核心诉求就三条数据必须落在我自己的硬盘上、记账动作要快到三秒内完成、月底能一键出报表看清楚钱花哪了。这个项目不追求做成一个产品它更像是一把趁手的私人工具代码量不大但每一行都服务于我自己的使用习惯。这篇文章我会把这个工具从设计思路到落地实现完整拆一遍。适合两类人看一类是有点 Go 基础、想找个真实小项目练手的开发者另一类是受够了商业记账软件、想拿回数据控制权的普通用户。哪怕你 Go 只学到能看懂变量和函数跟着走也能跑起来。我会把每个技术选型背后的“为什么”讲清楚也会把我在实际使用中踩过的坑、调过的参数一并交底让你少走弯路。2. 整体设计与技术选型拆解2.1 为什么是命令行加本地文件而不是做个网页或 App一开始我也想过做个带界面的东西毕竟图形界面看着舒服。但冷静下来分析自己的真实使用场景记账这个动作90% 发生在手机上或者电脑前我需要的是极低的启动成本。打开一个网页要等加载打开一个 App 要看过场动画而命令行工具敲一行命令就完事甚至可以用别名简化到两三个字母。所以最终架构定成了CLI命令行界面 本地文件存储。CLI 的好处是开发快、依赖少、跨平台Go 编译出来就是一个静态二进制文件扔到任何一台机器上都能跑不需要装运行时。本地文件存储我选了JSON而不是 SQLite 或者 CSV。这里有个取舍SQLite 查询能力强但对一个个人记账工具来说数据量撑死几千条用不上复杂查询CSV 虽然通用但嵌套结构比如一笔账带多个标签表达起来别扭。JSON 胜在结构灵活、人类可读、Go 标准库原生支持我甚至可以直接用文本编辑器打开账本文件手动改一笔错账这对个人工具来说太重要了。提示如果你的账目预期会超过几万条或者需要复杂的多维度统计那 SQLite 会更合适。但对绝大多数个人用户JSON 完全够用别过度设计。2.2 数据模型怎么设计才既简单又够用记账工具的核心就是一条账目记录。我最初的设计很朴素就四个字段金额、类型收入/支出、分类、备注。用了一周后发现不够因为我想知道“这笔钱是微信付的还是现金付的”也想按时间筛选。于是最终的数据结构定型为下面这样type Transaction struct { ID int json:id Amount float64 json:amount // 金额正数表示收入负数表示支出 Category string json:category // 分类如餐饮交通工资 Note string json:note // 备注 Date time.Time json:date // 发生时间 Method string json:method // 支付方式现金/微信/银行卡 }这里有个关键决策金额用正负号区分收支而不是单独搞一个 type 字段。为什么因为统计的时候你只需要把所有金额加起来就是净收支不用写 if-else 判断类型再决定加减。少一个字段少一层逻辑出错概率就低一分。分类字段我用的是字符串而不是枚举图的是灵活——今天想加个“宠物”分类直接写就行不用改代码。2.3 目录结构小项目也要有章法虽然是个小工具但我还是按职责把代码拆成了几个包避免所有逻辑堆在一个 main.go 里。结构大概是这样bookkeeping/ ├── main.go // 入口解析命令行参数 ├── cmd/ // 各个子命令的实现 │ ├── add.go │ ├── list.go │ └── report.go ├── store/ // 数据读写层 │ └── store.go └── model/ // 数据结构定义 └── transaction.go这么拆的好处是数据存储和业务逻辑解耦。哪天我想把 JSON 换成 SQLite只需要改 store 包cmd 里的命令逻辑一行都不用动。这是我在实际维护中体会最深的一点小项目也值得为“未来可能的改动”留一道口子成本很低收益很高。3. 核心功能实现与关键细节3.1 数据持久化读写 JSON 文件的正确姿势数据层是整个工具的地基我把它单独放在 store 包里。核心就两个操作读全部账目、写全部账目。看起来简单但有几个细节不注意就会出问题。package store import ( encoding/json os sync ) type Store struct { filePath string mu sync.Mutex } func NewStore(path string) *Store { return Store{filePath: path} } func (s *Store) Load() ([]model.Transaction, error) { s.mu.Lock() defer s.mu.Unlock() data, err : os.ReadFile(s.filePath) if os.IsNotExist(err) { return []model.Transaction{}, nil // 文件不存在返回空列表不算错误 } if err ! nil { return nil, err } var txs []model.Transaction if err : json.Unmarshal(data, txs); err ! nil { return nil, err } return txs, nil }第一个坑是文件不存在的情况。第一次运行时账本文件还没创建如果直接报错退出用户体验很差。我的处理是返回一个空列表让上层逻辑正常走下去。第二个坑是并发写。虽然个人工具很少并发但万一你开了两个终端同时记账不加锁就可能出现后写的覆盖先写的丢数据。所以我加了个sync.Mutex写操作串行化。写文件的时候我强烈建议用先写临时文件再重命名的方式而不是直接覆盖原文件func (s *Store) Save(txs []model.Transaction) error { s.mu.Lock() defer s.mu.Unlock() data, err : json.MarshalIndent(txs, , ) if err ! nil { return err } tmp : s.filePath .tmp if err : os.WriteFile(tmp, data, 0644); err ! nil { return err } return os.Rename(tmp, s.filePath) // 原子操作避免写一半崩溃导致文件损坏 }os.Rename在大多数文件系统上是原子操作意味着要么旧文件完整要么新文件完整绝不会出现写了一半的残缺文件。这个技巧在处理任何重要数据落盘时都值得用上我吃过直接覆盖写到一半断电、整个账本报废的亏。3.2 记账命令如何做到三秒内完成一笔记录记账的体验直接决定你会不会坚持用下去。我的目标是打开终端敲bk a 35 餐饮 午饭回车完事。这里bk是我给二进制文件起的别名a是 add 的缩写。命令解析我用的是 Go 标准库的flag包没引入第三方 CLI 框架。原因很简单标准库够用少一个依赖少一份维护负担。add 命令接收位置参数第一个是金额第二个是分类后面的是备注。func Add(args []string) error { if len(args) 2 { return errors.New(用法: bk add 金额 分类 [备注]) } amount, err : strconv.ParseFloat(args[0], 64) if err ! nil { return fmt.Errorf(金额格式不对: %s, args[0]) } category : args[1] note : if len(args) 2 { note strings.Join(args[2:], ) } // 支出默认记负数收入用正数 if amount 0 { amount -amount // 这里假设默认记支出收入需要额外标记 } // ... 构造 Transaction 并保存 }这里有个设计上的纠结怎么区分收入和支出。我试过两种方案。方案一是加个-i标志表示收入方案二是用正负号。最后我选了正负号但做了个约定默认输入正数视为支出自动转成负数要记收入就在金额前加个号。这样日常记支出最省事偶尔记工资收入时多敲一个加号也不麻烦。实测下来这个约定最符合直觉。注意金额用float64存储会有精度问题比如 0.1 0.2 不等于 0.3。个人记账场景下误差极小可以接受但如果你对精度要求高应该用整数存“分”显示时再除以 100。这是个经典的取舍我选简单你按需选严谨。3.3 列表与筛选让查账变得顺手记完账总得能看。list 命令支持按时间范围、分类筛选输出成对齐的表格。Go 标准库的text/tabwriter就是干这个的不用自己算空格。w : tabwriter.NewWriter(os.Stdout, 0, 0, 2, , 0) fmt.Fprintln(w, 日期\t金额\t分类\t备注) for _, tx : range txs { fmt.Fprintf(w, %s\t%.2f\t%s\t%s\n, tx.Date.Format(2006-01-02), tx.Amount, tx.Category, tx.Note) } w.Flush()tabwriter会自动根据内容宽度对齐各列输出效果很清爽。筛选逻辑我放在内存里做因为数据量小加载全部再过滤比写查询引擎简单得多。比如按分类筛选func filterByCategory(txs []model.Transaction, cat string) []model.Transaction { var result []model.Transaction for _, tx : range txs { if tx.Category cat { result append(result, tx) } } return result }时间筛选稍微麻烦点需要解析用户输入的日期。我支持的格式是2024-01某月和2024-01-15某天用time.Parse配合不同的 layout 尝试解析。这里有个小技巧先尝试精确到天的格式失败了再尝试精确到月的格式因为天的格式更具体优先匹配能避免歧义。3.4 月度报表把数字变成能看懂的结论报表是这个工具最有价值的部分。我实现了一个 report 命令按月汇总输出总收入、总支出、净结余以及各分类的占比。核心就是遍历加累加type MonthlyReport struct { Income float64 Expense float64 ByCategory map[string]float64 } func BuildReport(txs []model.Transaction, year int, month time.Month) MonthlyReport { r : MonthlyReport{ByCategory: make(map[string]float64)} for _, tx : range txs { if tx.Date.Year() ! year || tx.Date.Month() ! month { continue } if tx.Amount 0 { r.Income tx.Amount } else { r.Expense tx.Amount } r.ByCategory[tx.Category] tx.Amount } return r }为了让报表更直观我加了一个文本条形图用方块字符的长度表示各分类的支出占比。这样一眼就能看出这个月钱主要花在哪了比看一堆数字快得多。条形图的实现就是按比例算出每个分类该画多少个字符func bar(value, max float64, width int) string { n : int(value / max * float64(width)) if n 0 { n -n } return strings.Repeat(█, n) }这个小小的可视化让报表从“能看”变成了“想看”。我后来发现自己每个月月底会主动跑一次报表就为了看看那个条形图这种正向反馈对坚持记账特别重要。4. 完整实操流程与现场记录4.1 从零搭建环境准备与项目初始化先把环境搞定。你需要装 Go版本建议 1.20 以上因为用到了os.ReadFile这类较新的 API。装完之后验证一下go version # 输出类似 go version go1.21.0 darwin/arm64 就对了然后初始化项目。我习惯用模块名带上自己的命名空间方便以后复用mkdir bookkeeping cd bookkeeping go mod init example.com/bookkeeping接着按前面说的目录结构建好文件夹。这里有个实操细节Go 的包名和目录名最好一致比如 store 目录下的包就叫package store这样导入和使用时不会混淆。我见过有人目录叫 utils 包名却叫 helper维护起来很别扭。4.2 编译与别名配置让命令短到极致代码写完后编译go build -o bk .生成的bk就是可执行文件。为了在任何目录都能用把它放到 PATH 里比如/usr/local/bin。然后配置别名我习惯在 shell 配置里加一行alias bk~/go/bin/bk alias bkabk add alias bklbk list alias bkrbk report这样记一笔账就是bka 35 餐饮 午饭查账就是bkl报表就是bkr。把高频操作压缩到三四个字符是坚持使用命令行工具的关键。我试过不设别名每次敲完整命令坚持了三天就嫌烦了。4.3 一次真实的记账与报表全流程假设现在是月底我来完整走一遍。先记几笔账bka 35 餐饮 公司楼下快餐 bka 12 交通 地铁 bka 200 购物 买书 bka 8000 工资 本月工资然后查一下这个月的账bkl --month 2024-06输出大概是这样日期 金额 分类 备注 2024-06-03 -35.00 餐饮 公司楼下快餐 2024-06-03 -12.00 交通 地铁 2024-06-05 -200.00 购物 买书 2024-06-10 8000.00 工资 本月工资最后跑报表bkr --month 2024-06输出2024年6月收支报表 收入: 8000.00 支出: -247.00 结余: 7753.00 分类支出占比: 购物 ████████████████████ 200.00 餐饮 ███ 35.00 交通 █ 12.00整个过程从记账到看报表不超过一分钟。这种即时反馈是商业软件给不了的因为数据就在本地没有任何网络等待。4.4 数据备份别等丢了才后悔本地存储最大的风险就是硬盘坏了或者误删。我的做法是账本文件放在一个固定的、会被自动同步的目录里比如云盘的同步文件夹。这样每次保存后云盘客户端会自动把最新版本传上去相当于有了异地备份。另外我写了个简单的导出命令把 JSON 转成 CSV方便用表格软件打开做更复杂的分析func ExportCSV(txs []model.Transaction, w io.Writer) error { cw : csv.NewWriter(w) cw.Write([]string{日期, 金额, 分类, 备注, 支付方式}) for _, tx : range txs { cw.Write([]string{ tx.Date.Format(2006-01-02), fmt.Sprintf(%.2f, tx.Amount), tx.Category, tx.Note, tx.Method, }) } cw.Flush() return cw.Error() }提示备份策略上我建议至少保留两份一份本地一份异地。JSON 文件很小几千条记录也就几百 KB备份成本几乎为零但数据无价。5. 常见问题与排查技巧实录5.1 记账工具高频问题速查表实际用下来遇到的问题其实就那么几类。我整理成一张表方便你对照排查问题现象可能原因解决办法运行报错找不到文件账本文件路径不对或首次运行检查路径配置首次运行会自动创建空账本金额显示成科学计数法float64 精度或格式化问题用%.2f格式化输出避免直接打印中文分类显示乱码终端编码不是 UTF-8切换终端编码或检查文件保存编码并发记账丢数据多进程同时写文件已加文件锁避免同时开多个终端记账报表数字对不上时间筛选边界问题确认月份起止时间注意时区编译报错找不到包模块路径或导入路径写错检查 go.mod 和 import 路径是否一致5.2 我踩过的三个真实坑第一个坑是时间时区。我一开始用time.Now()存时间结果发现跨时区或者系统时区设置不对时同一天的账可能被分到不同月份。后来我统一用本地时区并且在解析用户输入的日期时明确指定时区问题才解决。个人工具虽然不涉及多时区但系统时区设置错误的情况并不少见。第二个坑是浮点数累加误差。有次报表算出来总收入是 7999.999999看着特别别扭。原因是多笔金额累加产生了浮点误差。解决办法是在最终输出时统一四舍五入到两位小数而不是在中间过程反复处理。这个坑让我明白浮点数只在展示层处理精度计算层保持原样。第三个坑是命令参数解析的歧义。我最初设计bk add 35 餐饮时如果备注里也有数字比如bk add 35 餐饮 买了2个包子解析逻辑会把“2”也当成参数。后来我改成只取前两个位置参数剩下的全部拼成备注用strings.Join处理才彻底解决。这个经验告诉我命令行参数设计要尽量让位置参数的含义固定避免歧义。5.3 几个提升体验的独家技巧第一个技巧是给分类做自动补全。我在 shell 里配置了分类的补全列表敲bka 35 餐按 Tab 就能补全成“餐饮”。这需要在 shell 配置里写一小段补全脚本成本不高但体验提升明显。第二个技巧是定期归档。账本文件按年拆分比如2024.json、2025.json避免单个文件越来越大。查询时根据年份加载对应文件既快又清晰。我在 store 层加了个按年路由的逻辑几行代码就搞定了。第三个技巧是用 git 管理账本。把账本文件纳入 git 仓库每次记账后自动 commit这样不仅有完整的历史记录还能随时回滚到任意一天的状态。git 的 diff 功能还能让你清楚看到每天改了哪些账比任何审计功能都强。这个用法我是从一个做财务的朋友那学来的实测非常香。6. 后续可以怎么扩展这个工具这个工具目前满足了我 90% 的需求但还有几个方向值得折腾。一是预算功能给每个分类设月度上限超支时报表里标红提醒。实现上就是在报表逻辑里加一层阈值判断不难。二是定期账单自动记录比如房租、订阅费这种每月固定的支出可以配置成自动生成省得每月手动记。三是多币种支持如果你有外币消费可以给 Transaction 加个币种字段报表时按汇率折算。不过我得提醒一句别为了扩展而扩展。我见过太多人一开始兴致勃勃要做一个全能财务系统结果功能越加越多最后自己都懒得用。个人工具的核心竞争力就是简单、快、贴合自己的习惯。先把最核心的记账和报表打磨到极致其他的等真正需要了再加这才是可持续的做法。我自己就是用了三个月之后才根据实际痛点陆续加了筛选和导出功能每一步都是被真实需求推着走的而不是拍脑袋想出来的。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑