Go 文件任务工具:从需求到持久化与失败测试
前置:文件、JSON 与时间、包与测试。目标是独立完成单用户任务工具,解释为何非法操作不能改坏原文件。Go 1.27.2;Windows/Linux 的验证是历史记录,本轮源码修正后未运行;macOS 仅作平台说明,本项目不执行平台运行验证。
先设计输入与不变量
输入是命令和参数,输出是列表或错误,状态保存在 JSON 文件。add 接收非空标题,list 只读,done 接收已存在的正整数 ID;未识别的命令必须失败。每次启动都重新读取文件,只有文件不存在时才能从空列表开始。文件损坏、权限错误不能当成空列表。
实现前写下三条检查:成功操作后再次运行能读回状态;非法 ID 不改变文件;两个不同任务不能得到同一个 ID。这是单进程写入练习,不能保证多个终端同时修改时不丢数据。标准库 API 的解释见前置正文,这里只组织完整任务流程。
项目文件与运行
仓库中模块目录为 examples/go/foundations,命令入口 task-cli/main.go,测试 task-cli/main_test.go。已取得仓库时进入该目录即可运行;也可新建空目录,把下面 go.mod 和 main.go 分别保存为 go.mod、task-cli/main.go。模块只有标准库依赖。
go.mod
module showdocs.example/foundations
go 1.27.0
toolchain go1.27.2task-cli/main.go(完整源码)
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
)
type task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
func run(file string, args []string, out io.Writer) error {
if len(args) == 0 {
return errors.New("用法:add 标题 | list | done ID")
}
body, err := os.ReadFile(file)
items := []task{}
if err == nil {
if err = json.Unmarshal(body, &items); err != nil {
return fmt.Errorf("读取任务 JSON: %w", err)
}
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
switch args[0] {
case "list":
if len(args) != 1 {
return errors.New("list 不接受参数")
}
for _, item := range items {
state := "todo"
if item.Done {
state = "done"
}
if _, err := fmt.Fprintf(out, "%d [%s] %s\n", item.ID, state, item.Title); err != nil {
return err
}
}
return nil
case "add":
if len(args) != 2 || strings.TrimSpace(args[1]) == "" {
return errors.New("需要非空标题")
}
id := 1
for _, item := range items {
if item.ID >= id {
if item.ID == int(^uint(0)>>1) {
return errors.New("任务 ID 已耗尽")
}
id = item.ID + 1
}
}
items = append(items, task{ID: id, Title: strings.TrimSpace(args[1])})
case "done":
if len(args) != 2 {
return errors.New("需要任务 ID")
}
id, err := strconv.Atoi(args[1])
if err != nil || id < 1 {
return errors.New("任务 ID 必须为正整数")
}
found := false
for i := range items {
if items[i].ID == id {
items[i].Done = true
found = true
break
}
}
if !found {
return errors.New("任务不存在")
}
default:
return errors.New("未知命令")
}
body, err = json.MarshalIndent(items, "", " ")
if err != nil {
return err
}
temp, err := os.CreateTemp(filepath.Dir(file), ".tasks-*.json")
if err != nil {
return err
}
name := temp.Name()
defer os.Remove(name)
if _, err = temp.Write(append(body, '\n')); err != nil {
temp.Close()
return err
}
if err = temp.Close(); err != nil {
return err
}
// 同目录临时文件写完后替换;此工具不支持多个进程同时修改。
return os.Rename(name, file)
}
func main() {
file := os.Getenv("TASKS_FILE")
if file == "" {
file = "tasks.json"
}
if err := run(file, os.Args[1:], os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}在模块根目录运行(PowerShell/Bash/Zsh 通用):
go run ./task-cli add "学习 JSON"
go run ./task-cli list
go run ./task-cli done 1
go run ./task-cli list在新 tasks.json 下,两次 list 预期分别为 1 [todo] 学习 JSON 和 1 [done] 学习 JSON;add/done 成功无输出。若文件已有记录,以 list 返回的 ID 为准。示例只保证自己生成的文件;它检查 JSON 语法,不完整校验手工编辑数据中的重复 ID、空标题等业务约束。每条命令启动独立进程,第二次 list 仍显示 done 才证明数据写入文件。
用独立练习文件避免改动已有数据。PowerShell 设置 $env:TASKS_FILE = Join-Path $PWD 'exercise.json';Bash/Zsh 设置 export TASKS_FILE="$PWD/exercise.json",再运行上述命令。换路径是在选另一份数据,不会自动迁移旧文件。
读懂实现与测试
从 main → run 阅读:main 读取环境与参数、报告错误并设置退出码;run 使用 io.Writer 输出,测试可传内存缓冲而不用拦截终端。然后依次读 JSON、处理命令、生成新 JSON、临时写入与替换。list 不触发写入,非法 ID/空标题也在写入之前返回。
下面测试是完整文件,保存为 task-cli/main_test.go 后 go test ./...。它包含独立临时目录,验证保存、重读、状态更新及非法操作不改变旧文件;预期输出包含模块的 task-cli 包 ok。
task-cli/main_test.go
package main
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func TestCLIWorkflow(t *testing.T) {
file := filepath.Join(t.TempDir(), "tasks.json")
var out bytes.Buffer
for _, args := range [][]string{{"add", "学习 JSON"}, {"add", "练习测试"}, {"done", "1"}, {"list"}} {
if err := run(file, args, &out); err != nil {
t.Fatal(err)
}
}
if !strings.Contains(out.String(), "1 [done] 学习 JSON") || !strings.Contains(out.String(), "2 [todo] 练习测试") {
t.Fatal(out.String())
}
before, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
for _, args := range [][]string{{"add", " "}, {"done", "99"}, {"done", "bad"}, {"unknown"}} {
if run(file, args, &out) == nil {
t.Fatal(args)
}
}
after, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(before, after) {
t.Fatal("invalid command changed file")
}
}
func TestCorruptFileIsNotOverwritten(t *testing.T) {
file := filepath.Join(t.TempDir(), "tasks.json")
if err := os.WriteFile(file, []byte("broken"), 0600); err != nil {
t.Fatal(err)
}
var out bytes.Buffer
if run(file, []string{"add", "test"}, &out) == nil {
t.Fatal("expected parse error")
}
body, err := os.ReadFile(file)
if err != nil || string(body) != "broken" {
t.Fatal(string(body), err)
}
}
func TestExhaustedIDDoesNotOverwrite(t *testing.T) {
file := filepath.Join(t.TempDir(), "tasks.json")
before := []byte(fmt.Sprintf(`[{"id":%d,"title":"last","done":false}]`, int(^uint(0)>>1)))
if err := os.WriteFile(file, before, 0600); err != nil {
t.Fatal(err)
}
var out bytes.Buffer
if err := run(file, []string{"add", "next"}, &out); err == nil {
t.Fatal("expected exhausted ID error")
}
after, err := os.ReadFile(file)
if err != nil || !bytes.Equal(before, after) {
t.Fatal("exhausted ID changed file", err)
}
}常见排错:读取任务 JSON 表示磁盘文件格式损坏,先备份并检查,程序不应悄悄重置;任务不存在 要先 list 查 ID;permission denied/Access is denied 检查父目录权限、文件占用和实际 TASKS_FILE 路径。不要把真实数据当作坏 JSON 测试输入。
练习与自检
在新练习文件中添加两项,完成第二项,关闭终端后重新设置相同 TASKS_FILE 再 list。预期 ID 1 为 todo、ID 2 为 done。然后尝试 done 999 和空标题,检查原文件没有改变。扩展设计:如果加入删除,为什么不能用 len(items)+1 作为新 ID?
参考解法
顺序执行 add 两次、done 2、list;错误命令返回非零退出码,文件内容应保持。删除会留下 ID 空洞,长度加一可能复用仍存在的 ID;当前示例遍历最大 ID 再加一;若最大值已经是本机 int 上限,则返回“任务 ID 已耗尽”,不让加一溢出为负数或覆盖原文件。练习没有并发写入保证,两个进程同时读写仍可能丢更新,不能将此方案直接用于多人服务;下一项目用 SQLite 约束与事务处理。
学习顺序请使用本页底部的路线导航。核对依据:os 文件 API、encoding/json、time。
继续阅读
- 开发实践
- 知识专题
- 知识专题