小工具是学习 Go 的好方式
学 Go 不一定要从 Web 框架开始。很多时候,一个能解决自己问题的命令行工具更适合入门:代码量不大,不需要数据库,也不需要前端,但会用到文件、JSON、参数解析、错误处理和结构体建模。这些都是 Go 日常开发的基本能力。
这篇文章我们做一个简单任务清单工具。它支持添加任务、列出任务、完成任务,并把数据保存到本地 JSON 文件。功能很小,但足够真实。你会看到如何用 os.ReadFile 读文件,如何用 json.MarshalIndent 写漂亮 JSON,如何用 flag 解析命令行参数,以及如何把错误处理放在合适位置。
最终目标不是写出功能最多的 todo 工具,而是掌握一条可复用的路径:用结构体表达数据,用函数组织动作,用文件保存状态,用命令行参数连接用户输入。
设计数据结构
先定义任务:
type Task struct {
ID int64 `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
任务列表可以是切片:
type TaskList struct {
Tasks []Task `json:"tasks"`
}
为什么外面再包一层 TaskList,而不是直接把 []Task 写进文件?两种都可以。包一层的好处是以后容易扩展,比如加版本号、更新时间、配置等:
{
"tasks": [
{"id": 1, "title": "学习 Go 文件读写", "done": false}
]
}
结构体字段标签决定 JSON 字段名。Go 字段用大写开头是为了让 encoding/json 能访问,JSON 字段用小写更符合接口习惯。
读取 JSON 文件
读取文件最简单的函数是:
data, err := os.ReadFile("tasks.json")
如果文件不存在,第一次运行工具时不应该直接失败,而是返回空任务列表:
func loadTasks(path string) (TaskList, error) {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return TaskList{Tasks: []Task{}}, nil
}
return TaskList{}, fmt.Errorf("read tasks file: %w", err)
}
if len(data) == 0 {
return TaskList{Tasks: []Task{}}, nil
}
var list TaskList
if err := json.Unmarshal(data, &list); err != nil {
return TaskList{}, fmt.Errorf("parse tasks file: %w", err)
}
if list.Tasks == nil {
list.Tasks = []Task{}
}
return list, nil
}
这里有几个细节。errors.Is(err, os.ErrNotExist) 用来判断文件不存在;空文件返回空列表;JSON 解析失败要包装错误;如果 tasks 字段缺失,list.Tasks 可能是 nil,我们把它规范成空切片,方便后续编码为 []。
这就是 Go 文件处理的常见形态:先处理预期内的特殊错误,再把其他错误带上下文返回。
写入 JSON 文件
写文件使用 os.WriteFile:
func saveTasks(path string, list TaskList) error {
data, err := json.MarshalIndent(list, "", " ")
if err != nil {
return fmt.Errorf("encode tasks: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("write tasks file: %w", err)
}
return nil
}
json.MarshalIndent 会生成带缩进的 JSON,方便人工查看。0644 是文件权限,表示所有者可读写,其他人可读。入门阶段不必深入 Unix 权限,但要知道写文件时需要给权限。
如果数据很大,可以使用流式编码器:
file, err := os.Create(path)
if err != nil {
return err
}
defer file.Close()
encoder := json.NewEncoder(file)
encoder.SetIndent("", " ")
if err := encoder.Encode(list); err != nil {
return err
}
小工具里 os.WriteFile 足够清楚。
添加任务
添加任务需要生成新 ID。简单做法是找当前最大 ID,再加一:
func nextTaskID(tasks []Task) int64 {
var maxID int64
for _, task := range tasks {
if task.ID > maxID {
maxID = task.ID
}
}
return maxID + 1
}
添加函数:
func addTask(list TaskList, title string) (TaskList, Task, error) {
title = strings.TrimSpace(title)
if title == "" {
return list, Task{}, fmt.Errorf("task title is required")
}
task := Task{
ID: nextTaskID(list.Tasks),
Title: title,
Done: false,
}
list.Tasks = append(list.Tasks, task)
return list, task, nil
}
这里没有直接读写文件,只处理任务列表。这种拆分很重要。业务函数不关心数据从哪里来,也不关心最后如何保存。这样更容易测试。
完成任务:
func completeTask(list TaskList, id int64) (TaskList, error) {
for i := range list.Tasks {
if list.Tasks[i].ID == id {
list.Tasks[i].Done = true
return list, nil
}
}
return list, fmt.Errorf("task %d not found", id)
}
注意这里用 for i := range list.Tasks,通过索引修改原切片元素。如果写成:
for _, task := range list.Tasks {
task.Done = true
}
修改的是循环变量副本,不会改变切片里的元素。这是 Go 初学者常见坑。
使用 flag 解析命令行参数
Go 标准库的 flag 包适合简单命令行参数:
action := flag.String("action", "list", "action: list, add, done")
title := flag.String("title", "", "task title")
id := flag.Int64("id", 0, "task id")
file := flag.String("file", "tasks.json", "tasks file")
flag.Parse()
这些函数返回指针,所以使用时要写 *action、*title。
完整主流程:
func run() error {
action := flag.String("action", "list", "action: list, add, done")
title := flag.String("title", "", "task title")
id := flag.Int64("id", 0, "task id")
file := flag.String("file", "tasks.json", "tasks file")
flag.Parse()
list, err := loadTasks(*file)
if err != nil {
return err
}
switch *action {
case "list":
printTasks(list.Tasks)
return nil
case "add":
updated, task, err := addTask(list, *title)
if err != nil {
return err
}
if err := saveTasks(*file, updated); err != nil {
return err
}
fmt.Printf("added task #%d\n", task.ID)
return nil
case "done":
if *id <= 0 {
return fmt.Errorf("id is required")
}
updated, err := completeTask(list, *id)
if err != nil {
return err
}
return saveTasks(*file, updated)
default:
return fmt.Errorf("unknown action: %s", *action)
}
}
main 保持很薄:
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
这种结构值得养成习惯。run 返回错误,main 负责打印并设置退出码。以后工具变复杂,测试 run 或拆分子函数也更容易。
打印任务列表
输出函数:
func printTasks(tasks []Task) {
if len(tasks) == 0 {
fmt.Println("no tasks")
return
}
for _, task := range tasks {
status := " "
if task.Done {
status = "x"
}
fmt.Printf("[%s] #%d %s\n", status, task.ID, task.Title)
}
}
使用示例:
go run . -action add -title "学习 Go JSON"
go run . -action list
go run . -action done -id 1
输出可能是:
[ ] #1 学习 Go JSON
[x] #1 学习 Go JSON
这已经是一个真正可用的小工具。你可以把它编译成二进制:
go build -o todo .
./todo -action add -title "整理笔记"
小工具也要注意边界
这个程序仍然很简单,但已经有一些值得讨论的边界。
第一,文件写入不是原子操作。如果程序写到一半崩溃,文件可能损坏。更严谨的做法是先写临时文件,再重命名覆盖原文件。入门阶段可以先不做,但要知道真实工具会考虑。
第二,多个进程同时写同一个 JSON 文件会互相覆盖。单用户小工具问题不大,服务端程序就不能这样处理共享状态。
第三,命令行参数复杂后,标准库 flag 可能不够舒服。可以引入 Cobra、urfave/cli 等库。但入门阶段先用标准库,能帮助你理解底层流程。
第四,错误消息要对用户友好。parse tasks file 对开发者有用,但普通用户可能需要更直接的说明。工具面向谁,决定错误文本怎么写。
小结
通过一个任务清单工具,我们把 Go 的文件读写、JSON 编解码、命令行参数、结构体和错误处理串了起来。os.ReadFile、os.WriteFile、encoding/json、flag 都是标准库能力,不需要额外依赖。
学习 Go 最好的方式之一,就是写这种小而完整的程序。它比孤立语法练习更真实,又比大型项目更容易掌控。你会自然遇到数据建模、错误上下文、切片修改、参数校验和保存状态这些问题。
当你能独立写出一个命令行工具,再去写 HTTP 服务时会更顺手。因为 Web 服务本质上也在做类似事情:接收输入,解析数据,调用业务函数,保存或读取状态,然后返回结果。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。