导读:本期聚焦于王柏年创作的《Golang如何开发记账小工具?从零实现一个命令行记账应用完整教程》,敬请观看详情。想用Golang做一个属于自己的记账小工具吗?本文将从需求分析开始,手把手带你实现一个支持收入支出记录、分类统计和数据持久化的命令行记账应用。内容涵盖项目结构设计、JSON文件存储方案、cobra命令行框架的使用,以及按月份和分类生成统计报表的功能实现。文章还会讲解如何处理时间格式化、金额计算精度等实际开发中容易踩坑的问题,最后补充数据导出与简单的可视化思路。整套方案不依赖数据库,代码量小,适合Go语言初学者练手,也能直接改造为自己的日常记账工具。

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

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语言从建模到落地的开发流程。

Golang记账命令行工具Go语言开发修改时间:2026-08-31 10:17:18

免责声明:​ 已尽一切努力确保本网站所含信息的准确性。网站内容多为原创整理与精心编撰,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们处理。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。