Go语言实战:从零构建命令行收支记录系统

📅 2026/7/12 5:36:16
Go语言实战:从零构建命令行收支记录系统
1. 项目概述为什么用Go写一个收支记录Demo最近在社区里看到不少朋友在讨论Go语言的学习路径很多人学完了基础语法但一到动手做点东西就卡壳了。正好一个“简易的收支记录Demo”是个绝佳的练手项目。它麻雀虽小五脏俱全涉及用户交互、数据结构设计、数据持久化、简单的业务逻辑几乎覆盖了一个后端应用最核心的骨架。用Go来实现它不仅能巩固语法更能让你体会到Go在构建清晰、高效命令行工具方面的独特魅力——没有繁重的框架依赖从main函数开始一步步搭出一个能跑、能用的程序这种掌控感是其他语言很难比拟的。这个Demo的目标很明确构建一个运行在终端里的程序让用户能记录每一笔收入或支出包括金额、分类、备注和时间并且能随时查看所有的历史记录甚至进行简单的统计。最终我们希望数据能保存到本地文件里关闭程序再打开记录依然还在。别看功能简单里面藏着结构体定义、切片操作、文件读写、时间处理、字符串格式化等多个关键知识点。接下来我会带你从零开始拆解每一个环节并分享我在实现过程中踩过的坑和总结的技巧。2. 核心设计与思路拆解2.1 为什么选择纯标准库实现看到这个需求你的第一反应可能是去找一个第三方库比如用于命令行交互的cobra或者用于表格打印的tablewriter。但对于一个旨在学习和演示核心概念的Demo我强烈建议坚持使用Go的标准库。原因有三点首先这能让你最直接地理解Go语言处理I/O、字符串和数据的原生方式这是成为合格Gopher的基石。其次依赖越少项目越纯粹编译和分享越方便一个go build就能搞定。最后标准库的API设计本身就是极佳的学习范本。我们的核心依赖将仅限于fmt、os、bufio、encoding/json、time这几个包它们足以支撑我们完成所有功能。2.2 数据模型设计结构体的艺术一切从定义数据模型开始。我们需要一个结构体来代表单笔收支记录。这里有几个设计考量type Record struct { ID int json:id Amount float64 json:amount Category string json:category Note string json:note Type string json:type // income 或 expense CreatedAt time.Time json:created_at }ID字段这是一个易被忽略但至关重要的字段。它为每笔记录提供唯一标识对于后续的修改、删除或精确查找操作是必要的。即使当前Demo只有新增和查看加上ID也是良好的实践。Amount类型金额使用float64是常见的做法但要注意浮点数精度问题。对于涉及金融计算的生产环境建议使用decimal类型如shopspring/decimal库。在Demo中我们约定以“元”为单位float64可以满足展示需求。Type字段用字符串枚举“income”和“expense”比用布尔值isIncome更清晰也便于后续扩展例如未来增加“transfer”转账类型。CreatedAt字段使用time.Time类型直接存储时间点而非字符串。这保证了时间的可比较性和便于格式化输出。json标签确保了序列化时的字段名。整个程序的状态就是由一个Record的切片来维护var records []Record。选择切片而非数组是因为记录数量是动态增长的。2.3 架构思路面向过程的清晰流对于这个规模的Demo采用简单的面向过程的主循环驱动就非常清晰。整体流程如下启动加载程序启动时从本地JSON文件如records.json中读取历史记录加载到内存的records切片中。主循环打印菜单等待用户输入选项。分支处理根据输入1.新增2.查看3.统计4.退出调用相应的处理函数。持久化在新增记录后立即或定期将整个records切片序列化为JSON并写入文件。这里我们采用“即写即存”的简单策略确保数据安全。退出保存程序退出前再次执行保存操作双保险。这种线性的控制流对于初学者来说直观易懂所有状态变化和I/O操作都发生在明确的函数调用中。3. 核心模块实现与实操要点3.1 数据持久化JSON文件的读写数据持久化是让Demo从“玩具”变成“工具”的关键。我们选择JSON格式因为它是人类可读的并且Go标准库encoding/json的支持非常完善。保存数据到文件func saveRecordsToFile(filename string, records []Record) error { // 1. 将记录切片序列化为JSON字节 data, err : json.MarshalIndent(records, , ) if err ! nil { return fmt.Errorf(序列化记录失败: %v, err) } // 2. 将字节数据写入文件覆盖模式 err os.WriteFile(filename, data, 0644) if err ! nil { return fmt.Errorf(写入文件失败: %v, err) } return nil }注意MarshalIndent使得生成的JSON带缩进便于阅读和调试但会比Marshal生成的文件稍大。在生产环境中为了效率可能用Marshal但Demo中可读性优先。文件权限0644表示所有者可读写其他人只读。从文件加载数据func loadRecordsFromFile(filename string) ([]Record, error) { // 1. 检查文件是否存在 _, err : os.Stat(filename) if os.IsNotExist(err) { // 文件不存在返回空切片这不是一个错误 return []Record{}, nil } // 2. 读取文件内容 data, err : os.ReadFile(filename) if err ! nil { return nil, fmt.Errorf(读取文件失败: %v, err) } // 3. 反序列化JSON到切片 var records []Record // 重点如果文件存在但为空json.Unmarshal 不会报错但records会是nil。 // 我们更希望返回一个空切片。 if len(data) 0 { return []Record{}, nil } err json.Unmarshal(data, records) if err ! nil { return nil, fmt.Errorf(解析JSON失败: %v, err) } return records, nil }实操心得这里有个常见的坑。如果文件存在但内容为空json.Unmarshal会成功但records为nil。后续对nil切片进行append操作虽然可以但打印长度时显示为0可能引发混淆。因此我们显式检查数据长度返回一个初始化过的空切片[]Record{}这样行为更一致。3.2 新增记录与用户交互的细节新增记录的函数需要引导用户输入各个字段并进行基本的验证。func addRecord(records *[]Record) { var record Record fmt.Print(请输入金额: ) _, err : fmt.Scanln(record.Amount) if err ! nil { fmt.Println(金额输入无效请重新操作。) return // 简单处理直接返回主菜单 } // 处理输入缓冲区残留的换行符Scanln的坑 scanner : bufio.NewScanner(os.Stdin) fmt.Print(请输入分类 (如: 餐饮、交通、工资): ) if scanner.Scan() { record.Category strings.TrimSpace(scanner.Text()) } fmt.Print(请输入类型 (1.收入 / 2.支出): ) var typeChoice int fmt.Scanln(typeChoice) if typeChoice 1 { record.Type income } else if typeChoice 2 { record.Type expense } else { fmt.Println(类型选择无效默认为支出。) record.Type expense } // 再次用scanner读取备注避免Scanln的换行符问题 fmt.Print(请输入备注: ) if scanner.Scan() { record.Note strings.TrimSpace(scanner.Text()) } // 生成ID当前最大ID1和时间 record.ID generateNextID(*records) record.CreatedAt time.Now() // 追加到切片 *records append(*records, record) fmt.Printf(记录已添加 (ID: %d)\n, record.ID) }关键技巧混合使用fmt.Scanln和bufio.Scanner。Scanln读取数字很方便但在读取字符串后会留下换行符在输入缓冲区导致下一次读取直接得到空行。对于连续的字符串输入如分类、备注使用同一个bufio.Scanner实例是更稳健的做法它能正确处理行尾。strings.TrimSpace用于去除用户输入首尾的无意义空格。生成ID的函数func generateNextID(records []Record) int { maxID : 0 for _, r : range records { if r.ID maxID { maxID r.ID } } return maxID 1 }这个简单的线性查找在记录数少时完全没问题。如果记录量巨大可以考虑在程序内存中维护一个全局自增ID计数器并在加载数据时初始化它。3.3 查看记录美观的终端表格输出将记录以表格形式打印到终端能极大提升可读性。我们可以利用fmt.Printf的格式化功能来模拟表格。func viewAllRecords(records []Record) { if len(records) 0 { fmt.Println(暂无任何收支记录。) return } // 打印表头 fmt.Printf(%-6s %-10s %-12s %-8s %-30s %-20s\n, ID, 类型, 分类, 金额, 备注, 时间) fmt.Println(strings.Repeat(-, 90)) // 分隔线 // 打印每条记录 for _, r : range records { // 根据类型添加前缀收入为绿色支出为红色部分终端支持 typeStr : r.Type if r.Type income { typeStr \033[32m收入\033[0m // 绿色 } else { typeStr \033[31m支出\033[0m // 红色 } // 格式化时间只显示日期和小时分钟 timeStr : r.CreatedAt.Format(2006-01-02 15:04) fmt.Printf(%-6d %-10s %-12s %-8.2f %-30s %-20s\n, r.ID, typeStr, r.Category, r.Amount, r.Note, timeStr) } }注意事项%-8.2f中的-表示左对齐8表示最小宽度为8个字符.2表示保留两位小数。颜色转义码\033[32m和\033[0m并非所有终端都支持比如Windows的默认CMD在需要跨平台时可以考虑去掉颜色或者使用第三方跨平台终端颜色库。2006-01-02 15:04:05是Go语言时间格式化的魔数必须严格按照这个参考时间写。3.4 简单统计功能切片遍历与计算统计功能可以快速给出总收入和总支出让用户对财务状况有个概览。func showStatistics(records []Record) { var totalIncome, totalExpense float64 for _, r : range records { if r.Type income { totalIncome r.Amount } else if r.Type expense { totalExpense r.Amount } } balance : totalIncome - totalExpense fmt.Println( 收支统计 ) fmt.Printf(总收入: ¥%.2f\n, totalIncome) fmt.Printf(总支出: ¥%.2f\n, totalExpense) fmt.Printf(结余: ¥%.2f\n, balance) fmt.Println(strings.Repeat(, 20)) }这个实现简单直接。如果记录量非常大且统计是频繁操作可以考虑在内存中缓存这些统计值并在每次新增记录时更新缓存用空间换时间。4. 主程序循环与用户交互将上述模块组合起来形成程序的主干。主循环的逻辑是经典的命令行交互模式。func main() { // 1. 初始化加载数据 filename : records.json records, err : loadRecordsFromFile(filename) if err ! nil { fmt.Printf(加载记录文件失败将创建新文件。错误: %v\n, err) records []Record{} // 初始化为空切片 } else { fmt.Printf(成功加载 %d 条历史记录。\n, len(records)) } // 2. 主循环 scanner : bufio.NewScanner(os.Stdin) for { printMenu() fmt.Print(请选择操作: ) if !scanner.Scan() { break // 读取输入失败退出循环 } choice : strings.TrimSpace(scanner.Text()) switch choice { case 1: addRecord(records) // 新增后立即保存 if err : saveRecordsToFile(filename, records); err ! nil { fmt.Printf(保存失败错误: %v\n, err) } case 2: viewAllRecords(records) case 3: showStatistics(records) case 4: fmt.Println(正在保存数据并退出...) if err : saveRecordsToFile(filename, records); err ! nil { fmt.Printf(保存失败错误: %v\n, err) } fmt.Println(再见) return // 直接退出程序 default: fmt.Println(无效选择请输入1-4之间的数字。) } fmt.Println() // 打印空行让每次操作后视觉上分隔开 } } func printMenu() { fmt.Println(\n 简易收支记录系统 ) fmt.Println(1. 新增收支记录) fmt.Println(2. 查看所有记录) fmt.Println(3. 查看统计) fmt.Println(4. 退出) }设计考量为什么在addRecord后立即保存而不是只在退出时保存这是为了数据的实时安全性。假设程序意外崩溃比如被强制终止立即保存能最大程度减少数据丢失。这是一种“牺牲一点性能换取数据安全”的策略对于本地文件操作这个性能开销微乎其微。5. 项目构建、运行与代码组织5.1 项目结构与Go Modules虽然是一个单文件Demo但良好的习惯从开始培养。建议创建一个项目目录并使用Go Modules管理依赖尽管我们目前没有第三方依赖。mkdir simple-finance-demo cd simple-finance-demo go mod init simple-finance-demo然后创建一个main.go文件将上述所有代码放入其中。最终目录结构如下simple-finance-demo/ ├── go.mod ├── go.sum (运行后生成) ├── main.go └── records.json (程序运行后生成)使用go run main.go即可运行。使用go build -o finance-demo main.go可以编译成可执行文件方便分发。5.2 如何扩展这个Demo这个基础版本已经可用但留出了大量的扩展空间你可以选择一两个方向进行深化练习按时间筛选查看修改viewAllRecords函数增加按今天、本周、本月查看记录的功能。这需要你与用户交互获取时间范围并遍历切片进行过滤。记录编辑与删除这是最自然的扩展。需要新增函数根据用户输入的ID在切片中找到对应记录这里体现了ID字段的作用然后修改其字段或从切片中移除使用append和切片切片的技巧records append(records[:index], records[index1:]...)。记住删除后要重新生成或调整后续记录的ID或者采用“逻辑删除”增加一个IsDeleted字段。按分类统计在showStatistics基础上输出每个分类如餐饮、交通的总收入和总支出。这需要用到map[string]float64来汇总。数据导出增加将记录导出为CSV或Excel格式的功能方便用其他软件分析。可以学习使用encoding/csv标准库。单元测试为generateNextID、loadRecordsFromFile需要处理测试文件等纯函数编写单元测试这是保证代码质量的关键步骤。6. 常见问题与排查技巧实录在实际编写和运行过程中你可能会遇到以下问题。这里记录了我的排查思路和解决方法。问题一运行程序后输入数字时程序直接跳过或者出现非预期的换行。现象在addRecord函数中输入金额后分类的输入提示一闪而过直接跳到了类型选择。根因这是Go中fmt.Scanln的经典问题。当用fmt.Scanln(record.Amount)读取数字后输入缓冲区里还残留着用户按下的Enter键即换行符\n。后续的fmt.Scanln或bufio.Scanner会立刻读到这个换行符误以为输入了一个空行。解决方案统一使用bufio.Scanner来读取所有输入包括数字然后用strconv包来转换字符串到数字。这是最稳健的方法。在每次fmt.Scanln读取后手动清空输入缓冲区。可以用fmt.Scanln()读取一个多余的字符串或者使用bufio读取到行尾。// 方法2示例在读取金额后清空缓冲区 var discard string fmt.Scanln(record.Amount) fmt.Scanln(discard) // 读取残留的换行符推荐使用方案1代码更清晰。问题二JSON文件内容为空或格式错误后程序无法启动。现象手动修改了records.json文件导致其不是合法的JSON格式比如少了个逗号或括号程序启动时loadRecordsFromFile报错直接退出。根因json.Unmarshal对格式要求严格。文件被其他程序或误操作破坏后会导致反序列化失败。解决方案增强程序的健壮性。在loadRecordsFromFile函数中如果解析失败可以有两种策略保守策略返回错误让主函数决定是退出还是使用空数据继续运行。我们Demo中采用了提示错误但初始化为空切片的方式保证了程序至少能启动。修复策略高级记录错误日志尝试备份损坏的文件如重命名为records.json.bak然后创建一个新的空文件继续运行。这需要更复杂的错误处理逻辑。问题三浮点数金额计算存在精度误差。现象输入0.1连续加10次统计显示的总和可能不是精确的1.0而是0.9999999999999999。根因这是二进制浮点数float32/float64的固有特性并非Go语言的bug。十进制小数0.1在二进制中是无限循环的无法精确表示。解决方案对于Demo在显示时用fmt.Printf(“%.2f”, value)格式化保留两位小数视觉上没问题。如果这是一个真实的财务系统必须使用定点数或十进制小数库例如非常流行的第三方库github.com/shopspring/decimal。它会用整数存储小数位数完全避免浮点误差。问题四记录很多时查看全部记录刷屏太快。现象有几百条记录时viewAllRecords会一次性全部打印不方便查看。解决方案实现分页功能。在函数内部计算总页数每次只显示一页比如20条并提示用户输入命令查看下一页(n)、上一页(p)或退出(q)。这需要用到更复杂的循环和状态控制。问题五Windows终端不显示颜色。现象在viewAllRecords中为“收入”“支出”添加的颜色代码在Windows CMD或PowerShell中显示为乱码。根因Windows旧版控制台默认不支持ANSI转义序列颜色代码。解决方案使用第三方跨平台库如github.com/fatih/color它会自动处理平台差异。条件编译为Windows系统编写无颜色的输出。最简单的方法在Demo中去掉颜色用其他符号标识如[]收入和[-]支出。通过亲手实现这个收支记录Demo你不仅练习了Go语法更重要的是实践了如何将一个想法分解为数据模型和函数模块如何处理用户输入和文件I/O如何考虑程序的健壮性和可扩展性。这些经验远比单纯看教程要深刻得多。试着运行它然后按照扩展建议去增加一两个功能你会对Go开发有更切实的体会。