Go
JSON 持久化、安全写入与仓储
为本地 JSON 仓储设计版本、校验、临时文件替换、备份恢复和明确的单写者一致性契约,并通过可运行的 StudyTasks 示例验证边界、失败处理与工程取舍。
发布于 2026年7月23日
JSON 持久化、安全写入与仓储
JSON 文件适合单用户 CLI 的学习项目,但它不是事务数据库。可靠实现需要定义 schema 版本、损坏数据处理、写入中断、备份恢复和并发边界。所谓“安全写入”必须说明平台与文件系统假设,不能只写一个 os.WriteFile 就宣称原子。
一、学习目标
- 通过仓储接口隔离领域与文件格式
- 设计带版本的 JSON 快照
- 使用同目录临时文件、Sync 与替换流程
- 保留可验证备份并处理恢复
- 明确单写者与多进程竞争限制
二、仓储契约
应用服务定义所需能力:
type Repository interface {
Load(context.Context) ([]domain.Task, error)
Save(context.Context, []domain.Task) error
}
接口说明 Save 是覆盖完整快照、取消何时生效、失败后旧数据是否仍可读。实现返回任务副本,不泄露内部缓存。业务错误与文件错误分别包装,让 CLI 能区分损坏数据、权限不足和不存在的任务。
三、版本化快照
持久化根对象包含格式版本:
type snapshot struct {
Version int `json:"version"`
Tasks []taskDTO `json:"tasks"`
}
读取时先限制大小、严格解码、检查唯一 ID 和领域不变量,再转换为 Task。未知未来版本必须拒绝,不能“尽量读”后覆盖成旧格式。升级函数应从已知版本逐步迁移,并保留原文件备份。
四、写入协议
推荐流程:
- 在目标同目录创建权限受控的临时文件。
- 写入完整 JSON,并检查 Encoder、Flush、Sync、Close 错误。
- 可选地验证临时文件能够重新解码。
- 把旧目标轮换为备份,再替换为新文件。
- 尽力同步父目录,并清理残留临时文件。
rename 的覆盖语义和目录同步在不同系统、文件系统上有差异。本项目明确为单写者桌面 CLI,Windows 采用可恢复的备份轮换,不声称每个平台都具备数据库级原子提交。
五、锁与竞争
两个独立进程同时读取旧快照再各自保存,会发生最后写入覆盖,即使每次替换本身完整。标准库没有统一跨平台文件锁 API,因此本项目不伪造多进程安全。
CLI 在进程内用 mutex 串行 Save,并在文档中声明同一数据文件只允许一个写者。更高要求应迁移到数据库或引入经过验证的平台锁实现,而不是用“检查文件是否存在”当锁;进程崩溃会留下陈旧锁文件。
六、恢复与测试
加载主文件失败时不要静默改读备份并继续覆盖。先返回含路径和原因的错误,由显式 recover 操作验证备份、展示差异并得到用户确认后恢复。
测试通过注入文件操作或在关键步骤制造失败,覆盖:临时文件写入失败、Sync 失败、Close 失败、替换失败、损坏主文件、有效备份和未知版本。每次失败后验证旧快照仍可读,且不会留下被误认为正式文件的半成品。
七、从知识点到工程契约
本篇的示例最终要进入可维护的 Go 包,而不是停留在 main 中的一次性片段。先把目标写成调用者可以观察的契约:输入是否允许零值或 nil,返回值是否是快照,错误能否通过 errors.Is/As 分类,函数是否启动 goroutine、取得资源或修改共享状态。然后再选择结构体、接口、函数值或泛型;抽象形式必须服务于契约,而不是反过来决定需求。
可以用以下顺序把知识点落到工程代码:
- 在独立小函数中写出最小成功路径,并让
go test能直接调用。 - 加入一个与“直接覆盖正式文件,崩溃时留下截断 JSON”相关的失败样例,确认失败可观察且不会留下半完成状态。
- 把文件、网络、时间、环境或并发等外部因素改成显式依赖,测试使用临时目录、固定时钟或本地服务。
- 运行 gofmt、vet 和相关测试;涉及共享状态时追加
-race,涉及解析器时追加有上限的 fuzz。 - 最后再评估 API 是否需要导出。只在同一模块内部使用的能力保留在
internal,避免过早形成公共兼容负担。
审查代码时至少回答四个问题:谁拥有数据,谁允许修改,失败由谁处理,工作由谁停止。Go 的垃圾回收只解决不可达内存回收,不会替你关闭文件、取消请求、等待 goroutine 或恢复被覆盖的数据。只要其中一个问题没有答案,就先缩小函数或包的边界。
本篇最重要的能力是“通过仓储接口隔离领域与文件格式”。不要用注释替代可执行约束:能由类型表达的就交给类型,能由构造或验证表达的就返回错误,能由测试观察的就保存回归用例。示例扩展到 StudyTasks 时,还要保持领域包不导入命令行、文件和 HTTP 细节。
八、验证策略与复盘
验证分为静态、动态和故障三层。静态层检查格式、模块图和分析器;动态层用正常输入证明结果;故障层主动制造取消、权限、损坏数据、超时或竞态。一次测试通过只能说明执行过的路径符合断言,不能证明所有输入都安全,因此需要让每条关键契约至少对应一个成功用例和一个反例。
建议保存下面的复盘记录:
| 项目 | 需要记录的证据 |
|---|---|
| 版本 | go version、模块与 toolchain 指令 |
| 输入 | 最小正常值、零值、边界值和非法值 |
| 状态 | 调用前后数据、资源和 goroutine 的所有者 |
| 输出 | 返回值、错误链、stdout/stderr 与日志字段 |
| 失败 | 第一个失败点、清理动作和可恢复状态 |
| 工具 | 实际运行的 test、race、vet、benchmark 或 build 命令 |
完成验证后,用另一份干净临时目录重跑,不读取开发机的用户配置、缓存数据或真实网络。若测试只能按特定顺序成功,就说明状态隔离仍不完整。若为了让测试通过必须长时间 sleep,应改用 channel、WaitGroup、context 或可注入时钟表达确定的同步条件。
本篇可以用以下目标做验收:设计带版本的 JSON 快照;使用同目录临时文件、Sync 与替换流程;保留可验证备份并处理恢复。把它们逐项转成命令输出或断言,而不是写成“人工看起来正确”。当实现与预期不符时,先保存最小失败样例,再调整设计。
发布前再做一次反向审查:从调用方而不是实现内部出发,写出一个完全不知道具体类型和文件布局的使用示例;从故障点出发,假设进程在每个 I/O 之后被取消;从升级出发,假设下一版改变字段或默认值。若调用方必须知道未公开细节、故障会留下无法判断的状态,或升级只能覆盖旧数据,契约就还不完整。把这三个场景加入测试或文档,比继续增加抽象更有价值。
最后检查示例能否被复制到一份最小程序独立运行,所有导入、错误处理和清理是否完整。教学代码可以省略与主题无关的界面,却不能省略会改变正确性的 context、Close、边界检查或同步。对为了篇幅省略的部分要明确标注,不能让读者把伪代码误当成生产承诺。
九、StudyTasks 实践
实现 JSONRepository 与快照版本 1,完成同目录临时写入和备份轮换。使用临时目录冒烟运行 add/list/done/delete;再人为截断主文件,确认程序拒绝覆盖并能通过显式恢复流程还原。
完成本节后,不要只保存代码或 SQL。请同时保存执行命令、关键输出和失败案例;学习笔记真正有价值的部分,是能够说明输入、状态变化、输出以及失败后的恢复方式。
十、常见错误
- 直接覆盖正式文件,崩溃时留下截断 JSON
- 遇到未知版本仍按当前结构解码并保存
- 自动从备份恢复后继续写入,隐藏数据损坏
- 把 rename 等同于所有平台和文件系统上的完整事务
- 忽略两个独立进程的丢失更新问题
十一、练习与自测
- 为快照加入版本 0→1 的迁移并保留原始备份。
- 注入替换失败,验证旧文件内容保持不变。
- 设计显式 recover 命令的确认与退出码。
- 解释为什么互斥锁只能保护同一进程内调用。
自测时应在干净的临时目录或临时数据库中重新执行,而不是依赖上一节遗留的状态。如果结果与预期不同,先记录实际输出,再缩小问题范围。
十二、官方资料
版本行为与二手文章不一致时,以本系列固定版本的官方文档、命令输出和可重复测试结果为准。
上一篇:命令行、配置与结构化日志 下一篇:HTTP、JSON 与 API 客户端