Go 文件、JSON 和命令行工具:做一个真正能用的小程序

本文通过一个任务清单命令行工具讲解 Go 的文件读写、JSON 编解码、命令行参数和错误处理。

小工具是学习 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.ReadFileos.WriteFileencoding/jsonflag 都是标准库能力,不需要额外依赖。

学习 Go 最好的方式之一,就是写这种小而完整的程序。它比孤立语法练习更真实,又比大型项目更容易掌控。你会自然遇到数据建模、错误上下文、切片修改、参数校验和保存状态这些问题。

当你能独立写出一个命令行工具,再去写 HTTP 服务时会更顺手。因为 Web 服务本质上也在做类似事情:接收输入,解析数据,调用业务函数,保存或读取状态,然后返回结果。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页