Skip to content
← 返回Go列表
开发实践

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
text
module showdocs.example/foundations

go 1.27.0

toolchain go1.27.2
task-cli/main.go(完整源码)
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 通用):

sh
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
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。

继续阅读