【Go语言-Day 40】项目实战:从零到一打造一个功能完备的命令行 Todo List 应用
在本文中,我们将综合运用前一阶段学习的核心知识,从零开始构建一个完整、实用且具备数据持久化功能的命令行待办事项(Todo List)应用。这个项目不仅是对 `struct`、方法、`JSON` 序列化、文件 I/O 和 `flag` 包等知识点的一次全面实战演练,更是你迈向构建更复杂 Go 程序的重要基石。读完本文,你将能独立设计并实现一个结构清晰、功能完备的 CLI (Command-Line
Langchain系列文章目录
01-玩转LangChain:从模型调用到Prompt模板与输出解析的完整指南
02-玩转 LangChain Memory 模块:四种记忆类型详解及应用场景全覆盖
03-全面掌握 LangChain:从核心链条构建到动态任务分配的实战指南
04-玩转 LangChain:从文档加载到高效问答系统构建的全程实战
05-玩转 LangChain:深度评估问答系统的三种高效方法(示例生成、手动评估与LLM辅助评估)
06-从 0 到 1 掌握 LangChain Agents:自定义工具 + LLM 打造智能工作流!
07-【深度解析】从GPT-1到GPT-4:ChatGPT背后的核心原理全揭秘
08-【万字长文】MCP深度解析:打通AI与世界的“USB-C”,模型上下文协议原理、实践与未来
Python系列文章目录
PyTorch系列文章目录
机器学习系列文章目录
深度学习系列文章目录
Java系列文章目录
JavaScript系列文章目录
Python系列文章目录
Go语言系列文章目录
01-【Go语言-Day 1】扬帆起航:从零到一,精通 Go 语言环境搭建与首个程序
02-【Go语言-Day 2】代码的基石:深入解析Go变量(var, :=)与常量(const, iota)
03-【Go语言-Day 3】从零掌握 Go 基本数据类型:string
, rune
和 strconv
的实战技巧
04-【Go语言-Day 4】掌握标准 I/O:fmt 包 Print, Scan, Printf 核心用法详解
05-【Go语言-Day 5】掌握Go的运算脉络:算术、逻辑到位的全方位指南
06-【Go语言-Day 6】掌控代码流:if-else 条件判断的四种核心用法
07-【Go语言-Day 7】循环控制全解析:从 for 基础到 for-range 遍历与高级控制
08-【Go语言-Day 8】告别冗长if-else:深入解析 switch-case 的优雅之道
09-【Go语言-Day 9】指针基础:深入理解内存地址与值传递
10-【Go语言-Day 10】深入指针应用:解锁函数“引用传递”与内存分配的秘密
11-【Go语言-Day 11】深入浅出Go语言数组(Array):从基础到核心特性全解析
12-【Go语言-Day 12】解密动态数组:深入理解 Go 切片 (Slice) 的创建与核心原理
13-【Go语言-Day 13】切片操作终极指南:append、copy与内存陷阱解析
14-【Go语言-Day 14】深入解析 map:创建、增删改查与“键是否存在”的奥秘
15-【Go语言-Day 15】玩转 Go Map:从 for range 遍历到 delete 删除的终极指南
16-【Go语言-Day 16】从零掌握 Go 函数:参数、多返回值与命名返回值的妙用
17-【Go语言-Day 17】函数进阶三部曲:变参、匿名函数与闭包深度解析
18-【Go语言-Day 18】从入门到精通:defer、return 与 panic 的执行顺序全解析
19-【Go语言-Day 19】深入理解Go自定义类型:Type、Struct、嵌套与构造函数实战
20-【Go语言-Day 20】从理论到实践:Go基础知识点回顾与综合编程挑战
21-【Go语言-Day 21】从值到指针:一文搞懂 Go 方法 (Method) 的核心奥秘
22-【Go语言-Day 22】解耦与多态的基石:深入理解 Go 接口 (Interface) 的核心概念
23-【Go语言-Day 23】接口的进阶之道:空接口、类型断言与 Type Switch 详解
24-【Go语言-Day 24】从混乱到有序:Go 语言包 (Package) 管理实战指南
25-【Go语言-Day 25】从go.mod到go.sum:一文彻底搞懂Go Modules依赖管理
26-【Go语言-Day 26】深入解析error:从errors.New到errors.As的演进之路
27-【Go语言-Day 27】驾驭 Go 的异常处理:panic 与 recover 的实战指南与陷阱分析
28-【Go语言-Day 28】文本处理利器:strings
包函数全解析与实战
29-【Go语言-Day 29】从time.Now()到Ticker:Go语言time包实战指南
30-【Go语言-Day 30】深入探索Go文件读取:从os.ReadFile到bufio.Scanner的全方位指南
31-【Go语言-Day 31】精通文件写入与目录管理:os
与filepath
包实战指南
32-【Go语言-Day 32】从零精通 Go JSON:Marshal
、Unmarshal
与 Struct Tag 实战指南
33-【Go语言-Day 33】告别“能跑就行”:手把手教你用testing
包写出高质量的单元测试
34-【Go语言-Day 34】告别凭感觉优化:手把手教你 Go Benchmark 性能测试
35-【Go语言-Day 35】Go 反射核心:reflect
包从入门到精通
36-【Go语言-Day 36】构建专业命令行工具:flag
包入门与实战
37-【Go语言-Day 37】深入C世界:Go与C语言交互的桥梁——Cgo入门指南
38-【Go语言-Day 38】编写地道Go代码:Go语言官方代码规范与最佳实践深度解析
39-【Go语言-Day 39】Go 工具链深度游:掌握 build, vet, pprof 和交叉编译四大神器
40-【Go语言-Day 40】项目实战:从零到一打造一个功能完备的命令行 Todo List 应用
文章目录
摘要
欢迎来到我们 Go 语言学习之旅第二阶段的收官之作!在本文中,我们将综合运用前一阶段学习的核心知识,从零开始构建一个完整、实用且具备数据持久化功能的命令行待办事项(Todo List)应用。这个项目不仅是对 struct
、方法、JSON
序列化、文件 I/O 和 flag
包等知识点的一次全面实战演练,更是你迈向构建更复杂 Go 程序的重要基石。读完本文,你将能独立设计并实现一个结构清晰、功能完备的 CLI (Command-Line Interface) 工具,并对 Go 语言的项目组织方式有更深刻的理解。
一、项目概述与目标设定
在开始编写代码之前,清晰地定义项目目标和功能边界至关重要。这有助于我们保持专注,并衡量最终的成果。
1.1 功能需求分析
我们的 Todo List 应用需要满足以下核心功能,完全通过命令行进行交互:
- 添加任务 (Add): 用户可以添加一个新的待办事项。
- 查看任务 (List): 用户可以查看所有待办事项,并能清晰地区分已完成和未完成的任务。
- 完成任务 (Complete): 用户可以通过任务编号将某个待办事项标记为已完成。
- 删除任务 (Delete): 用户可以通过任务编号删除某个不再需要的待办事项。
- 数据持久化 (Persistence): 应用关闭后,所有任务数据不应丢失。再次启动时,应能加载之前的任务列表。
1.2 技术选型与知识点回顾
为了实现上述功能,我们将主要依赖 Go 语言的标准库,这完美地体现了 Go “自带电池”的哲学。
技术点 | 核心 Go 包/概念 | 关联课程 | 作用 |
---|---|---|---|
数据建模 | struct , type |
Day 19 | 定义 Todo 项和 Todos 列表的数据结构。 |
行为绑定 | Method (值/指针接收者) |
Day 21 | 为 Todos 列表添加 Add , Complete , Delete 等操作方法。 |
数据持久化 | encoding/json , os |
Day 30, 31, 32 | 使用 JSON 格式对任务列表进行序列化和反序列化,并读写文件。 |
命令行交互 | flag , os.Args |
Day 36 | 解析用户输入的命令(如 add , list )和参数(如任务内容)。 |
错误处理 | error 接口 |
Day 26 | 规范地处理文件操作、数据解析等过程中可能出现的错误。 |
项目管理 | Go Modules |
Day 25 | 初始化项目,管理依赖(尽管本项目无外部依赖)。 |
1.3 最终成果展示
设想一下我们最终完成的应用,它将这样在你的终端中运行:
1. 添加新任务
$ go run . add -task="学习 Go 并发编程"
任务 "学习 Go 并发编程" 已添加。
2. 查看任务列表
$ go run . list
待办事项列表:
[ ] 1: 学习 Go 并发编程
3. 完成一个任务
$ go run . complete -id=1
任务 1 已标记为完成。
4. 再次查看任务列表
$ go run . list
待办事项列表:
[✔] 1: 学习 Go 并发编程
5. 删除一个任务
$ go run . delete -id=1
任务 1 已删除。
二、项目结构设计
一个清晰的项目结构是软件可维护性的基础。对于我们这个小项目,我们将采用一种简洁而模块化的结构。
2.1 项目目录规划
我们将项目命名为 todo-cli
,其内部结构如下:
main.go
: 程序的入口,负责解析命令行参数,并调用todo.go
中定义的功能。它扮演着“指挥官”的角色。todo.go
: 定义了核心数据结构(Todo
项和Todos
列表)以及所有相关的业务逻辑(增、删、改、查、加载、保存)。它是我么应用的核心“引擎”。go.mod
: Go Modules 文件,用于管理项目依赖。.todos.json
: 默认的持久化数据文件。文件名以.
开头,在类 Unix 系统中通常表示为隐藏文件。
2.2 初始化 Go Modules
首先,创建项目目录并初始化 Go Modules。
# 创建项目文件夹
mkdir todo-cli
cd todo-cli
# 初始化 Go Module
go mod init todo.cli
执行后,目录下会生成一个 go.mod
文件,内容类似:
module todo.cli
go 1.21
三、核心模型与方法实现 (todo.go
)
现在,我们来填充项目的核心 todo.go
文件。这个文件将包含所有与待办事项列表本身相关的数据和操作。
3.1 定义 Todo
项与 Todos
列表
我们首先定义单个待办事项 Todo
的结构,以及由多个 Todo
组成的列表 Todos
。
// file: todo.go
package main
import (
"encoding/json"
"fmt"
"os"
"time"
)
// Todo 定义了单个待办事项的结构
type Todo struct {
// 任务内容,json:"task" 是结构体标签,用于JSON序列化/反序列化时指定字段名
Task string `json:"task"`
Completed bool `json:"completed"`
CreatedAt time.Time `json:"created_at"`
}
// Todos 是一个 Todo 类型的切片,我们为其定义方法
type Todos []Todo
代码解析:
Todo
结构体包含了任务描述Task
、完成状态Completed
和创建时间CreatedAt
。json:"..."
结构体标签(Struct Tag)是 Go 语言的一个强大特性。它告诉encoding/json
包在序列化(转为JSON)或反序列化(从JSON解析)时,Go 结构体字段应对应 JSON 中的哪个键名。type Todos []Todo
定义了一个新类型Todos
,它是Todo
切片的别名。这样做的好处是,我们可以为Todos
类型附加方法,使其表现得像一个拥有自己行为的对象。
3.2 实现核心业务方法
接下来,我们将为 Todos
类型添加一系列方法,以实现我们的功能需求。所有方法都定义在 todo.go
中。
3.2.1 添加任务 (Add
方法)
// file: todo.go (续)
// Add 方法向 Todos 列表中添加一个新的待办事项
func (t *Todos) Add(task string) {
todo := Todo{
Task: task,
Completed: false,
CreatedAt: time.Now(),
}
// 使用指针接收者,这样才能修改原始的 Todos 列表
*t = append(*t, todo)
}
代码解析:
func (t *Todos) ...
定义了一个指针接收者方法。我们必须使用指针接收者*Todos
,因为append
函数可能会返回一个新的底层数组,我们需要更新调用者作用域中的切片变量t
。如果使用值接收者(t Todos)
,方法内对t
的修改将是副本的修改,不会影响原始列表。
3.2.2 完成任务 (Complete
方法)
// file: todo.go (续)
// Complete 方法将指定索引的任务标记为完成
func (t *Todos) Complete(index int) error {
// 检查索引是否有效
if index <= 0 || index > len(*t) {
return fmt.Errorf("无效的任务ID: %d", index)
}
// 列表索引从0开始,而我们的用户输入从1开始
(*t)[index-1].Completed = true
return nil
}
代码解析:
- 该方法返回一个
error
类型,这是 Go 语言中处理可预见错误的惯用方式。如果用户提供了无效的索引,我们就返回一个描述性错误。 - 注意
(*t)[index-1]
的写法,因为t
是一个指针,我们需要先用*t
解引用得到切片本身,然后再进行索引访问。
3.2.3 删除任务 (Delete
方法)
// file: todo.go (续)
// Delete 方法从 Todos 列表中删除一个任务
func (t *Todos) Delete(index int) error {
if index <= 0 || index > len(*t) {
return fmt.Errorf("无效的任务ID: %d", index)
}
// 通过切片拼接的方式删除指定索引的元素
*t = append((*t)[:index-1], (*t)[index:]...)
return nil
}
代码解析:
- 删除操作利用了 Go 切片的拼接技巧。
(*t)[:index-1]
获取了待删除元素之前的所有元素,(*t)[index:]...
获取了待删除元素之后的所有元素。将这两部分拼接起来,就实现了删除效果。...
是必须的,用于将第二个切片的所有元素打散后附加到第一个切片。
3.2.4 打印任务列表 (Print
方法)
// file: todo.go (续)
// Print 方法将所有待办事项打印到控制台
func (t *Todos) Print() {
if len(*t) == 0 {
fmt.Println("没有待办事项,快去添加一个吧!")
return
}
fmt.Println("待办事项列表:")
for i, todo := range *t {
prefix := "[ ]" // 未完成任务的前缀
if todo.Completed {
prefix = "[✔]" // 已完成任务的前缀
}
// 用户看到的ID从1开始
fmt.Printf("%s %d: %s\n", prefix, i+1, todo.Task)
}
}
代码解析:
- 这个方法遍历
Todos
列表,并根据Completed
字段的值,使用不同的前缀[ ]
或[✔]
来可视化地展示任务状态,提升了用户体验。
四、数据持久化与文件交互
为了让我们的应用能够“记住”任务,我们需要实现将 Todos
列表保存到文件和从文件加载的功能。这些方法同样添加到 todo.go
中。
4.1 实现数据加载 (Load
方法)
// file: todo.go (续)
// Load 方法从一个 JSON 文件中加载待办事项
func (t *Todos) Load(filename string) error {
// 读取文件内容
data, err := os.ReadFile(filename)
if err != nil {
// 如果文件不存在,这是正常情况(首次运行),不作为错误处理
if os.IsNotExist(err) {
return nil
}
return err
}
// 如果文件内容为空,也直接返回,无需反序列化
if len(data) == 0 {
return nil
}
// 将 JSON 数据反序列化到 Todos 列表中
return json.Unmarshal(data, t)
}
代码解析:
os.ReadFile
用于一次性读取整个文件。os.IsNotExist(err)
是一个非常重要的检查。当应用第一次运行时,数据文件还不存在,os.ReadFile
会返回一个“文件未找到”的错误。我们应该将这种情况视为正常,而不是一个程序故障,因此直接return nil
。json.Unmarshal
是encoding/json
包的核心函数,它接收 JSON 数据的字节切片和目标数据结构的指针,然后将 JSON 解析并填充到指针指向的变量中。
4.2 实现数据保存 (Store
方法)
// file: todo.go (续)
// Store 方法将 Todos 列表以 JSON 格式保存到文件
func (t *Todos) Store(filename string) error {
// 将 Todos 列表序列化为 JSON 格式的字节切片
// 使用 MarshalIndent 进行格式化,增加可读性
data, err := json.MarshalIndent(t, "", " ")
if err != nil {
return err
}
// 将数据写入文件,os.WriteFile 会覆盖已存在的文件
// 0644 是文件权限,表示所有者可读写,其他用户只读
return os.WriteFile(filename, data, 0644)
}
代码解析:
json.MarshalIndent
相较于json.Marshal
,会生成带缩进的、格式化后的 JSON 字符串,这对于调试或直接查看数据文件非常友好。os.WriteFile
是一个便捷的函数,它负责打开文件、写入数据、然后关闭文件,并处理了相关的错误。
至此,我们的 todo.go
文件已经包含了所有核心逻辑。
五、构建命令行界面 (main.go
)
现在是时候编写程序的入口 main.go
了。它的职责是解析用户的命令和参数,并调用 todo.go
中已经准备好的方法。
5.1 main.go
整体逻辑
我们将使用 flag
包来处理命令行参数。由于原生的 flag
包对子命令(subcommand)的支持不直接,我们将通过检查 os.Args[1]
来模拟子命令的路由。
// file: main.go
package main
import (
"flag"
"fmt"
"os"
"strconv"
)
// 定义数据文件的路径
const todoFileName = ".todos.json"
func main() {
// 1. 初始化一个 Todos 列表
todos := &Todos{}
// 2. 从文件加载数据
if err := todos.Load(todoFileName); err != nil {
fmt.Fprintln(os.Stderr, "错误: 无法加载数据文件:", err)
os.Exit(1)
}
// 3. 检查命令行参数,确定用户意图
if len(os.Args) < 2 {
printUsage()
os.Exit(1)
}
// 4. 使用 switch 路由到不同的子命令处理逻辑
switch os.Args[1] {
case "add":
handleAdd(todos)
case "list":
todos.Print()
case "complete":
handleComplete(todos)
case "delete":
handleDelete(todos)
default:
fmt.Fprintln(os.Stderr, "错误: 未知的命令:", os.Args[1])
printUsage()
os.Exit(1)
}
// 5. 将更新后的数据保存回文件
if err := todos.Store(todoFileName); err != nil {
fmt.Fprintln(os.Stderr, "错误: 无法保存数据文件:", err)
os.Exit(1)
}
}
func printUsage() {
fmt.Println("用法: todo-cli <命令> [参数]")
fmt.Println("可用命令:")
fmt.Println(" add -task=\"任务内容\" - 添加新任务")
fmt.Println(" list - 列出所有任务")
fmt.Println(" complete -id=<任务ID> - 标记任务为完成")
fmt.Println(" delete -id=<任务ID> - 删除任务")
}
代码解析:
main
函数的逻辑非常清晰:加载数据 -> 解析命令 -> 执行操作 -> 保存数据。fmt.Fprintln(os.Stderr, ...)
用于将错误信息输出到标准错误流,这是 CLI 工具的最佳实践。os.Exit(1)
表示程序异常退出。- 我们为每个子命令创建了独立的
handle...
函数,这使得main
函数保持简洁。
5.2 子命令处理函数
现在我们来实现 handleAdd
,handleComplete
和 handleDelete
。
5.2.1 实现 add
命令处理
// file: main.go (续)
func handleAdd(todos *Todos) {
// 创建一个专门用于 add 命令的 flag set
addCmd := flag.NewFlagSet("add", flag.ExitOnError)
task := addCmd.String("task", "", "要添加的任务内容")
addCmd.Parse(os.Args[2:]) // 解析 add 命令之后的参数
if *task == "" {
fmt.Fprintln(os.Stderr, "错误: -task 参数不能为空")
printUsage()
os.Exit(1)
}
todos.Add(*task)
fmt.Printf("任务 \"%s\" 已添加。\n", *task)
}
5.2.2 实现 complete
命令处理
// file: main.go (续)
func handleComplete(todos *Todos) {
completeCmd := flag.NewFlagSet("complete", flag.ExitOnError)
idStr := completeCmd.String("id", "", "要完成的任务ID")
completeCmd.Parse(os.Args[2:])
if *idStr == "" {
fmt.Fprintln(os.Stderr, "错误: -id 参数不能为空")
printUsage()
os.Exit(1)
}
id, err := strconv.Atoi(*idStr)
if err != nil {
fmt.Fprintln(os.Stderr, "错误: -id 参数必须是一个整数")
os.Exit(1)
}
if err := todos.Complete(id); err != nil {
fmt.Fprintln(os.Stderr, "错误:", err)
os.Exit(1)
}
fmt.Printf("任务 %d 已标记为完成。\n", id)
}
5.2.3 实现 delete
命令处理
handleDelete
的实现与 handleComplete
极其相似,只是调用的核心方法不同。
// file: main.go (续)
func handleDelete(todos *Todos) {
deleteCmd := flag.NewFlagSet("delete", flag.ExitOnError)
idStr := deleteCmd.String("id", "", "要删除的任务ID")
deleteCmd.Parse(os.Args[2:])
if *idStr == "" {
fmt.Fprintln(os.Stderr, "错误: -id 参数不能为空")
printUsage()
os.Exit(1)
}
id, err := strconv.Atoi(*idStr)
if err != nil {
fmt.Fprintln(os.Stderr, "错误: -id 参数必须是一个整数")
os.Exit(1)
}
if err := todos.Delete(id); err != nil {
fmt.Fprintln(os.Stderr, "错误:", err)
os.Exit(1)
}
fmt.Printf("任务 %d 已删除。\n", id)
}
代码解析:
flag.NewFlagSet
是处理子命令参数的推荐方式。它为每个命令创建一个独立的解析环境,避免了不同命令间参数的冲突。addCmd.Parse(os.Args[2:])
是关键,它告诉 flag set 只解析从第三个参数开始的部分(os.Args[0]
是程序名,os.Args[1]
是命令名)。strconv.Atoi
用于将字符串形式的 ID 转换为整数。
六、整合与测试
现在,我们已经完成了所有代码的编写。todo-cli
目录下应该有两个 .go
文件:main.go
和 todo.go
。让我们来运行和验证它!
6.1 运行与验证
在 todo-cli
目录下打开你的终端,依次执行以下命令,并观察输出:
-
添加第一个任务
go run . add -task="完成Go语言阶段项目" # 输出: 任务 "完成Go语言阶段项目" 已添加。
-
添加第二个任务
go run . add -task="学习Go并发模型" # 输出: 任务 "学习Go并发模型" 已添加。
-
列出所有任务
go run . list # 输出: # 待办事项列表: # [ ] 1: 完成Go语言阶段项目 # [ ] 2: 学习Go并发模型
-
检查持久化文件 (可选)
cat .todos.json # 输出 (格式化的JSON): # [ # { # "task": "完成Go语言阶段项目", # "completed": false, # "created_at": "..." # }, # { # "task": "学习Go并发模型", # "completed": false, # "created_at": "..." # } # ]
-
完成第一个任务
go run . complete -id=1 # 输出: 任务 1 已标记为完成。
-
再次列出任务
go run . list # 输出: # 待办事项列表: # [✔] 1: 完成Go语言阶段项目 # [ ] 2: 学习Go并发模型
-
删除第二个任务
go run . delete -id=2 # 输出: 任务 2 已删除。
-
最终列表
go run . list # 输出: # 待办事项列表: # [✔] 1: 完成Go语言阶段项目
恭喜!你已经成功构建了一个功能完整的命令行工具!
七、总结
在今天的项目实战中,我们不仅仅是写了代码,更是将第二阶段学习的零散知识点串联成了一个有机的整体。通过这个 Todo List 应用,我们深入实践了:
- 面向对象思想: 虽然 Go 没有
class
,但我们通过为自定义类型Todos
绑定方法(Add
,Delete
等),实现了数据与行为的封装,这正是面向对象编程的核心思想之一。 - 分层与抽象: 我们将应用逻辑清晰地划分为两层:
main.go
负责用户交互(视图/控制器层),todo.go
负责数据模型和业务逻辑(模型/服务层),使得代码结构清晰,易于维护和扩展。 - 数据持久化: 我们掌握了使用
encoding/json
和os
包实现数据序列化与文件读写的标准流程,这是任何需要保存状态的应用都必备的技能。 - 健壮的命令行工具开发: 我们学会了使用
flag
包构建规范的命令行接口,并实践了良好的错误处理和用户提示,提升了程序的可用性。
这个项目是你 Go 语言学习道路上的一个重要里程碑。它证明了你已经具备了使用 Go 解决实际问题的能力。接下来,我们将带着这些坚实的基础,共同迈向 Go 语言最激动人心的领域——并发编程与 Web 开发!
更多推荐
所有评论(0)