各位同仁,各位对前端技术与系统编程充满热情的开发者们,下午好。
今天,我们将深入探讨一个在Web平台日益重要的领域:JavaScript 对原生文件系统的访问。具体来说,我们将聚焦于 File System Access API 所提供的关键能力——文件锁(Exclusive Lock)竞争与原子写入机制。在Web应用逐渐从简单的内容展示转向复杂的数据管理与离线能力时,对本地文件系统进行可靠、高效、并发安全的操作,成为了一个不可回避的挑战。理解并掌握这些机制,将使我们能够构建出更加健壮、用户体验更佳的应用程序。
File System Access API:Web 能力的边界扩展
传统上,Web浏览器出于安全沙箱的考虑,对本地文件系统的访问权限极为受限。用户只能通过 <input type="file"> 选择文件进行读取,或通过下载链接保存文件。这种模型对于复杂应用而言远远不够。File System Access API (FSA API) 的出现,彻底改变了这一局面。它允许Web应用在用户授权的前提下,直接读取、写入、甚至管理本地文件和目录。
FSA API 的核心是 FileSystemFileHandle 和 FileSystemDirectoryHandle 对象,它们分别代表用户授权访问的文件和目录。通过 window.showOpenFilePicker(), window.showSaveFilePicker(), 和 window.showDirectoryPicker() 等方法,应用可以启动用户友好的文件选择器,获取这些句柄。
/**
* 示例:通过文件选择器获取文件句柄并读取内容
*/
async function selectFileAndReadContent() {
try {
// 1. 弹出文件选择器,允许用户选择一个文件
const [fileHandle] = await window.showOpenFilePicker({
types: [{
description: 'Text Files',
accept: {
'text/plain': ['.txt'],
'application/json': ['.json']
}
}],
multiple: false // 只允许选择一个文件
});
// 2. 获取文件对象
const file = await fileHandle.getFile();
// 3. 读取文件内容
const content = await file.text();
console.log(`文件名称: ${file.name}`);
console.log(`文件内容:n${content}`);
return fileHandle; // 返回文件句柄,以便后续操作
} catch (error) {
if (error.name === 'AbortError') {
console.log('用户取消了文件选择。');
} else {
console.error('读取文件时发生错误:', error);
}
return null;
}
}
/**
* 示例:通过文件保存器获取文件句柄并写入内容
*/
async function saveFileAndWriteContent(contentToWrite, suggestedName = 'untitled.txt') {
try {
// 1. 弹出文件保存器,允许用户选择保存位置和文件名
const fileHandle = await window.showSaveFilePicker({
types: [{
description: 'Text Files',
accept: {
'text/plain': ['.txt']
}
}],
suggestedName: suggestedName
});
// 2. 创建一个可写流
const writableStream = await fileHandle.createWritable();
// 3. 写入内容
await writableStream.write(contentToWrite);
// 4. 关闭流,提交更改
await writableStream.close();
console.log(`文件已成功保存到: ${fileHandle.name}`);
return fileHandle;
} catch (error) {
if (error.name === 'AbortError') {
console.log('用户取消了文件保存。');
} else {
console.error('保存文件时发生错误:', error);
}
return null;
}
}
// 实际调用示例
// selectFileAndReadContent().then(handle => {
// if (handle) console.log('文件句柄:', handle);
// });
// saveFileAndWriteContent('Hello, File System Access API!', 'my-greeting.txt');
FSA API 的功能强大,但随之而来的,是我们需要面对传统文件系统编程中的挑战,尤其是并发访问带来的数据一致性问题。
并发访问的挑战:数据竞争与一致性危机
在任何多进程或多线程环境中,对共享资源的并发访问都可能导致问题。对于文件而言,当多个操作尝试同时修改同一个文件时,就可能出现以下情况:
- 数据损坏 (Data Corruption):如果两个写入操作交错执行,例如一个写入一半,另一个写入覆盖了前半部分,然后第一个继续写入后半部分,最终文件内容将变得混乱,无法预测。
- 脏读 (Dirty Read):一个进程读取了另一个进程尚未完全提交(仍在写入中)的数据。
- 丢失更新 (Lost Update):两个进程都读取了文件的旧版本,各自修改后写入。最终保存的将是其中一个进程的修改,另一个进程的修改会丢失。
考虑一个简单的场景:一个配置文件 config.json,存储了应用的重要设置。两个不同的模块(或同一个模块的两个不同实例)可能同时尝试更新这个文件。
场景描述:
- 模块 A:读取
config.json,将其解析为 JSON 对象,修改settingA属性。 - 模块 B:几乎同时读取
config.json,解析为 JSON 对象,修改settingB属性。 - 模块 A:将修改后的 JSON 对象序列化为字符串,并写入
config.json。 - 模块 B:将修改后的 JSON 对象序列化为字符串,并写入
config.json。
结果:
无论哪个模块最后完成写入,另一个模块的修改都将被覆盖。如果模块 A 后写入,那么 settingB 的修改会丢失;如果模块 B 后写入,那么 settingA 的修改会丢失。这是典型的“丢失更新”问题。
为了避免这些问题,我们需要引入同步机制,确保在修改文件内容时,只有一个操作能够独占文件,或者至少以某种可控的方式进行访问。这就是文件锁的用武之地。
文件锁(Exclusive Lock):独占式访问的守护者
File System Access API 提供了一种强大的机制来解决并发写入问题:独占锁(Exclusive Lock)。当一个进程或线程成功获取了某个文件的独占锁后,其他任何试图获取该文件独占锁的尝试都将被阻塞或拒绝,直到当前持有锁的进程释放它。
在 FSA API 中,独占锁是通过 FileSystemFileHandle.createWritable() 方法的 mode 选项实现的。
createWritable() 的 mode 选项
createWritable() 方法用于创建一个 FileSystemWritableFileStream,通过这个流可以向文件写入数据。它接受一个可选的 options 对象,其中包含 mode 属性。
interface FileSystemCreateWritableOptions {
mode?: 'string'; // 'exclusive' 或 'readwrite'
// ... 其他属性,例如 `keepExistingData` (Chrome 122+), `start` (Chrome 122+)
}
// 默认行为:
// createWritable()
// createWritable({ mode: 'readwrite' })
// 这两种方式允许对文件进行写入,但不尝试获取独占锁。
// 如果文件已经被其他地方打开并写入,可能会导致数据冲突。
// 独占模式:
// createWritable({ mode: 'exclusive' })
// 尝试获取文件的独占锁。如果文件当前已经被其他 FileSystemWritableFileStream 以 'exclusive' 模式打开,
// 或者在某些系统上,文件被其他进程打开,则会失败。
当 mode 设置为 'exclusive' 时,createWritable() 方法的行为如下:
- 尝试获取锁: 浏览器会尝试为当前操作获取文件的独占锁。
- 成功获取: 如果成功获取到锁,方法会返回一个
FileSystemWritableFileStream实例,并且文件现在被当前操作独占。其他任何试图以'exclusive'模式获取该文件锁的操作都将失败。 - 获取失败: 如果文件已经被其他
FileSystemWritableFileStream以'exclusive'模式打开,或者由于操作系统层面的限制(例如,文件被其他应用独占),createWritable()会抛出一个DOMException。通常,这个异常的name属性会是'AbortError'或'NoModificationAllowedError'。 - 释放锁: 独占锁会在
FileSystemWritableFileStream被close()或abort()时自动释放。这意味着,即使在写入过程中发生错误,也务必确保流被关闭,以避免死锁。
独占锁的生命周期
独占锁的生命周期与 FileSystemWritableFileStream 紧密绑定:
- 获取: 当
createWritable({ mode: 'exclusive' })成功返回FileSystemWritableFileStream时,锁被获取。 - 持有: 在
FileSystemWritableFileStream处于活动状态(即未关闭或中止)期间,锁被持有。 - 释放: 当调用
writableStream.close()(成功完成写入并提交) 或writableStream.abort()(中止写入并回滚) 时,锁被释放。
重要提示: 如果一个 FileSystemWritableFileStream 在没有被 close() 或 abort() 的情况下,其所在的页面/Worker 被关闭或崩溃,操作系统可能会在一段时间后自动清理锁,但这并非立即且确定的行为,可能导致文件暂时无法被其他独占写入。因此,总是在 finally 块中确保流被关闭是最佳实践。
代码示例:独占锁的获取与竞争
我们来看一个具体的例子,模拟两个并发操作尝试写入同一个文件。
/**
* 助手函数:模拟异步延迟
*/
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 核心函数:尝试以独占模式写入文件
* @param {FileSystemFileHandle} fileHandle - 文件的句柄
* @param {string} content - 要写入的内容
* @param {string} callerId - 调用者的标识符 (例如 'Process A', 'Process B')
* @returns {Promise<boolean>} - 如果写入成功返回 true,否则返回 false
*/
async function writeContentWithExclusiveLock(fileHandle, content, callerId) {
let writableStream = null;
try {
console.log(`[${callerId}] 尝试获取文件 "${fileHandle.name}" 的独占锁...`);
writableStream = await fileHandle.createWritable({ mode: 'exclusive' });
console.log(`[${callerId}] 成功获取到独占锁,开始写入内容。`);
// 模拟写入操作耗时
await sleep(Math.random() * 500 + 200); // 200-700ms 写入时间
await writableStream.write(content);
await writableStream.close();
console.log(`[${callerId}] 写入完成并释放锁。文件内容: "${content}"`);
return true;
} catch (error) {
if (error.name === 'AbortError' || error.name === 'NoModificationAllowedError') {
console.warn(`[${callerId}] 无法获取独占锁。文件可能正在被其他进程使用。错误信息: ${error.message}`);
} else {
console.error(`[${callerId}] 写入文件时发生未知错误:`, error);
}
return false;
} finally {
if (writableStream) {
// 确保流在任何情况下都被关闭,以释放资源和锁
// 注意:如果流在写入过程中出错,此时调用 close() 可能还会抛出错误,
// 但对于独占锁而言,无论 close() 或 abort() 都会尝试释放锁。
// 更好的做法是在捕获到错误后,显式调用 abort()。
// 这里为了简化示例,假设 close() 是在成功写入后调用的。
// 对于错误情况,如果 stream 已经打开但写入失败,我们应该使用 abort()
// if (error && writableStream.locked) { await writableStream.abort(); }
// else { await writableStream.close(); }
}
}
}
/**
* 模拟两个并发进程尝试写入同一个文件
*/
async function simulateConcurrentWrites() {
// 1. 获取一个文件句柄 (例如,通过保存对话框创建一个新文件)
const fileHandle = await window.showSaveFilePicker({
types: [{
description: 'Test File',
accept: { 'text/plain': ['.txt'] }
}],
suggestedName: 'concurrent_test.txt'
});
if (!fileHandle) {
console.log('用户取消了文件保存。');
return;
}
console.log(`开始模拟对文件 "${fileHandle.name}" 的并发写入...`);
// 2. 启动两个并发写入操作
const taskA = writeContentWithExclusiveLock(fileHandle, 'Content from Process A', 'Process A');
// 稍微延迟一下,确保两个任务几乎同时开始,但其中一个可能先尝试获取锁
await sleep(50);
const taskB = writeContentWithExclusiveLock(fileHandle, 'Content from Process B', 'Process B');
// 3. 等待所有操作完成
const [resultA, resultB] = await Promise.all([taskA, taskB]);
console.log('n所有并发写入操作已完成。');
console.log(`Process A 写入结果: ${resultA ? '成功' : '失败'}`);
console.log(`Process B 写入结果: ${resultB ? '成功' : '失败'}`);
// 4. 读取最终文件内容以验证
try {
const file = await fileHandle.getFile();
const finalContent = await file.text();
console.log(`n文件 "${fileHandle.name}" 的最终内容: "${finalContent}"`);
} catch (readError) {
console.error('读取最终文件内容时发生错误:', readError);
}
}
// 调用模拟函数
// simulateConcurrentWrites();
运行结果分析:
当你运行 simulateConcurrentWrites() 时,你会观察到以下现象:
- 其中一个进程(例如 ‘Process A’)会成功打印
成功获取到独占锁,开始写入内容。 - 另一个进程(例如 ‘Process B’)会打印
无法获取独占锁。文件可能正在被其他进程使用。 - 最终文件的内容将是成功获取锁并完成写入的那个进程所写入的内容。
这证明了独占锁机制在防止并发写入冲突方面的有效性。它强制了对文件的串行化访问,确保了数据的一致性。
独占锁的局限性与处理策略
虽然独占锁很强大,但在实际应用中,我们也需要考虑其局限性并设计合适的处理策略:
- 阻塞性: 锁是阻塞的。如果一个操作长时间持有锁,其他等待锁的操作将无法进行。这可能影响用户体验。
- 死锁风险: 尽管 FSA API 的独占锁是针对单个文件的,相对简单,但在更复杂的场景中(例如需要同时修改多个文件,并且以不同顺序获取锁),仍然存在死锁的理论风险。
- 异常处理: 必须妥善处理
DOMException,并确保在任何情况下都能释放锁。
处理策略:
- 重试机制: 当获取独占锁失败时,可以实现一个带指数退避(exponential backoff)的重试机制。即,等待一小段时间后再次尝试获取锁,每次失败后延长等待时间,直到成功或达到最大重试次数。
- 用户通知: 如果重试多次后仍无法获取锁,应向用户友好的提示,例如“文件当前被其他应用占用,请稍后再试”或“您是否要强制覆盖?”(这需要更复杂的逻辑)。
- 队列化操作: 如果有多个操作需要写入同一个文件,可以将其放入一个队列中,确保每次只有一个操作尝试获取锁并执行写入。
以下是一个带有重试机制的独占写入函数示例:
/**
* 带有重试机制的独占写入文件函数
* @param {FileSystemFileHandle} fileHandle - 文件的句柄
* @param {string} content - 要写入的内容
* @param {string} callerId - 调用者的标识符
* @param {object} options - 重试选项
* @param {number} options.maxRetries - 最大重试次数
* @param {number} options.initialDelayMs - 初始重试延迟(毫秒)
* @returns {Promise<boolean>} - 如果写入成功返回 true,否则返回 false
*/
async function writeContentWithExclusiveLockAndRetry(
fileHandle,
content,
callerId,
{ maxRetries = 5, initialDelayMs = 100 } = {}
) {
let retryCount = 0;
let currentDelay = initialDelayMs;
while (retryCount < maxRetries) {
let writableStream = null;
try {
console.log(`[${callerId}] 尝试获取文件 "${fileHandle.name}" 的独占锁 (尝试 ${retryCount + 1}/${maxRetries})...`);
writableStream = await fileHandle.createWritable({ mode: 'exclusive' });
console.log(`[${callerId}] 成功获取到独占锁,开始写入内容。`);
await sleep(Math.random() * 300 + 100); // 模拟写入耗时
await writableStream.write(content);
await writableStream.close();
console.log(`[${callerId}] 写入完成并释放锁。文件内容: "${content}"`);
return true;
} catch (error) {
if (error.name === 'AbortError' || error.name === 'NoModificationAllowedError') {
console.warn(`[${callerId}] 无法获取独占锁。重试中... (延迟 ${currentDelay}ms)`);
await sleep(currentDelay);
currentDelay *= 2; // 指数退避
retryCount++;
} else {
console.error(`[${callerId}] 写入文件时发生未知错误:`, error);
return false; // 非锁竞争错误,直接退出
}
} finally {
// 确保流在任何情况下都尝试被关闭/中止
if (writableStream) {
// 如果是错误情况,显式中止流
// 通常,如果 createWritable 成功,但后续操作失败,
// writableStream 仍然是打开的,需要处理。
// 如果是锁竞争失败,writableStream 根本不会被创建。
}
}
}
console.error(`[${callerId}] 达到最大重试次数,未能成功获取独占锁并写入文件。`);
return false;
}
/**
* 模拟两个并发进程尝试写入同一个文件,使用重试机制
*/
async function simulateConcurrentWritesWithRetry() {
const fileHandle = await window.showSaveFilePicker({
types: [{
description: 'Test File',
accept: { 'text/plain': ['.txt'] }
}],
suggestedName: 'concurrent_retry_test.txt'
});
if (!fileHandle) {
console.log('用户取消了文件保存。');
return;
}
console.log(`开始模拟对文件 "${fileHandle.name}" 的并发写入 (带重试机制)...`);
const options = { maxRetries: 10, initialDelayMs: 50 };
const taskA = writeContentWithExclusiveLockAndRetry(fileHandle, 'Content A with Retry', 'Process A', options);
await sleep(20); // 确保另一个任务几乎同时开始
const taskB = writeContentWithExclusiveLockAndRetry(fileHandle, 'Content B with Retry', 'Process B', options);
const [resultA, resultB] = await Promise.all([taskA, taskB]);
console.log('n所有并发写入操作已完成。');
console.log(`Process A 写入结果: ${resultA ? '成功' : '失败'}`);
console.log(`Process B 写入结果: ${resultB ? '成功' : '失败'}`);
try {
const file = await fileHandle.getFile();
const finalContent = await file.text();
console.log(`n文件 "${fileHandle.name}" 的最终内容: "${finalContent}"`);
} catch (readError) {
console.error('读取最终文件内容时发生错误:', readError);
}
}
// 调用模拟函数
// simulateConcurrentWritesWithRetry();
通过引入重试机制,即使有并发竞争,两个进程最终都能有机会写入文件,尽管它们会串行执行。最终文件的内容将是最后成功写入的那个进程的数据。
原子写入机制:确保数据完整性
理解了独占锁,我们再来看原子写入(Atomic Write)。原子性是一个非常重要的概念,它意味着一个操作要么完全成功,要么完全失败,中间状态对外部是不可见的。对于文件写入而言,这意味着在写入过程中,文件不会出现部分更新或损坏的状态。
传统的非原子写入可能导致以下问题:
- 进程崩溃: 如果在写入一半时应用崩溃,文件将处于损坏状态。
- 外部读取: 如果其他进程在写入过程中读取文件,可能会读到不完整或错误的数据。
为了实现原子写入,常见的模式是“写入临时文件,然后重命名”:
- 创建临时文件: 将所有要写入的数据先写入到一个新的、临时的文件中。这个临时文件通常与目标文件位于同一目录下,并有一个独特的名称(例如
target.json.tmp)。 - 完整写入: 确保所有数据都已成功写入到临时文件并刷新到磁盘。
- 重命名/替换: 将临时文件重命名为目标文件名,替换掉原有的目标文件。这个重命名操作在大多数文件系统上是原子的。
如果在此过程中发生任何错误(例如,写入临时文件失败),那么原始的目标文件将保持不变。只有当所有数据都成功写入临时文件后,才会替换原始文件。这样就保证了在任何时候,目标文件要么是旧的完整版本,要么是新的完整版本,不会出现中间的损坏状态。
FSA API 中的原子写入
FileSystemWritableFileStream 本身就内置了原子写入的机制。当您通过 fileHandle.createWritable() 创建一个流并向其写入数据,然后调用 writableStream.close() 时,浏览器通常会在底层执行以下操作:
- 创建临时文件: 在文件句柄指向的同一目录下,创建一个临时文件。
- 写入数据: 所有通过
writableStream.write()写入的数据,实际上是写入到这个临时文件。 - 提交更改: 当调用
writableStream.close()时,浏览器会原子性地将这个临时文件重命名为目标文件的名称,替换掉原有的目标文件。 - 回滚更改: 如果调用
writableStream.abort(),或者在close()之前发生错误,临时文件会被删除,原有的目标文件保持不变。
这意味着,只要您正确地使用了 FileSystemWritableFileStream(即,在写入完成后调用 close(),或在出错时调用 abort()),那么您的写入操作就具备了原子性。
/**
* 演示 FileSystemWritableFileStream 的原子写入特性
* @param {FileSystemFileHandle} fileHandle - 要写入的文件句柄
* @param {string} content - 要写入的内容
* @param {boolean} simulateCrashBeforeClose - 是否模拟在 close() 前崩溃
*/
async function demonstrateAtomicWrite(fileHandle, content, simulateCrashBeforeClose = false) {
let writableStream = null;
console.log(`开始对文件 "${fileHandle.name}" 进行原子写入演示...`);
try {
writableStream = await fileHandle.createWritable(); // 默认 mode: 'readwrite'
console.log(`成功创建可写流。`);
await writableStream.write(content);
console.log(`内容已写入到临时文件 (内部操作)。`);
if (simulateCrashBeforeClose) {
console.warn(`模拟崩溃,不调用 close()。原始内容应保持不变。`);
// 不调用 close() 或 abort(),模拟进程崩溃
return;
}
await writableStream.close();
console.log(`流已关闭,内容已原子性地提交到文件。`);
} catch (error) {
console.error(`写入文件时发生错误:`, error);
if (writableStream) {
await writableStream.abort(); // 确保在错误时回滚更改
console.log(`流已中止,更改已回滚。`);
}
}
}
/**
* 运行原子写入演示
*/
async function runAtomicWriteDemo() {
// 1. 获取一个文件句柄,创建一个新文件
const fileHandle = await window.showSaveFilePicker({
types: [{
description: 'Demo File',
accept: { 'text/plain': ['.txt'] }
}],
suggestedName: 'atomic_write_demo.txt'
});
if (!fileHandle) {
console.log('用户取消了文件保存。');
return;
}
// 2. 初始写入一些内容
await demonstrateAtomicWrite(fileHandle, 'Initial Content');
let file = await fileHandle.getFile();
let currentContent = await file.text();
console.log(`n文件当前内容: "${currentContent}"`); // 应该显示 "Initial Content"
// 3. 尝试写入新内容,但模拟崩溃
console.log('n--- 模拟崩溃情况 ---');
await demonstrateAtomicWrite(fileHandle, 'New Content (should not be saved)', true);
// 模拟等待一段时间,让潜在的垃圾回收发生,虽然不是保证
await sleep(500);
file = await fileHandle.getFile();
currentContent = await file.text();
console.log(`文件模拟崩溃后内容: "${currentContent}"`); // 应该仍然显示 "Initial Content"
// 4. 再次写入新内容,这次成功提交
console.log('n--- 成功写入情况 ---');
await demonstrateAtomicWrite(fileHandle, 'Successfully Saved New Content');
file = await fileHandle.getFile();
currentContent = await file.text();
console.log(`文件最终内容: "${currentContent}"`); // 应该显示 "Successfully Saved New Content"
}
// 调用演示函数
// runAtomicWriteDemo();
在上述演示中,当 simulateCrashBeforeClose 为 true 时,writableStream.close() 不会被调用。在这种情况下,尽管 write() 操作已经执行,但 New Content 并不会最终出现在文件中,因为临时文件没有被重命名替换。文件内容仍然是 Initial Content,这正是原子写入所期望的行为。
结合独占锁与原子写入:构建强大的数据管理
现在我们已经分别理解了独占锁和原子写入,是时候将它们结合起来,构建一个真正健壮的文件数据管理策略了。独占锁解决了并发访问时的冲突问题,而原子写入则解决了单次写入操作的完整性问题。当它们协同工作时,可以确保在多方竞争写入同一文件时,每次成功的写入都是完整且一致的。
典型场景: 更新一个共享的 JSON 配置文件。
假设 config.json 包含应用的状态或配置,多个模块可能会读取它,修改部分内容,然后写回。为了避免数据丢失和损坏,我们需要:
- 独占访问: 确保在读取、修改和写入的整个过程中,没有其他模块能同时修改文件。
- 原子提交: 确保写入操作本身是原子的,即使在写入过程中发生故障,文件也能保持一个有效状态。
以下是实现这种机制的步骤和代码示例:
- 获取文件句柄:通过
showOpenFilePicker或showSaveFilePicker获取FileSystemFileHandle。 - 获取独占锁:使用
createWritable({ mode: 'exclusive' })尝试获取锁。如果失败,则根据策略重试或报错。 - 读取现有内容:如果文件已存在,读取其内容。
- 修改内存中的数据:将内容解析为 JavaScript 对象,进行所需的修改。
- 写入新内容:将修改后的数据序列化为字符串,写入到
FileSystemWritableFileStream。 - 关闭流并释放锁:调用
writableStream.close()。这会原子性地提交更改并释放独占锁。
/**
* 助手函数:读取文件内容并解析为 JSON
* @param {FileSystemFileHandle} fileHandle - 文件句柄
* @returns {Promise<object|null>} - 解析后的 JSON 对象,如果文件不存在或内容非法则返回 null
*/
async function readJsonFile(fileHandle) {
try {
const file = await fileHandle.getFile();
if (file.size === 0) {
return {}; // 空文件,返回空对象
}
const content = await file.text();
return JSON.parse(content);
} catch (error) {
if (error.name === 'NotFoundError') {
console.warn(`文件 "${fileHandle.name}" 不存在,将创建新文件。`);
return {};
}
console.error(`读取或解析文件 "${fileHandle.name}" 失败:`, error);
return null;
}
}
/**
* 结合独占锁和原子写入,安全地更新 JSON 文件
* @param {FileSystemFileHandle} fileHandle - 文件的句柄
* @param {function(object): object} updateFn - 用于更新 JSON 对象的函数。它接收当前 JSON 对象,返回修改后的新对象。
* @param {string} callerId - 调用者的标识符
* @param {object} retryOptions - 重试选项 (maxRetries, initialDelayMs)
* @returns {Promise<boolean>} - 如果更新成功返回 true,否则返回 false
*/
async function updateJsonFileAtomically(
fileHandle,
updateFn,
callerId,
{ maxRetries = 5, initialDelayMs = 100 } = {}
) {
let retryCount = 0;
let currentDelay = initialDelayMs;
let writableStream = null;
while (retryCount < maxRetries) {
try {
console.log(`[${callerId}] 尝试获取文件 "${fileHandle.name}" 的独占锁 (尝试 ${retryCount + 1}/${maxRetries})...`);
writableStream = await fileHandle.createWritable({ mode: 'exclusive' });
console.log(`[${callerId}] 成功获取到独占锁。`);
// 1. 读取当前内容
const currentData = await readJsonFile(fileHandle);
if (currentData === null) {
// 如果读取或解析失败,且不是因为文件不存在,则无法安全更新
console.error(`[${callerId}] 无法读取或解析文件内容,中止更新。`);
await writableStream.abort(); // 确保释放锁
return false;
}
console.log(`[${callerId}] 读取到当前数据:`, currentData);
// 2. 在内存中修改数据
const newData = updateFn(currentData);
const contentToWrite = JSON.stringify(newData, null, 2);
console.log(`[${callerId}] 准备写入新数据:`, newData);
// 3. 写入新内容到流 (底层写入临时文件)
await writableStream.truncate(0); // 清空文件,确保从头开始写入
await writableStream.write(contentToWrite);
// 4. 关闭流 (原子性提交更改并释放锁)
await writableStream.close();
console.log(`[${callerId}] 文件 "${fileHandle.name}" 已成功原子更新并释放锁。`);
return true;
} catch (error) {
if (error.name === 'AbortError' || error.name === 'NoModificationAllowedError') {
console.warn(`[${callerId}] 无法获取独占锁。重试中... (延迟 ${currentDelay}ms)`);
await sleep(currentDelay);
currentDelay *= 2; // 指数退避
retryCount++;
} else {
console.error(`[${callerId}] 更新文件时发生未知错误:`, error);
// 如果在获取锁后发生错误,确保流被中止以释放锁和回滚更改
if (writableStream) {
await writableStream.abort();
console.log(`[${callerId}] 流已中止,更改已回滚。`);
}
return false;
}
} finally {
// 在这里不需要额外的 close/abort 逻辑,因为在 try/catch 块中已经处理了。
// 确保 writableStream 在任何情况下都被处理。
}
}
console.error(`[${callerId}] 达到最大重试次数,未能成功获取独占锁并更新文件。`);
return false;
}
/**
* 模拟两个并发进程尝试原子更新同一个 JSON 文件
*/
async function simulateConcurrentAtomicUpdates() {
const fileHandle = await window.showSaveFilePicker({
types: [{
description: 'JSON Config',
accept: { 'application/json': ['.json'] }
}],
suggestedName: 'app_config.json'
});
if (!fileHandle) {
console.log('用户取消了文件保存。');
return;
}
// 初始写入一个空配置
await updateJsonFileAtomically(fileHandle, () => ({}), 'Initializer');
console.log('n开始模拟对文件 "${fileHandle.name}" 的并发原子更新...');
const options = { maxRetries: 10, initialDelayMs: 50 };
// 任务 A: 更新 settingA
const taskA = updateJsonFileAtomically(
fileHandle,
(config) => ({ ...config, settingA: 'value from A', timestampA: Date.now() }),
'Module A',
options
);
await sleep(20); // 确保另一个任务几乎同时开始
// 任务 B: 更新 settingB
const taskB = updateJsonFileAtomically(
fileHandle,
(config) => ({ ...config, settingB: 'value from B', timestampB: Date.now() }),
'Module B',
options
);
const [resultA, resultB] = await Promise.all([taskA, taskB]);
console.log('n所有并发更新操作已完成。');
console.log(`Module A 更新结果: ${resultA ? '成功' : '失败'}`);
console.log(`Module B 更新结果: ${resultB ? '成功' : '失败'}`);
// 读取最终文件内容以验证
try {
const file = await fileHandle.getFile();
const finalContent = await file.text();
console.log(`n文件 "${fileHandle.name}" 的最终内容:n${finalContent}`);
console.log('最终解析的 JSON 对象:', JSON.parse(finalContent));
} catch (readError) {
console.error('读取最终文件内容时发生错误:', readError);
}
}
// 调用模拟函数
// simulateConcurrentAtomicUpdates();
运行结果分析:
当 simulateConcurrentAtomicUpdates() 运行时,你会看到两个模块会竞争独占锁。一个模块会成功获取锁,读取当前配置(初始可能为空),修改并写入。另一个模块则会因为无法获取锁而进入重试循环。一旦第一个模块完成并释放锁,第二个模块就有机会获取锁,读取此时已更新的配置,然后在其基础上进行修改并写入。
最终,app_config.json 文件将包含 settingA 和 settingB 两个属性,以及它们对应的 timestamp,这证明了两个并发更新都被安全地合并了,没有发生数据丢失。这是因为:
- 独占锁:确保了在任何一个时刻,只有一个模块能够执行“读取-修改-写入”的完整操作,防止了丢失更新。
- 原子写入:确保了每个模块的写入操作都是完整的,不会出现半成品文件。
- 重试机制:使得即使发生竞争,所有模块最终都有机会完成其操作。
高级考量与最佳实践
在实际应用中,除了上述核心机制,我们还需要考虑一些高级因素和最佳实践:
1. 锁粒度
FSA API 提供的独占锁是文件级的。这意味着,如果您需要修改文件中的一小部分内容,您仍然需要锁定整个文件。这对于小型文件(如配置文件)通常不是问题。但对于大型文件,如果只需要修改其中一个字节范围,文件级锁可能会导致不必要的性能瓶颈。目前 FSA API 不支持字节范围锁,如果有此类需求,可能需要考虑将大文件拆分为更小的、逻辑上独立的块。
2. 死锁防范
FSA API 的文件级独占锁本身不太容易导致死锁,因为一个操作只尝试获取一个锁。然而,如果在您的应用逻辑中涉及到需要同时锁定多个文件才能完成的复杂事务,并且这些文件被不同的并发任务以不同的顺序尝试锁定,那么死锁的风险就会出现。
例如:
- 任务 X 尝试锁定文件 A,然后锁定文件 B。
- 任务 Y 尝试锁定文件 B,然后锁定文件 A。
如果任务 X 成功锁定了 A,同时任务 Y 成功锁定了 B,那么它们都将等待对方持有的锁,从而导致死锁。
防范策略:
- 统一锁定顺序: 始终以相同的预定义顺序(例如,按文件名的字母顺序)获取多个文件的锁。
- 一次性获取所有锁: 尝试一次性获取所有需要的锁。如果不能全部获取,则释放已获取的锁并重试。
3. 健壮的错误处理
文件操作本质上容易出错(磁盘空间不足、权限问题、文件不存在、用户取消等)。始终使用 try...catch...finally 结构来处理潜在的异常,并确保在 finally 块中关闭或中止 FileSystemWritableFileStream,即使在错误发生时也要如此,以释放文件锁和系统资源。
let writableStream = null;
try {
writableStream = await fileHandle.createWritable({ mode: 'exclusive' });
// ... 写入操作 ...
await writableStream.close();
} catch (error) {
console.error('文件操作失败:', error);
if (writableStream) {
await writableStream.abort(); // 确保在错误时中止流
}
} finally {
// 即使在没有 catch 到错误时,如果 stream 未关闭,也应确保它被关闭
// 但在上面的 try/catch 结构中,如果成功,close() 已经调用了。
// 如果是 createWritable() 失败,writableStream 会是 null。
// 所以这里的 finally 主要用于捕获 createWritable() 成功后,但内部操作失败的情况。
// 但最佳实践是在 catch 块中显式处理 abort()。
}
4. 用户体验与权限管理
FSA API 的使用需要用户授权,并且浏览器会通过权限提示告知用户。在设计应用时,要充分考虑用户体验:
- 明确的权限请求:在用户即将进行文件操作时才请求权限,并清晰地说明为何需要这些权限。
- 友好的错误提示:当文件锁竞争失败或发生其他文件系统错误时,向用户提供有意义的反馈,而不是生硬的错误信息。例如,“文件正在被其他程序使用,请稍后再试”,并提供一个重试按钮。
- 持久化权限: 如果用户授予了持久化权限(即勾选了“记住此选择”),则应用可以在后续会话中直接访问文件,无需再次提示。但用户随时可以在浏览器设置中撤销这些权限。
5. 性能考量
频繁的文件锁获取和释放,以及重试机制,都会带来一定的性能开销。对于需要极高吞吐量的写入操作,应仔细评估这种策略的性能影响。
- 批量写入: 尽量将多个小更新合并为一次大更新,减少文件锁的获取和释放次数。
- 缓存: 对于不频繁变化但频繁读取的数据,可以在内存中进行缓存,减少对文件系统的直接读取。
6. 与 fs 模块的对比 (Node.js)
如果您熟悉 Node.js 的 fs 模块,可能会联想到其提供的文件锁机制。fs.open() 方法可以使用 flags 参数,例如 wx(独占写入,如果文件存在则失败)或 ax(独占追加,如果文件存在则失败)。Node.js 的这些标志在 POSIX 系统上通常会映射到 O_EXCL 标志,行为与 FSA API 的 'exclusive' 模式类似,用于防止文件被并发创建或覆盖。
Node.js 也有像 fs.flock (在某些平台) 这样的更低级文件锁,以及像 fs.renameSync 这样通常是原子操作的方法,它们共同构成了构建原子写入的基石。FSA API 在设计上封装了这些底层复杂性,以更Web友好的方式提供高级功能,但其核心思想是相通的。
总结与展望
File System Access API 极大地扩展了Web应用在本地文件系统上的能力。通过其提供的独占锁(Exclusive Lock)机制,我们可以有效地解决并发写入带来的数据竞争问题,确保在多方访问同一文件时,每次操作都是串行且安全的。结合 FileSystemWritableFileStream 内置的原子写入特性(写入临时文件后原子重命名),我们能够构建出即使在系统崩溃或意外中断时也能保证文件数据完整性的健壮应用。
理解并熟练运用这些机制,是构建高性能、高可靠性离线Web应用、本地数据管理工具乃至Web IDE的关键一步。虽然目前 FSA API 仍有其局限性,例如缺乏共享锁和字节范围锁,但其提供的基础能力已经足够强大,足以应对绝大多数客户端文件操作场景。随着Web平台能力的不断演进,我们可以期待未来 File System Access API 会带来更多高级的文件系统控制选项,进一步缩小Web与原生应用之间的能力差距。