浏览知识库目录

Go

JSON 持久化、安全写入与仓储

为本地 JSON 仓储设计版本、校验、临时文件替换、备份恢复和明确的单写者一致性契约,并通过可运行的 StudyTasks 示例验证边界、失败处理与工程取舍。

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。未知未来版本必须拒绝,不能“尽量读”后覆盖成旧格式。升级函数应从已知版本逐步迁移,并保留原文件备份。


四、写入协议

推荐流程:

  1. 在目标同目录创建权限受控的临时文件。
  2. 写入完整 JSON,并检查 Encoder、Flush、Sync、Close 错误。
  3. 可选地验证临时文件能够重新解码。
  4. 把旧目标轮换为备份,再替换为新文件。
  5. 尽力同步父目录,并清理残留临时文件。

rename 的覆盖语义和目录同步在不同系统、文件系统上有差异。本项目明确为单写者桌面 CLI,Windows 采用可恢复的备份轮换,不声称每个平台都具备数据库级原子提交。


五、锁与竞争

两个独立进程同时读取旧快照再各自保存,会发生最后写入覆盖,即使每次替换本身完整。标准库没有统一跨平台文件锁 API,因此本项目不伪造多进程安全。

CLI 在进程内用 mutex 串行 Save,并在文档中声明同一数据文件只允许一个写者。更高要求应迁移到数据库或引入经过验证的平台锁实现,而不是用“检查文件是否存在”当锁;进程崩溃会留下陈旧锁文件。


六、恢复与测试

加载主文件失败时不要静默改读备份并继续覆盖。先返回含路径和原因的错误,由显式 recover 操作验证备份、展示差异并得到用户确认后恢复。

测试通过注入文件操作或在关键步骤制造失败,覆盖:临时文件写入失败、Sync 失败、Close 失败、替换失败、损坏主文件、有效备份和未知版本。每次失败后验证旧快照仍可读,且不会留下被误认为正式文件的半成品。


七、从知识点到工程契约

本篇的示例最终要进入可维护的 Go 包,而不是停留在 main 中的一次性片段。先把目标写成调用者可以观察的契约:输入是否允许零值或 nil,返回值是否是快照,错误能否通过 errors.Is/As 分类,函数是否启动 goroutine、取得资源或修改共享状态。然后再选择结构体、接口、函数值或泛型;抽象形式必须服务于契约,而不是反过来决定需求。

可以用以下顺序把知识点落到工程代码:

  1. 在独立小函数中写出最小成功路径,并让 go test 能直接调用。
  2. 加入一个与“直接覆盖正式文件,崩溃时留下截断 JSON”相关的失败样例,确认失败可观察且不会留下半完成状态。
  3. 把文件、网络、时间、环境或并发等外部因素改成显式依赖,测试使用临时目录、固定时钟或本地服务。
  4. 运行 gofmt、vet 和相关测试;涉及共享状态时追加 -race,涉及解析器时追加有上限的 fuzz。
  5. 最后再评估 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 等同于所有平台和文件系统上的完整事务
  • 忽略两个独立进程的丢失更新问题

十一、练习与自测

  1. 为快照加入版本 0→1 的迁移并保留原始备份。
  2. 注入替换失败,验证旧文件内容保持不变。
  3. 设计显式 recover 命令的确认与退出码。
  4. 解释为什么互斥锁只能保护同一进程内调用。

自测时应在干净的临时目录或临时数据库中重新执行,而不是依赖上一节遗留的状态。如果结果与预期不同,先记录实际输出,再缩小问题范围。


十二、官方资料

版本行为与二手文章不一致时,以本系列固定版本的官方文档、命令输出和可重复测试结果为准。

上一篇:命令行、配置与结构化日志 下一篇:HTTP、JSON 与 API 客户端