一个本地保存的小程序,用户花时间种的花、调好的实验参数,最后可能都装在一份 JSON 里。
如果这份文件在覆盖写入时出了问题,下次启动读到的可能只有半段内容。此时如果捕获异常、返回默认状态,再让自动保存继续运行,原来的数据就更难找回了。
给微境沙盘做原生微信端时,我把保存拆成了两个轮换槽位。每次写其中一个,读取时同时检查两份,选择有效的新版本。下面结合 native/client/repository.ts 的实际实现,讲一下保存失败后怎么继续处理。
1. 新版本写另一个槽
以花园存档为例,同一个逻辑档案对应两个文件。
world-garden.0.json
world-garden.1.json
每份记录带有格式版本 version、档案编号 id、保存序号 revision、业务数据 data 和校验值 checksum。version 用于识别格式,revision 在每次成功保存后递增,用来判断哪份更新。
写入槽位按 revision % 2 选择。
| 保存过程 | 槽 0 | 槽 1 | 启动时选择 |
|---|---|---|---|
| 首次保存 | 空 | revision 1,有效 | revision 1 |
| 再保存一次 | revision 2,有效 | revision 1,有效 | revision 2 |
| 第三次写入失败 | revision 2,有效 | 新内容不完整 | revision 2 |
第三次写入只影响槽 1,槽 0 仍有上一次有效记录。恢复后可能少掉最后一次未成功保存的修改,但用户还有一个明确的接续点。
当前实现每次从两个槽里读取有效记录,再计算新序号,省去了额外维护“当前槽位指针”的步骤。
2. 写完以后,回读一次再确认
项目把文件访问封装成 FilePort,提供 read、write、list 和 remove。微信端适配到 wx.getFileSystemManager(),文件放在 wx.env.USER_DATA_PATH 下的独立目录。
同步写入失败会抛异常,调用方需要处理。这里还增加了一次写后回读,确认此时读到的内容与准备写入的文本一致。以下是生成并验证好记录之后的写入部分。 微信文件写入文档
const name = `${id}.${revision % 2}.json`;
this.room(name, text);
this.io.write(name, text);
if (this.io.read(name) !== text) {
throw Error('存档写入不完整,上一份已保留');
}
return record;
room() 在写入前检查文件和目录预算。页面只有在 save() 返回后才更新自己持有的 revision。失败时保留内存中的当前内容,并显示错误,用户可以重试。
回读确认的是当前可读内容。操作系统缓存、设备断电等更底层的持久化行为,需要另外验证。这里把可能发生的单次槽位写坏,转化为下次启动时可检查、可回退的状态。
3. 读取两个槽,先验内容,再比版本
读取时有三种结果需要区分。
- 两个文件都不存在,返回空结果,由首次使用流程创建档案。
- 至少一份有效,选择较大的
revision。 - 找到了文件,但两份都无法通过校验,抛出错误并保留原文件。
下面是项目的 load() 方法。parseRecord() 负责格式、校验值和业务数据校验,validId() 负责档案编号规则。
load(id: string): LoadedRecord | null {
if (!validId(id)) throw Error('存档编号无效');
const valid: SavedRecord[] = [];
let found = 0;
let bad = false;
for (const slot of [0, 1]) {
const raw = this.io.read(`${id}.${slot}.json`);
if (raw === null) continue;
found++;
try {
const record = parseRecord(raw);
if (record.id !== id) throw Error('存档归属无效');
valid.push(record);
} catch {
bad = true;
}
}
if (!found) return null;
if (!valid.length) throw Error('无法读取存档,原文件已保留');
valid.sort((a, b) => b.revision - a.revision);
if (
valid.length === 2 &&
valid[0].revision === valid[1].revision &&
JSON.stringify(valid[0]) !== JSON.stringify(valid[1])
) {
throw Error('存档版本冲突,原文件已保留');
}
return { record: valid[0], recovered: bad };
}
recovered 表示这次发现了坏槽,同时找到了可以继续使用的有效记录。两个槽具有相同序号、内容却不同,也会作为冲突交给上层处理。
校验分两层。外层检查字段、格式版本、编号和正整数序号,校验值针对 JSON.stringify(data) 计算。内层再由 parseDocument() 检查具体世界类型、模型版本与参数范围。
项目采用一个32位字符串校验值辅助检测意外损坏。它有碰撞可能,覆盖范围是业务数据,身份认证和防篡改需要单独设计。
4. 恢复之后,坏文件还要留一下
假设槽 1 写坏了,当前从槽 0 的 revision 2 恢复。下一次保存 revision 3,还会写槽 1。
正式覆盖前,项目先把坏槽原文另存为恢复副本。副本也要经过容量检查和回读确认,成功后再继续写。这样以后排查问题或尝试人工恢复时,还能拿到当时留下的内容。
两槽都坏时,普通自动保存会停止。用户可以主动导入经过校验的外部备份,程序先逐份保留原始内容和恢复清单,再尝试恢复。
项目还允许调用方传入预期版本。旧页面持有 revision 2,而存档已经变成 revision 3 时,会拒绝这次旧写入,让页面先重新载入。这个检查适用于当前串行文件访问流程,多进程或多写入者场景还需要相应的互斥协议。
5. 用“只写一半”验证恢复路径
文件接口抽出来以后,可以在测试里注入一个写入不完整的实现。内存文件系统用 Map 保存文本,把下一次写入故意截成一半,就能重复检查同一条失败路径。
下面从项目测试中整理了一段。Memory 实现 FilePort,partial 模式只保存传入文本的前半段。
const io = new Memory();
const repo = new NativeRepository(io);
const data = createDocument('garden', 0);
const first = repo.save('world-garden', data, 0);
data.name = '新的花园名字';
io.mode = 'partial';
assert.throws(
() => repo.save('world-garden', data, first.revision),
/写入不完整/,
);
io.mode = '';
const recovered = repo.load('world-garden')!;
assert.equal(recovered.record.revision, first.revision);
assert.equal(recovered.recovered, true);
repo.save('world-garden', data, first.revision);
assert.ok(io.list().some((name) => name.startsWith('recovery-')));
另外几项测试覆盖了两槽损坏后拒绝自动覆盖、外部备份无效、保存抛异常和空间不足。测试会比较失败前后的文件内容,并检查恢复副本是否保留了损坏原文。这些用例验证的是文件协议,真实设备的杀进程、断电和长期使用仍需单独测试。
存档预算也要算上两槽和恢复副本。微境沙盘当前把单份记录限制为4 MiB、整个目录限制为32 MiB,这两个数字是项目自行设置的预算。达到上限会提示用户导出和整理作品,保留已有文件。
文中的代码来自微境沙盘原生微信端。想看看沙盘本身,可以打开已上线网页版,体验种植观察、光学和其他交互实验。 微境沙盘