记账是很多人日常生活中的刚需,市面上的记账App功能繁杂还常常夹带广告。其实用Golang自己写一个命令行记账工具并不难,只需要几百行代码就能实现记录收支、分类统计、按月汇总等核心功能。本文将完整讲解从项目搭建到功能实现的全部过程,代码全部基于标准库和少量第三方库,编译后是一个单独的可执行文件,拷贝到任何机器上都能直接运行。

一、需求分析与项目结构设计
在动手写代码之前,先明确这个记账工具要做什么。核心需求可以归纳为四点:第一,能记录一笔收入或支出,包含金额、分类、备注和时间;第二,能列出所有账目记录,支持按月份筛选;第三,能按分类统计支出占比;第四,数据要能持久化保存,程序重启后数据不丢失。
考虑到这是一个轻量级工具,我们不用数据库,而是直接用JSON文件存储数据。这样做的优点是零依赖、可读性好,用户甚至可以手动编辑数据文件。项目结构建议按照Go的标准布局来组织,将数据模型、存储逻辑和命令处理分层,方便后期扩展。项目目录大致如下:
ledger/
├── go.mod
├── main.go // 程序入口
├── model/
│ └── record.go // 账目数据模型
├── store/
│ └── store.go // JSON文件读写
└── cmd/
├── add.go // 添加账目命令
├── list.go // 列出账目命令
└── stat.go // 统计报表命令这种分层结构让每个文件职责单一。model包定义数据结构,store包负责持久化,cmd包处理用户交互,main.go只做调度。当以后想加新功能时,比如数据导出,只需要新增一个命令文件,不影响已有代码。
二、定义数据模型与JSON持久化
记账工具的核心数据是账目记录。这里定义一个Record结构体,包含ID、类型(收入或支出)、金额、分类、备注和时间戳。关于金额有一个非常重要的细节:不要用float64存储金额。浮点数在计算时会产生精度问题,比如0.1加0.2的结果是0.30000000000000004,记账场景下这种误差不可接受。正确做法是以分为单位用int64存储,也就是把10.5元存成1050分。
package model
import "time"
// Record 一笔账目记录
type Record struct {
ID int64 `json:"id"`
Type string `json:"type"` // income 或 expense
Amount int64 `json:"amount"` // 以分为单位
Category string `json:"category"`
Note string `json:"note"`
CreatedAt time.Time `json:"created_at"`
}存储层的实现思路很简单:把所有记录放在一个切片里,整体序列化为JSON写入文件,读取时反序列化回来。数据文件默认放在用户主目录下的.ledger.json。写入时使用os.WriteFile配合json.MarshalIndent,输出的文件带缩进,方便人工检查。需要注意并发写入的问题,虽然单机命令行工具很少遇到并发,但加上互斥锁是好习惯。
package store
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"ledger/model"
)
type Store struct {
mu sync.Mutex
path string
}
func NewStore() (*Store, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, err
}
return &Store{path: filepath.Join(home, ".ledger.json")}, nil
}
// Load 读取全部账目
func (s *Store) Load() ([]model.Record, error) {
s.mu.Lock()
defer s.mu.Unlock()
data, err := os.ReadFile(s.path)
if os.IsNotExist(err) {
return nil, nil // 首次使用,文件不存在是正常的
}
if err != nil {
return nil, err
}
var records []model.Record
if err := json.Unmarshal(data, &records); err != nil {
return nil, err
}
return records, nil
}
// Save 保存全部账目
func (s *Store) Save(records []model.Record) error {
s.mu.Lock()
defer s.mu.Unlock()
data, err := json.MarshalIndent(records, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.path, data, 0644)
}这里有个容易忽略的细节:文件不存在时不要报错,而是返回空切片,因为用户第一次使用工具时数据文件还不存在。另外0644权限对个人记账数据来说够用了,如果对隐私有更高要求,可以进一步用crypto/aes包对文件内容加密后再落盘。
三、实现添加与查询命令
命令行交互可以用标准库的flag包,也可以用更强大的spf13/cobra。为了展示原理,这里用flag包实现,足够简单直接。添加账目时接收四个参数:类型、金额、分类、备注。金额输入的是元,程序内部乘以100转成分。为了避免浮点转换误差,先把浮点元乘以100,再加上0.5做四舍五入再转int64。
package cmd
import (
"flag"
"fmt"
"time"
"ledger/model"
"ledger/store"
)
// AddCmd 处理 ledger add 命令
func AddCmd(args []string) error {
fs := flag.NewFlagSet("add", flag.ExitOnError)
rtype := fs.String("type", "expense", "类型: income 或 expense")
amount := fs.Float64("amount", 0, "金额(元)")
category := fs.String("cat", "其他", "分类")
note := fs.String("note", "", "备注")
if err := fs.Parse(args); err != nil {
return err
}
if *amount <= 0 {
return fmt.Errorf("金额必须大于0")
}
// 元转分,四舍五入避免浮点误差
cents := int64(*amount*100 + 0.5)
st, err := store.NewStore()
if err != nil {
return err
}
records, err := st.Load()
if err != nil {
return err
}
rec := model.Record{
ID: time.Now().UnixNano(),
Type: *rtype,
Amount: cents,
Category: *category,
Note: *note,
CreatedAt: time.Now(),
}
records = append(records, rec)
if err := st.Save(records); err != nil {
return err
}
fmt.Printf("已记录: %s %s %.2f元 [%s] %s\n",
rec.CreatedAt.Format("2006-01-02 15:04"),
typeLabel(*rtype), *amount, *category, *note)
return nil
}
func typeLabel(t string) string {
if t == "income" {
return "收入"
}
return "支出"
}查询命令的实现是遍历记录并格式化输出。用tabwriter包可以自动对齐各列,让终端里的表格看起来整齐。按月份筛选时,用time.Time的Format方法生成"2006-01"格式的字符串做比较即可。注意Go的时间格式化模板很特别,必须用2006-01-02 15:04:05这个特定时间点,这是初学者最常犯的错误之一。
package cmd
import (
"flag"
"fmt"
"os"
"text/tabwriter"
"ledger/store"
)
// ListCmd 列出账目,支持 -m 按月份筛选
func ListCmd(args []string) error {
fs := flag.NewFlagSet("list", flag.ExitOnError)
month := fs.String("m", "", "月份,格式 2024-06")
if err := fs.Parse(args); err != nil {
return err
}
st, _ := store.NewStore()
records, err := st.Load()
if err != nil {
return err
}
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(w, "时间\t类型\t金额\t分类\t备注")
for _, r := range records {
if *month != "" && r.CreatedAt.Format("2006-01") != *month {
continue
}
fmt.Fprintf(w, "%s\t%s\t%.2f\t%s\t%s\n",
r.CreatedAt.Format("2006-01-02"), typeLabel(r.Type),
float64(r.Amount)/100, r.Category, r.Note)
}
return w.Flush()
}四、统计报表与功能扩展
统计功能是记账工具的灵魂。按分类统计的实现思路是遍历所有支出记录,用map累加每个分类的总金额,再排序输出。如果想更直观,可以在终端里用字符画一个简单的条形图,用井号的重复次数表示金额占比,这种纯文本可视化不需要任何第三方绘图库。
package cmd
import (
"flag"
"fmt"
"sort"
"ledger/store"
)
// StatCmd 按分类统计支出
func StatCmd(args []string) error {
fs := flag.NewFlagSet("stat", flag.ExitOnError)
month := fs.String("m", "", "月份,格式 2024-06")
if err := fs.Parse(args); err != nil {
return err
}
st, _ := store.NewStore()
records, err := st.Load()
if err != nil {
return err
}
total := int64(0)
catSum := make(map[string]int64)
for _, r := range records {
if r.Type != "expense" {
continue
}
if *month != "" && r.CreatedAt.Format("2006-01") != *month {
continue
}
catSum[r.Category] += r.Amount
total += r.Amount
}
// 按金额降序排列
cats := make([]string, 0, len(catSum))
for c := range catSum {
cats = append(cats, c)
}
sort.Slice(cats, func(i, j int) bool {
return catSum[cats[i]] > catSum[cats[j]]
})
for _, c := range cats {
pct := float64(catSum[c]) / float64(total) * 100
bar := ""
n := int(pct / 2)
for i := 0; i < n; i++ {
bar += "#"
}
fmt.Printf("%-8s %10.2f元 %5.1f%% %s\n",
c, float64(catSum[c])/100, pct, bar)
}
fmt.Printf("总支出: %.2f元\n", float64(total)/100)
return nil
}最后在main.go里根据第一个参数分发到不同子命令,没有参数时打印使用说明。整个程序编译后只有几MB大小,可以加入系统的alias或者放进PATH,随时在终端敲一条命令完成记账。
package main
import (
"fmt"
"os"
"ledger/cmd"
)
func main() {
if len(os.Args) < 2 {
fmt.Println(`用法:
ledger add -type expense -amount 25.5 -cat 餐饮 -note 午饭
ledger list -m 2024-06
ledger stat -m 2024-06`)
os.Exit(0)
}
var err error
switch os.Args[1] {
case "add":
err = cmd.AddCmd(os.Args[2:])
case "list":
err = cmd.ListCmd(os.Args[2:])
case "stat":
err = cmd.StatCmd(os.Args[2:])
default:
err = fmt.Errorf("未知命令: %s", os.Args[1])
}
if err != nil {
fmt.Println("错误:", err)
os.Exit(1)
}
}如果想让工具更进一步,还有几个方向可以考虑:一是加删除命令,按ID删除错误记录;二是加导出功能,把数据转成CSV用Excel打开;三是换用SQLite存储,当记录超过几万条时性能更好;四是做一个简单的Web界面,用net/http包起一个本地服务,配合ECharts画饼图。这些扩展都建立在本文的分层结构之上,改动成本很低。动手把这个工具跑起来,你不仅能得到一个顺手的记账应用,更能完整体验一次Go语言从建模到落地的开发流程。