用户目录、配置写入与本地数据可靠性
以可运行文件仓库理解写队列、版本冲突、文件替换、迁移和加密边界。
本页内容
本地数据先回答“谁拥有它”#
网页应用常把数据交给后端,Electron 让你第一次必须决定文件放在哪、谁能改、什么时候算保存成功。用户设置适合小型 JSON;可查询的业务记录可能适合 SQLite;大型附件通常单独存文件并由元数据引用。它们的共同点是不能写到应用安装目录,也不能依赖启动命令的当前目录。
本章的程度是能够实现单实例、小配置文件的可靠保存,并知道何时需要数据库和更强的恢复机制。前置是 Promise、文件路径与主进程职责;不要求掌握数据库内核。两个完整实验都使用 Node 标准库,可以离线运行;Electron 的目录接入另行说明。我们验证的是临时目录中的文件语义,没有把实验文件当作用户真实配置。
app.getPath("userData") 返回 Electron 为当前应用选择的用户数据目录。它不是“所有用户共享目录”,也不是安装包资源目录。默认位置与应用名称及操作系统有关,所以示例不硬编码 Windows 用户名或 macOS 路径。主进程在 app.whenReady 后取得它,再建立应用自己的数据子目录。改应用名称、产品标识或主动改路径,可能使旧数据看起来消失,迁移需要独立设计。app 目录 API
renderer 不应接收一个绝对文件路径再请求“写进去”。界面应该发送“把主题改成深色”或者“保存这条记录”,由 main 决定固定存储位置、校验内容与执行写入。这样系统权限留在主进程,用户输入不会变成任意路径。文件选择功能可以由主进程创建受控句柄或业务标识,再限制用途,不把路径访问能力普遍暴露给页面。
三个容易混淆的保证#
写队列解决同一进程内的操作顺序。两次保存同时开始时,如果各自先读旧值再写,新值可能被慢操作覆盖;即使每次文件写入都完整,最后的业务结果仍然错误。队列让读取当前状态、检查版本、生成下一个状态和提交形成一个顺序。把 await 加在调用者函数里不等于全局排队,另一个窗口仍可能同时发起调用。
临时文件加 rename 解决目标文件被直接截断后只写了一半的问题。完整新内容先写到同目录的唯一临时文件,关闭后替换目标;读取者通常看到旧文件或新文件,而不是半段 JSON。但这需要文件系统支持相应重命名语义,同目录很重要,跨文件系统移动不是同一种操作。Windows 文件占用、权限、杀毒软件和网络盘都可能让替换失败。
崩溃持久性又是另一层。writeFile 返回不代表断电后必然落到存储介质;对文件句柄 sync 可以请求刷新该文件,但重命名涉及目录元数据,严谨的掉电保证还与目录刷新、操作系统和文件系统有关。本章不声称跨平台电源故障下绝不丢最后一次提交。应明确允许的损失、是否保留上个版本,以及能否从日志或备份恢复。
还有一个业务保证叫并发冲突检测。两个窗口都从 revision=7 编辑,即便写队列按顺序执行,第二个窗口的旧快照也不应无声覆盖第一个窗口刚写的 revision=8。请求携带 expectedRevision,主进程只接受匹配当前版本的更新;冲突返回给 UI 重新读取或让用户合并。队列决定先后,版本比较决定旧编辑是否仍有资格提交。
实验一:有写队列和失败边界的配置仓库#
创建 config-lab 目录,保存 settings-store.cjs 和 settings-check.cjs,使用 Node 22.22.0 执行 node settings-check.cjs,无需安装包。这个仓库只接受主题配置,不接收任意路径;路径由创建仓库的主进程提供。schemaVersion 表示文件格式,revision 表示内容更新次数,二者不能互相替代。
const fs = require("node:fs/promises");
const path = require("node:path");
const { randomUUID } = require("node:crypto");
function validate(value) {
if (!value || value.schemaVersion !== 2 ||
!Number.isSafeInteger(value.revision) || value.revision < 0 ||
!["light", "dark"].includes(value.theme)) {
throw new Error("INVALID_CONFIG");
}
return { schemaVersion: 2, revision: value.revision, theme: value.theme };
}
function createStore(file, { beforeCommit = async () => {} } = {}) {
let state;
let tail = (async () => {
await fs.mkdir(path.dirname(file), { recursive: true });
try {
state = validate(JSON.parse(await fs.readFile(file, "utf8")));
} catch (error) {
if (error.code !== "ENOENT") throw error;
state = { schemaVersion: 2, revision: 0, theme: "light" };
}
})();
function queue(operation) {
const result = tail.then(operation);
// 后续任务仍有机会执行,但当前调用者仍收到自己的失败。
tail = result.catch(() => {});
return result;
}
return {
read: () => queue(() => {
if (!state) throw new Error("STORE_NOT_READY");
return { ...state };
}),
update(expectedRevision, theme) {
// 立即捕获原始标量参数,后续不读取调用者可变对象。
return queue(async () => {
if (!state) throw new Error("STORE_NOT_READY");
if (expectedRevision !== state.revision) throw new Error("REVISION_CONFLICT");
const next = validate({
schemaVersion: 2, revision: state.revision + 1, theme
});
const temp = file + "." + randomUUID() + ".tmp";
let created = false;
try {
const handle = await fs.open(temp, "wx", 0o600);
created = true;
try {
await handle.writeFile(JSON.stringify(next) + "\n", "utf8");
await handle.sync(); // 刷新文件内容;不夸大为完整断电持久性。
} finally {
await handle.close();
}
await beforeCommit(); // 仅供本地故障实验注入,不暴露给 IPC。
await fs.rename(temp, file);
state = next; // 文件替换成功后,才发布新的内存状态。
return { ...state };
} finally {
if (created) await fs.rm(temp, { force: true });
}
});
}
};
}
module.exports = { createStore };
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const { createStore } = require("./settings-store.cjs");
(async () => {
const tempBase = await fs.realpath(os.tmpdir());
const tempPrefix = "settings-lab-";
const root = await fs.mkdtemp(path.join(tempBase, tempPrefix));
try {
const file = path.join(root, "settings.json");
const store = createStore(file);
assert.deepEqual(await store.read(),
{ schemaVersion: 2, revision: 0, theme: "light" });
const results = await Promise.allSettled([
store.update(0, "dark"), store.update(0, "light")
]);
assert.equal(results[0].status, "fulfilled");
assert.equal(results[1].status, "rejected");
assert.match(results[1].reason.message, /REVISION_CONFLICT/);
const before = await fs.readFile(file, "utf8");
const broken = createStore(file, {
beforeCommit: async () => { throw new Error("INJECTED_FAILURE"); }
});
await assert.rejects(() => broken.update(1, "light"), /INJECTED_FAILURE/);
assert.equal(await fs.readFile(file, "utf8"), before);
assert.equal((await broken.read()).theme, "dark");
assert.deepEqual((await fs.readdir(root)).sort(), ["settings.json"]);
const reopened = createStore(file);
assert.equal((await reopened.read()).revision, 1);
console.log("settings checks passed");
} finally {
const resolved = await fs.realpath(root);
if (path.dirname(resolved) !== tempBase || !path.basename(resolved).startsWith(tempPrefix)) {
throw new Error("UNSAFE_TEMP_CLEANUP");
}
await fs.rm(resolved, { recursive: true, force: true });
}
})().catch(error => { console.error(error); process.exitCode = 1; });
正常输出 settings checks passed。实验同时发出两个基于零版本的更新,第一个成功,第二个收到冲突;随后在 rename 前主动制造故障,磁盘和内存都必须维持上次成功状态。重新创建仓库对象模拟重新读取磁盘,不等于真实进程突然断电,但足以检查数据没有只存在于旧内存对象里。
fs.open 的 wx 表示必须新建文件,已有同名路径会失败;随机标识降低碰撞可能性,排他创建才是拒绝覆盖的规则。临时文件与目标同目录,所以替换不会跨卷。finally 清理的是本次成功创建的临时文件;rename 成功后临时路径已不存在,force:true 允许这种预期情况。目标文件从不在失败清理中删除。
read 返回浅拷贝之所以足够,是 schema 只有标量。添加嵌套配置后,应使用受支持的数据拷贝方式或不可变结构,不能让外部调用者拿到 state 内部引用。validate 明确生成允许字段,避免把用户提交的未知属性原样落盘。例子没有把 JSON.parse 错误视作“新用户默认配置”,因为那会悄悄掩盖损坏文件,下一次保存可能覆盖事故证据。
初始化失败不能被队列吞掉#
这里要特别区分“单次更新失败后队列继续工作”和“初始配置加载失败后允许新建默认状态”。前者常常合理:某次主题值错误不应该让整个仓库永久瘫痪;后者危险:磁盘文件格式错误时,仓库没有可信初始状态。本例 update 检查 state,阻止在初始化失败后写入;read 同样检查 state,因此首次读取暴露原始加载错误后,后续读取仍会明确抛出 STORE_NOT_READY,不会成功返回空对象。
对权限拒绝、磁盘满、文件被占用,应该保留原错误的技术分类,同时向 UI 返回稳定错误码。UI 可以提示重试或导出未保存内容,日志记录操作编号、文件角色和错误码,不打印所有配置值。不要遇到任何错误就先删 settings.json 再重试:这会把短暂 I/O 问题变成永久数据损失。
同一进程队列不处理两个应用实例的竞争。桌面应用通常可用 app.requestSingleInstanceLock 限制主实例,但多用户、辅助进程或者外部编辑器仍可能写文件。要求多写者时,应使用有事务与锁语义的存储,或者设计操作系统锁与恢复策略。本例明确限定单个仓库所有者,不能把它包装成通用多进程数据库。
在 Electron 主进程中接入固定目录#
下面是可独立作为 Electron 入口运行的 main.cjs,它没有创建窗口,只展示目录选择与读取。将 settings-store.cjs 放在同目录,使用前章固定 Electron 依赖,执行 electron main.cjs;需要 Electron 可启动的桌面环境,本文没有运行。首次没有配置时输出默认状态,随后退出;不会自动写用户文件。
const { app } = require("electron");
const path = require("node:path");
const { createStore } = require("./settings-store.cjs");
const ownsLock = app.requestSingleInstanceLock();
if (!ownsLock) {
app.quit();
} else {
app.whenReady().then(async () => {
const file = path.join(app.getPath("userData"), "application-data", "settings.json");
const store = createStore(file);
const value = await store.read();
console.log({ dataDirectory: path.dirname(file), config: value });
app.quit();
}).catch(error => {
console.error({ code: error.code ?? "CONFIG_LOAD_FAILED", message: error.message });
app.exit(1);
});
}
application-data 子目录把自己管理的数据与 Chromium 会话缓存区分开。缓存损坏时重建缓存,不应该顺便删除业务数据库。备份时同样需要明确哪些目录可再生、哪些是用户唯一副本。控制磁盘占用应依据数据类别和保留规则,而不是对整个 userData 做不加区分的递归清理。
若将配置能力接入窗口,main 应先校验发送者,再校验 expectedRevision 与 theme,然后调用 store.update。来源校验和字段校验不是仓库模块的职责替代品;仓库保证自身数据合同,IPC 层保证谁能够发起操作。保持分层后,Node 实验可以验证文件与队列,桌面实验专门验证窗口和权限。
实验二:升级配置格式,不把旧文件直接改坏#
假设第一版用 darkMode 布尔值,第二版改成 theme 字符串。迁移不是 JSON 字段随手替换:旧版本号是否可信,缺失值怎么办,将来版本能否读取,以及失败时是否保留原文件,都要定义。本实验只输出迁移计划,不写入用户目录。保存以下两个文件,执行 node migration-check.cjs。
function migrateConfig(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error("INVALID_CONFIG");
}
if (value.schemaVersion === 1) {
if (typeof value.darkMode !== "boolean") throw new Error("INVALID_V1");
return { schemaVersion: 2, revision: 0,
theme: value.darkMode ? "dark" : "light" };
}
if (value.schemaVersion === 2) {
if (!Number.isSafeInteger(value.revision) || value.revision < 0 ||
!["light", "dark"].includes(value.theme)) throw new Error("INVALID_V2");
return { schemaVersion: 2, revision: value.revision, theme: value.theme };
}
throw new Error("UNSUPPORTED_SCHEMA");
}
module.exports = { migrateConfig };
const assert = require("node:assert/strict");
const { migrateConfig } = require("./config-migration.cjs");
const old = { schemaVersion: 1, darkMode: true };
const snapshot = JSON.stringify(old);
const current = migrateConfig(old);
assert.deepEqual(current, { schemaVersion: 2, revision: 0, theme: "dark" });
assert.equal(JSON.stringify(old), snapshot);
assert.deepEqual(migrateConfig(current), current);
assert.throws(() => migrateConfig({ schemaVersion: 1, darkMode: "true" }), /INVALID_V1/);
assert.throws(() => migrateConfig({ schemaVersion: 99 }), /UNSUPPORTED_SCHEMA/);
console.log("migration checks passed");
预期输出 migration checks passed。迁移函数不修改传入对象,因此失败不会留下半个新结构;已是第二版时返回等价结果,重复执行不会不断增加 revision。未知的更高版本明确拒绝,防止旧应用把新文件按旧合同重写。schemaVersion 不是应用版本号,应用修复样式时不需要无意义地迁移数据。
真正提交迁移时,先保存受保护的旧版本副本或可恢复快照,在独立目标验证新结构,再按清晰提交点切换。不要同时用新旧应用长期写同一个 JSON 文件。用户选择降级应用时,新文件不一定向后兼容,应该提示需要恢复副本或使用兼容版本,不能让旧代码凭字段缺失推断“全新安装”。
SQLite 何时更合适,原生依赖为何特殊#
配置只有几个字段时,整个文件重写容易理解;记录增多、需要索引、跨表约束、事务和并发查询时,SQLite 更合适。把几万条笔记塞进一个 JSON 数组,每次编辑都序列化并替换整文件,会增加延迟与故障影响面。数据库提供更清楚的查询和事务边界,但仍需要 schema 迁移、备份、完整性检查与应用级冲突处理。
不少 SQLite 驱动包含原生二进制模块。系统 Node 能加载,不代表 Electron 内置 Node 能加载;运行时 ABI、平台和 CPU 架构都必须匹配。使用 Electron 目标重新构建或安装对应预构建产物,是解决兼容问题的方向,不是随机删除锁文件重装。打包后原生文件还可能需要从 asar 解包,具体由驱动与打包工具决定。原生模块
同步数据库调用可能阻塞主进程,异步封装也不保证内部没有耗时 CPU 工作。先测量查询规模与执行时长,再决定是否放到专门工作线程或 utility process。把一个连接随意跨线程共享通常不成立;连接由对应执行环境拥有,其他进程通过受限消息提交业务操作。下一章会详细讨论任务隔离。
本地、私有与加密是不同概念#
文件在 userData 只表示存放位置,不表示密文。系统用户、备份工具、恶意软件或有足够权限的其他程序可能读取它。零六〇〇权限也不等于加密,Windows 上还有不同的访问控制机制。应先区分普通偏好、业务内容和长期凭据,不要因为“没有上传服务器”就承诺数据对本机其他主体不可见。
Electron safeStorage 可以利用平台提供的机制加密小型字符串,但平台保障并不完全一致。使用前检查加密是否可用,并理解 Linux 上可用后端与 basic_text 等退化情况;不能把有返回值当作所有机器上都有同等保护。加密还需要考虑备份恢复、用户切换和密钥不可用时如何处理,不应失败后悄悄把令牌写成明文。safeStorage
日志和错误消息也是持久化通道。即使凭据文件加密,调试时打印整个请求对象仍可能把令牌写进日志。建议保存操作编号、字节数、schema 版本和错误码,不保存正文与密钥;用户主动导出诊断时先明确范围。数据删除也包括备份、临时文件和历史日志,不只删除当前 settings.json。
保存按钮与磁盘提交之间,还隔着产品语义#
用户点击保存后,界面可以立即显示正在保存,但应等待仓库 Promise 成功才显示已保存。若你为了流畅先更新界面,再发生磁盘错误,就要明确保留未保存草稿并提供重试,而不是把旧数据重新覆盖到输入框。乐观界面状态和持久化提交是两个状态,桌面应用没有远程服务器也同样需要区分。
自动保存还要解决频率问题。每次键入都提交完整配置,会制造大量写入与版本变化;可以在界面层合并短时间连续编辑,但退出前必须等待最后一次已接受保存完成。防抖不是可靠队列,它只减少发起次数。窗口关闭时若还有未提交编辑,应依业务规则保存、提示或放弃,并让用户知道结果。
这里的 expectedRevision 使冲突可见,但它不会自动合并。两个窗口分别修改不同字段时,可以重新读取当前版本后只重放自己的字段补丁;两边修改同一字段时,可能需要用户选择。若直接将旧窗口完整对象重新提交,只会把冲突检查变成无限重试,最终仍可能覆盖别人修改。版本错误应触发重新理解当前状态,而不是机械重试。
重命名前后的故障,需要不同恢复动作#
临时文件还未完成写入时退出,旧目标应继续可用,残留临时文件可在下次启动时按明确规则识别。rename 已完成但尚未向 UI 返回时退出,用户可能不确定保存是否成功;重新启动后读取 revision 与内容,才能确认提交结果。不能因为调用者没收到成功响应,就认定磁盘上一定没有写入。
临时文件名包含随机标识有助于避免碰撞,但自动清理不能只凭扩展名就删除整个目录里所有 .tmp。它们可能属于正在运行的任务或其他组件。真实应用可使用独立暂存目录、任务记录和过期策略,先验证文件归属与任务状态,再回收。清理应是可重试维护动作,不要与读取正常配置混成一段破坏性启动脚本。
如果需要保留上一份有效配置,备份提交顺序也要设计。先移动旧文件再写新文件,可能造成目标暂时不存在;先覆盖备份则可能失去最后已知良好版本。更严格的方案可以使用不可变版本文件与一个当前版本指针,但会增加回收、提交与恢复复杂度。小型配置选择足够清楚的保证,比给复杂方案贴上原子标签更有帮助。
数据规模改变后,重新审视这份实现#
实验读取与写入整个 JSON 文件,只适合有明确上限的小配置。增加大量正文、附件或索引后,JSON.parse 与 stringify 本身会消耗 CPU,异步 readFile 不会把解析过程移出主线程。应给文件大小设上限,并在数据规模发生变化时重新选择存储与执行位置,不能因为扩展名仍叫配置就继续使用同一种实现。
同样,备份策略应该依据数据能否重建。窗口大小可以重置,用户唯一一份正文不能轻易丢失;缓存索引可以从原文再生,但权限关系和编辑历史可能没有替代来源。将这些数据混在同一个文件中,会让恢复和清理的风险捆在一起。目录布局应反映真实生命周期,而不只是为了文件列表看起来整齐。
练习与验收:证明保存失败没有伪装成功#
练习要求在第一次提交前模拟一次错误,随后恢复正常提交,确认队列没有永久失败;同时把损坏配置文件加载错误传给调用者。提示是故障注入只执行一次,并用状态读取验证之前成功值。下面是完整独立检查文件,与 settings-store.cjs 同目录运行。
完整参考实现:recovery-check.cjs
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const { createStore } = require("./settings-store.cjs");
(async () => {
const tempBase = await fs.realpath(os.tmpdir());
const tempPrefix = "recovery-lab-";
const root = await fs.mkdtemp(path.join(tempBase, tempPrefix));
try {
let failOnce = true;
const store = createStore(path.join(root, "config.json"), {
beforeCommit: async () => {
if (failOnce) { failOnce = false; throw new Error("DISK_SIMULATION"); }
}
});
await assert.rejects(() => store.update(0, "dark"), /DISK_SIMULATION/);
assert.equal((await store.read()).revision, 0);
assert.equal((await store.update(0, "dark")).revision, 1);
const corrupt = path.join(root, "corrupt.json");
await fs.writeFile(corrupt, "{broken", "utf8");
const damaged = createStore(corrupt);
await assert.rejects(() => damaged.read(), SyntaxError);
await assert.rejects(() => damaged.read(), /STORE_NOT_READY/);
await assert.rejects(() => damaged.read(), /STORE_NOT_READY/);
await assert.rejects(() => damaged.update(0, "dark"), /STORE_NOT_READY/);
assert.equal(await fs.readFile(corrupt, "utf8"), "{broken");
console.log("recovery checks passed");
} finally { const resolved = await fs.realpath(root);
if (path.dirname(resolved) !== tempBase || !path.basename(resolved).startsWith(tempPrefix)) {
throw new Error("UNSAFE_TEMP_CLEANUP");
}
await fs.rm(resolved, { recursive: true, force: true }); }
})().catch(error => { console.error(error); process.exitCode = 1; });
验收不只看成功提示:旧 revision 冲突必须被拒绝,rename 前失败不得改变目标,临时文件应清理,进程重新读取应得到最后成功状态,损坏配置应保留。以上 Node 实验已经实际通过;没有执行真实掉电、网络文件系统、Electron GUI、SQLite 原生驱动安装或生产数据迁移。
自测一:排队能避免所有覆盖吗?不能,旧窗口仍需版本比较,多进程还需额外协调。自测二:rename 成功能证明断电安全吗?不能,它与介质和目录持久性有关。自测三:userData 中保存令牌就是加密吗?不是,目录位置、访问控制与加密必须分别讨论。
本章验证记录#
Node 22.22.0 已运行 settings-check.cjs、migration-check.cjs 与 recovery-check.cjs,覆盖写入冲突、提交前失败、旧文件保留、重新读取、迁移、初始化错误后的重复读取拒绝与后续写入拒绝。清理前核对真实绝对目录和前缀。Electron 目录入口仅语法检查;未执行 GUI、原生 SQLite 驱动、真实断电或用户目录写入。