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, runestrconv 的实战技巧
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】精通文件写入与目录管理:osfilepath包实战指南
32-【Go语言-Day 32】从零精通 Go JSON:MarshalUnmarshal 与 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 应用需要满足以下核心功能,完全通过命令行进行交互:

  1. 添加任务 (Add): 用户可以添加一个新的待办事项。
  2. 查看任务 (List): 用户可以查看所有待办事项,并能清晰地区分已完成和未完成的任务。
  3. 完成任务 (Complete): 用户可以通过任务编号将某个待办事项标记为已完成。
  4. 删除任务 (Delete): 用户可以通过任务编号删除某个不再需要的待办事项。
  5. 数据持久化 (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,其内部结构如下:

数据存储
项目管理
代码文件
.todos.json (持久化数据文件)
go.mod (Go 模块文件)
main.go (程序入口, CLI逻辑)
todo.go (核心模型与方法)
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.Unmarshalencoding/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 子命令处理函数

现在我们来实现 handleAddhandleCompletehandleDelete

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.gotodo.go。让我们来运行和验证它!

6.1 运行与验证

todo-cli 目录下打开你的终端,依次执行以下命令,并观察输出:

  1. 添加第一个任务

    go run . add -task="完成Go语言阶段项目"
    # 输出: 任务 "完成Go语言阶段项目" 已添加。
    
  2. 添加第二个任务

    go run . add -task="学习Go并发模型"
    # 输出: 任务 "学习Go并发模型" 已添加。
    
  3. 列出所有任务

    go run . list
    # 输出:
    # 待办事项列表:
    # [ ] 1: 完成Go语言阶段项目
    # [ ] 2: 学习Go并发模型
    
  4. 检查持久化文件 (可选)

    cat .todos.json
    # 输出 (格式化的JSON):
    # [
    #   {
    #     "task": "完成Go语言阶段项目",
    #     "completed": false,
    #     "created_at": "..."
    #   },
    #   {
    #     "task": "学习Go并发模型",
    #     "completed": false,
    #     "created_at": "..."
    #   }
    # ]
    
  5. 完成第一个任务

    go run . complete -id=1
    # 输出: 任务 1 已标记为完成。
    
  6. 再次列出任务

    go run . list
    # 输出:
    # 待办事项列表:
    # [✔] 1: 完成Go语言阶段项目
    # [ ] 2: 学习Go并发模型
    
  7. 删除第二个任务

    go run . delete -id=2
    # 输出: 任务 2 已删除。
    
  8. 最终列表

    go run . list
    # 输出:
    # 待办事项列表:
    # [✔] 1: 完成Go语言阶段项目
    

恭喜!你已经成功构建了一个功能完整的命令行工具!

七、总结

在今天的项目实战中,我们不仅仅是写了代码,更是将第二阶段学习的零散知识点串联成了一个有机的整体。通过这个 Todo List 应用,我们深入实践了:

  1. 面向对象思想: 虽然 Go 没有 class,但我们通过为自定义类型 Todos 绑定方法(Add, Delete 等),实现了数据与行为的封装,这正是面向对象编程的核心思想之一。
  2. 分层与抽象: 我们将应用逻辑清晰地划分为两层:main.go 负责用户交互(视图/控制器层),todo.go 负责数据模型和业务逻辑(模型/服务层),使得代码结构清晰,易于维护和扩展。
  3. 数据持久化: 我们掌握了使用 encoding/jsonos 包实现数据序列化与文件读写的标准流程,这是任何需要保存状态的应用都必备的技能。
  4. 健壮的命令行工具开发: 我们学会了使用 flag 包构建规范的命令行接口,并实践了良好的错误处理和用户提示,提升了程序的可用性。

这个项目是你 Go 语言学习道路上的一个重要里程碑。它证明了你已经具备了使用 Go 解决实际问题的能力。接下来,我们将带着这些坚实的基础,共同迈向 Go 语言最激动人心的领域——并发编程与 Web 开发!


Logo

惟楚有才,于斯为盛。欢迎来到长沙!!! 茶颜悦色、臭豆腐、CSDN和你一个都不能少~

更多推荐