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

小工具是学习 Go 的好方式 学 Go 不一定要从 Web 框架开始。很多时候,一个能解决自己问题的命令行工具更适合入门:代码量不大,不需要数据库,也不需要前端,但会用到文件、JSON、参数解析、错误处理和结构体建模。这些都是 Go 日常开发的基本能力。

小工具是学习 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 服务本质上也在做类似事情:接收输入,解析数据,调用业务函数,保存或读取状态,然后返回结果。

常见问题与解答

os.ReadFile 和 bufio.Scanner 怎么选?

文件小(几十MB以内)用 os.ReadFile 简单。文件大或需要逐行处理用 bufio.Scanner。超大文件用 bufio.Reader 按块读取。

JSON 字段要不要加 omitempty?

如果零值不应该出现在 JSON 中,加 omitempty。但要注意:int 的 0、string 的空串、bool 的 false 都会触发 omitempty。

怎么优雅处理文件不存在?

用 errors.Is(err, os.ErrNotExist) 判断,这是 Go 1.13+ 推荐的方式。

JSON 编码和解码技巧

// 忽略未知字段
dec := json.NewDecoder(r)
dec.DisallowUnknownFields()

// 流式解码
for dec.More() {
    var item Item
    dec.Decode(&item)
    process(item)
}

环境感知路径

func dataPath(env string) string {
    if env == "production" {
        return "/var/lib/myapp/data.json"
    }
    if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
        return filepath.Join(xdg, "myapp", "data.json")
    }
    home, _ := os.UserHomeDir()
    return filepath.Join(home, ".local", "share", "myapp", "data.json")
}

配置文件解析

type Config struct {
    Port    int            `json:"port"`
    Debug   bool           `json:"debug"`
    Timeout time.Duration  `json:"timeout"`
}

func loadConfig(path string) (Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return Config{}, err
    }
    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return Config{}, err
    }
    return cfg, nil
}

实践练习

完成以下练习以巩固所学知识:

  1. 阅读 Go 官方文档相关章节
  2. 编写一个完整的示例程序
  3. 为示例程序编写单元测试
  4. 使用 go test 和 go benchmark 验证实现
  5. 尝试优化内存分配和运行时间

推荐阅读

真实项目用例

在实际团队协作中,下面是几个推荐的工作流:

代码审查清单

  • 函数是否处理了所有 error 返回值
  • 并发代码是否有明确的退出路径和 WaitGroup
  • 用户输入是否经过校验和清洗
  • 敏感配置是否通过环境变量或加密存储注入
  • 测试是否覆盖了正常路径和至少一个错误路径
  • 日志是否包含足够的上下文信息但不泄露敏感数据
  • 接口设计是否符合最小接口原则

CI/CD 集成建议

  • 每次提交前运行 go fmt ./...
  • CI 中运行 go vet ./... 和 golangci-lint run
  • 单元测试使用 go test -race ./... 检测数据竞争
  • 关键路径的 benchmark 加入回归测试
  • 使用 go mod verify 确保依赖完整性

性能调优检查点

  • 使用 pprof 分析 CPU 和内存使用
  • 关注 benchmark 的 allocs/op,减少高频路径的堆分配
  • 检查数据库查询是否使用索引
  • 确认外部 HTTP 调用有合理的超时设置
  • 缓存热点数据,但注意缓存一致性和过期策略

面试高频考点

如果你正在准备 Go 相关面试,以下概念是高频考点:

  1. goroutine 和线程的区别
  2. channel 的缓冲和非缓冲用法
  3. defer 的执行顺序和与返回值的关系
  4. map 的并发不安全性和解决方案
  5. interface 的隐式实现和类型断言
  6. slice 的底层数组和 append 机制
  7. GC 的基本原理和调优参数
  8. context 的使用场景和超时控制
  9. error 的包装和 errors.Is/errors.As
  10. sync.Mutex vs sync.RWMutex vs atomic

掌握这些概念意味着你具备了独立开发 Go 服务的基础能力。继续在实际项目中磨练,你会越来越熟悉 Go 的工程风格和最佳实践。

常见问题(FAQ)

Q: 这个特性在实际项目中真的有用吗?
A: 是的。本文介绍的技术来源于真实后端开发场景。无论是标准库工具还是工程实践,在日常服务开发中都会反复用到。

Q: Go 版本会影响示例代码吗?
A: 本文代码主要针对 Go 1.20+ 编写。较新版本(如 1.22、1.23)的语法可能有微调,但核心概念保持不变。如有版本差异,文中会特别说明。

Q: 学习 Go 应该先学标准库还是直接上框架?
A: 强烈建议先学标准库。框架是对标准库的封装和扩展。只有理解了标准库的能力边界,才能正确选择和使用框架,也才能在框架出问题时快速定位。

Q: 代码里的错误处理为什么都是显式的 if err != nil?
A: 这是 Go 的设计哲学。显式错误处理让失败路径清晰可见,不会隐藏在任何 try-catch 之后。习惯了之后,你会发现这种写法实际上降低了排查错误的难度。

Q: 并发相关代码怎么测试?
A: 使用 Go 内置的 -race 标志检测数据竞争:go test -race ./...。结合 sync.WaitGroup 和 context.WithTimeout 编写有退出路径的并发测试,避免 goroutine 泄漏。

常见坑与避坑指南

  1. 不要信任用户输入:无论表单、JSON、Cookie 还是 HTTP Header,都当作不可信数据处理,做校验和转义。
  2. 资源要释放:文件、数据库连接、HTTP 响应体都要及时关闭。defer 是一个好习惯。
  3. 不要忽略错误:即使 defer file.Close() 可能返回错误,至少记录日志。完全忽略错误是 bug 的温床。
  4. 不要滥用 goroutine:每个 goroutine 都要有明确的退出路径。使用 sync.WaitGroup 和 context 管理生命周期。
  5. 不要硬编码配置:端口、路径、超时时间、密钥都应该从配置读取,让程序适应不同环境。
  6. 不要过早优化:先让代码正确和可读,再用 benchmark 和 profile 找到真正的热点。

延伸阅读与实践建议

读完本文后,建议完成以下实践:

  1. 把文中所有示例代码在自己的机器上跑一遍
  2. 给示例代码补充错误分支的测试用例
  3. 尝试基于本文内容构建一个小型完整项目
  4. 在 review 他人的 Go 代码时,检查本文提到的边界是否被覆盖
  5. 订阅 Go 官方博客,关注语言演进和最佳实践更新

参考资源

  • Go 官方网站:https://go.dev/
  • Go 标准库文档:https://pkg.go.dev/std
  • Go by Example:https://gobyexample.com/
  • Effective Go:https://go.dev/doc/effective_go
  • Go 常见问题:https://go.dev/doc/faq
  • Go 项目实战社区案例和开源项目源码

本文力求在讲解技术细节的同时兼顾工程实用性。Go 语言的设计简洁但不简单,掌握它需要持续的实践和反思。希望这篇文章能成为你学习道路上的一个可靠参考。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练