小工具是学习 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
}
实践练习
完成以下练习以巩固所学知识:
- 阅读 Go 官方文档相关章节
- 编写一个完整的示例程序
- 为示例程序编写单元测试
- 使用
go test和go benchmark验证实现 - 尝试优化内存分配和运行时间
推荐阅读
- Go 官方博客: https://go.dev/blog/
- Effective Go: https://go.dev/doc/effective_go
- Go by Example: https://gobyexample.com/
- Go 标准库文档: https://pkg.go.dev/std
真实项目用例
在实际团队协作中,下面是几个推荐的工作流:
代码审查清单
- 函数是否处理了所有 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 相关面试,以下概念是高频考点:
- goroutine 和线程的区别
- channel 的缓冲和非缓冲用法
- defer 的执行顺序和与返回值的关系
- map 的并发不安全性和解决方案
- interface 的隐式实现和类型断言
- slice 的底层数组和 append 机制
- GC 的基本原理和调优参数
- context 的使用场景和超时控制
- error 的包装和 errors.Is/errors.As
- 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 泄漏。
常见坑与避坑指南
- 不要信任用户输入:无论表单、JSON、Cookie 还是 HTTP Header,都当作不可信数据处理,做校验和转义。
- 资源要释放:文件、数据库连接、HTTP 响应体都要及时关闭。
defer是一个好习惯。 - 不要忽略错误:即使
defer file.Close()可能返回错误,至少记录日志。完全忽略错误是 bug 的温床。 - 不要滥用 goroutine:每个 goroutine 都要有明确的退出路径。使用
sync.WaitGroup和context管理生命周期。 - 不要硬编码配置:端口、路径、超时时间、密钥都应该从配置读取,让程序适应不同环境。
- 不要过早优化:先让代码正确和可读,再用 benchmark 和 profile 找到真正的热点。
延伸阅读与实践建议
读完本文后,建议完成以下实践:
- 把文中所有示例代码在自己的机器上跑一遍
- 给示例代码补充错误分支的测试用例
- 尝试基于本文内容构建一个小型完整项目
- 在 review 他人的 Go 代码时,检查本文提到的边界是否被覆盖
- 订阅 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 语言的设计简洁但不简单,掌握它需要持续的实践和反思。希望这篇文章能成为你学习道路上的一个可靠参考。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。