Go 从 0 到精通 · 第 24 课:命令行小项目实战
Go 从 0 到精通 · 第 24 课:命令行小项目实战
学习定位:这是整套 Go 教程的第 24 课,也是阶段四(标准库与实战阶段)的最后一课。
前置要求:已经完成第 23 课,掌握了排序与集合操作。需要综合运用前面所有课程的知识。
本课目标:整合前面所学的结构体、切片、map、文件操作、JSON、排序、错误处理、时间处理、字符串处理等知识,从零完成一个具有实际功能的命令行待办事项管理器。
1. 本课你要解决的核心问题
前面 23 课你学了很多东西:变量、函数、结构体、切片、map、接口、文件、JSON、排序……但它们都是零散的。这一课要做的事就一个:把它们串起来,写一个真正能用的程序。
我们要完成一个命令行待办事项管理器(Todo Manager),它能做到:
- 添加待办事项(标题、分类、优先级、截止日期)
- 列出所有待办事项(支持多种排序方式)
- 标记任务为已完成
- 删除任务
- 按关键字搜索
- 按分类筛选
- 显示统计信息
- 数据保存到文件(关闭程序后不丢失)
这个项目会用到你前面学的几乎所有东西:
| 知识点 | 对应课程 | 在项目中的用途 |
|---|---|---|
| 输入输出 | 第 3 课 | 命令行交互 |
| 条件判断与循环 | 第 4、5 课 | 菜单逻辑、遍历 |
| 函数 | 第 6 课 | 功能拆分 |
| 指针 | 第 7 课 | 修改任务状态 |
| 切片 | 第 9 课 | 存储任务列表 |
| map | 第 10 课 | 分类统计 |
| 字符串 | 第 11、21 课 | 搜索匹配 |
| 结构体 | 第 12 课 | 任务数据模型 |
| 方法 | 第 13 课 | 任务行为 |
| 错误处理 | 第 16 课 | 文件读写、输入校验 |
| defer | 第 17 课 | 文件关闭 |
| 文件操作 | 第 19 课 | 数据持久化 |
| 时间处理 | 第 20 课 | 截止日期、创建时间 |
| JSON | 第 22 课 | 数据序列化 |
| 排序 | 第 23 课 | 任务排序 |
学完这一课,你就完成了整个阶段四,具备了用 Go 做真实小项目的完整能力。
2. 项目设计——动手之前先想清楚
2.1 需求拆解
一个待办事项管理器,最核心的事情是什么?管理任务。一个任务需要哪些信息?
1 | 任务 = { |
用户通过命令行菜单操作这些任务,所有数据保存到一个 JSON 文件里。
2.2 交互设计
程序启动后显示菜单,用户输入数字选择操作:
1 | ========== 待办事项管理器 ========== |
2.3 数据存储
用 JSON 文件存储,格式大概长这样:
1 | { |
设计想清楚了,开始写代码。
3. 第一步:定义数据结构
3.1 任务结构体
1 | package main |
设计说明:
ID是唯一标识,方便用户指定"完成第几号任务"Priority用 int 表示(1=高、2=中、3=低),数字越小优先级越高,方便排序DueDate加了omitempty,没设截止日期时 JSON 里不输出这个字段- JSON tag 用蛇形命名,这是 JSON 的常见风格(第 22 课)
3.2 优先级的显示方法
1 | // PriorityText 返回优先级的中文描述 |
3.3 任务的显示方法
1 | import "fmt" |
这里用到了:
- 方法(第 13 课):给
Task定义String()方法 - 时间格式化(第 20 课):
t.DueDate.Format("2006-01-02") - 时间比较(第 20 课):
time.Now().After(t.DueDate)判断是否过期 - 零值判断:
t.DueDate.IsZero()判断有没有设置截止日期
3.4 数据存储结构
1 | // TodoList 是整个待办事项列表,也是 JSON 文件的顶层结构 |
NextID 记录下一个可用的 ID。每次添加任务就用这个 ID,然后自增。这样即使删除了任务,ID 也不会重复。
4. 第二步:实现核心功能
4.1 添加任务
1 | // AddTask 添加一个新任务 |
为什么用指针接收者(第 13 课):AddTask 要修改 TodoList 的内容(添加元素、递增 ID),如果用值接收者,修改的是副本,原始数据不会变。
4.2 标记完成
1 | import "errors" |
注意 for i := range 而不是 for _, task := range。
这是第 9 课和第 12 课提过的经典问题:for _, task := range 中的 task 是副本,修改 task.Done 不会影响切片中的原始数据。用索引 tl.Tasks[i].Done = true 才能真正修改。
错误处理(第 16 课):找不到任务或任务已完成,都返回错误信息。
4.3 删除任务
1 | // DeleteTask 删除指定 ID 的任务 |
切片删除元素的技巧,第 9 课讲过:append(s[:i], s[i+1:]...)。
4.4 搜索任务
1 | import "strings" |
字符串处理(第 21 课):strings.ToLower 转小写实现大小写不敏感搜索,strings.Contains 判断是否包含子串。
4.5 按分类筛选
1 | // GetByCategory 返回指定分类的所有任务 |
去重用到了第 23 课的 map 去重技巧。
4.6 排序功能
1 | import "sort" |
为什么用 SliceStable(第 23 课):稳定排序保证同优先级的任务保持原来的顺序。
多字段排序(第 23 课):先按完成状态分组,再按优先级排序。这正是第 23 课讲的多字段排序技巧。
4.7 统计信息
1 | // Stats 返回任务的统计信息 |
map 统计(第 10 课):用 map[string]int 按分类和优先级计数。
5. 第三步:数据持久化
5.1 保存到文件
1 | import ( |
JSON 编码(第 22 课):json.MarshalIndent 生成格式化的 JSON,方便人类阅读。
文件写入(第 19 课):os.WriteFile 直接写入整个文件。
错误包装(第 16 课):用 %w 包装原始错误,保留错误链。
5.2 从文件加载
1 | // Load 从 JSON 文件加载数据 |
文件不存在的处理:第一次运行程序时 todos.json 不存在,这是正常情况,不应该报错,而应该返回一个空列表。os.IsNotExist(err) 判断是否是"文件不存在"的错误。
JSON 解码(第 22 课):json.Unmarshal 把 JSON 数据解析到结构体中。
6. 第四步:命令行交互
6.1 主菜单
1 | import "bufio" |
为什么用 bufio.Scanner 而不是 fmt.Scan:fmt.Scan 遇到空格就停了,读不了一整行。bufio.Scanner 按行读取,能处理带空格的输入(比如任务标题"写 Go 作业")。
6.2 添加任务的交互
1 | import "strconv" |
这个函数展示了完整的用户输入处理流程:
- 提示用户输入
- 读取一行
- 去除空白(
strings.TrimSpace,第 21 课) - 校验输入(空值检查)
- 类型转换(
strconv.Atoi,第 21 课) - 提供默认值(分类默认"其他",优先级默认"中")
- 解析时间(
time.Parse,第 20 课)
6.3 查看任务列表
1 | func handleList(tl *TodoList, scanner *bufio.Scanner) { |
fmt.Println(task) 自动调用 String() 方法:这是 Go 的约定(第 13 课提到的 Stringer 接口)。只要类型实现了 String() string 方法,fmt.Println 就会用它来格式化输出。
6.4 标记完成
1 | func handleComplete(tl *TodoList, scanner *bufio.Scanner) { |
6.5 删除任务
1 | func handleDelete(tl *TodoList, scanner *bufio.Scanner) { |
二次确认是好习惯:删除是不可逆的操作,加一步确认可以防止误操作。
6.6 搜索任务
1 | func handleSearch(tl *TodoList, scanner *bufio.Scanner) { |
6.7 按分类查看
1 | func handleCategoryView(tl *TodoList, scanner *bufio.Scanner) { |
6.8 统计信息
1 | func handleStats(tl *TodoList) { |
注意遍历顺序:直接遍历 map 的顺序是随机的(第 10 课)。所以按优先级输出时,手动指定了遍历顺序 []string{"高", "中", "低"}。
7. 完整代码
把上面所有代码合并到一个 main.go 文件中:
1 | package main |
8. 运行效果
把代码保存为 main.go,运行 go run main.go,实际操作一把:
1 | 欢迎使用待办事项管理器! |
退出后再启动程序,数据还在——因为保存到了 todos.json 文件里。
9. 项目回顾——学到了什么
这个项目虽然不大,但已经包含了一个真实程序的完整要素。回顾一下每个关键设计决策:
9.1 为什么用 NextID 而不是数组索引做 ID
如果用数组索引做 ID,删除一个任务后,后面所有任务的 ID 都会变。用户说"完成第 3 号任务",删了中间一个后第 3 号指向的任务就变了。NextID 只增不减,保证每个任务有唯一稳定的编号。
9.2 为什么 CompleteTask 用索引遍历而不用 range 值
1 | // 错误:修改的是副本 |
这是第 9 课和第 12 课反复强调的:range 的第二个值是副本。
9.3 为什么用 bufio.Scanner 读输入
fmt.Scan 以空白为分隔符,如果用户输入"写 Go 作业",fmt.Scan 只能读到"写"。bufio.Scanner 按行读取,能处理完整的一行输入。
9.4 为什么每个 handle 函数都传 scanner
因为 bufio.Scanner 包装了 os.Stdin。如果在多个地方各自创建 Scanner,它们的内部缓冲区会互相抢数据,导致读取混乱。一个 stdin 只用一个 Scanner。
9.5 为什么排序用 SliceStable 而不是 Slice
用户操作任务时有个朴素的期望:如果两个任务优先级一样,它们应该保持我添加时的顺序。SliceStable 满足这个期望,Slice 不保证。
9.6 保存时机的选择
当前实现是退出时保存。这意味着如果程序崩溃或者被强制关闭,数据会丢失。更稳妥的做法是每次修改后都自动保存,但那样代码更复杂。对于学习项目,退出时保存够用了。
10. 常见坑总结
10.1 Scanner 的缓冲区大小
1 | scanner := bufio.NewScanner(os.Stdin) |
10.2 time.Parse 的参考时间
1 | // Go 的时间解析用的参考时间是固定的:2006-01-02 15:04:05 |
10.3 JSON 的零值问题
1 | type Task struct { |
10.4 切片删除后的索引
1 | // 删除后不要继续用原来的索引遍历 |
这个项目里每次只删一个,删完就 return,所以没有问题。但如果你要批量删除,需要从后往前遍历,或者用新切片收集要保留的元素。
10.5 map 遍历顺序不确定
1 | // 错误:直接遍历 map,每次顺序可能不同 |
10.6 输入校验不能省
1 | // 如果用户输入的不是数字,strconv.Atoi 会返回错误 |
每个用户输入都要假设它可能是错的。不做校验的程序迟早会崩。
11. 本课练习
练习 1:添加"修改任务"功能
要求:
- 用户可以修改已有任务的标题、分类、优先级、截止日期
- 先显示当前值,用户输入新值(留空表示不修改)
提示:找到任务后逐个字段提示用户输入,空输入跳过。
练习 2:添加"批量完成"功能
要求:
- 用户可以一次性输入多个任务编号,用逗号分隔(如
1,3,5) - 逐个处理,输出每个任务的处理结果
提示:用 strings.Split 按逗号分割,循环处理每个编号。
练习 3:数据备份功能
要求:
- 添加一个菜单选项"备份数据"
- 把当前数据保存到带日期的文件名,如
todos_20250120.json - 用
time.Now().Format("20060102")生成日期字符串
练习 4:改进排序——支持同时按多个条件排序
要求:
- 让用户可以选择"先按分类排,再按优先级排"
- 或者"先按完成状态排,再按截止日期排"
提示:在 less 函数中嵌套条件判断,跟第 23 课的多字段排序一样。
练习 5:导出为文本报告
要求:
- 添加一个菜单选项"导出报告"
- 生成一个纯文本的任务报告文件
report.txt - 内容包括统计信息和所有任务列表
提示:用 os.WriteFile 或 bufio.Writer 写文件。
12. 自测题
12.1 概念题
- 为什么
AddTask方法要用指针接收者*TodoList而不是值接收者TodoList? for _, task := range tl.Tasks中修改task.Done为什么不生效?怎么改?- 为什么用
bufio.Scanner读输入而不是fmt.Scan? os.IsNotExist(err)判断的是什么?为什么要单独处理这个错误?json.MarshalIndent(tl, "", " ")的第二个和第三个参数分别是什么意思?- 为什么
NextID只增不减,即使删了任务也不回收 ID? - 为什么一个
os.Stdin只应该创建一个bufio.Scanner? sort.SliceStable比sort.Slice多保证了什么?在这个项目中为什么选择用它?
12.2 代码阅读题
预测以下代码的输出:
1 | type Item struct { |
点击查看答案
1 | A: false |
解释:
- 第一个
for range中的item是副本,修改item.Done不影响切片中的原始数据 - 要修改原始数据,必须用索引:
items[i].Done = true - 这正是本课
CompleteTask方法中为什么用for i := range而不是for _, task := range的原因
13. 本课总结
这是阶段四的最后一课,你通过一个完整的项目把前面学的知识串了起来。
| 你做了什么 | 用到了哪些知识 |
|---|---|
| 定义 Task 结构体 | 结构体、JSON tag |
| 实现 String() 方法 | 方法、Stringer 接口、时间格式化 |
| 添加/完成/删除任务 | 指针接收者、切片操作、错误处理 |
| 搜索和筛选 | 字符串处理、切片过滤 |
| 多种排序方式 | sort.SliceStable、多字段排序 |
| 统计信息 | map 计数、格式化输出 |
| 数据持久化 | JSON 编解码、文件读写 |
| 命令行交互 | bufio.Scanner、strconv、输入校验 |
最重要的三件事:
- 写项目先想清楚数据结构——结构体和切片是 Go 程序的骨架
- 用户输入永远不可信——每个输入都要校验,每个错误都要处理
- 知识串起来才有用——单独会排序、会文件操作没用,能组合在一起解决实际问题才算掌握
恭喜你完成了阶段四!到这里,你已经具备了用 Go 编写真实小项目的完整能力。
14. 下一课预告
下一课进入阶段五:并发与工程阶段,这是 Go 最有特色的部分。
第 25 课:goroutine 入门
会重点讲:
- Go 并发的基本概念——什么是 goroutine
- 怎么创建和运行 goroutine
- 主协程退出问题——为什么你的 goroutine “没有执行”
- goroutine 和线程的区别
学完下一课,你就踏入了 Go 最强大也最有意思的领域。





