JavaScript 运行时中的文件系统 API 已经很久没有这么好了,这是我试图做出一个更好的文件系统 API 的尝试。
我们今天拥有的 JavaScript API 比十年前要好得多。考虑一下从 XMLHttpRequest
到 fetch()
的转变:开发者体验显著改善,允许我们编写更简洁、功能性更强的代码来完成同样的事情。异步编程的 promises 的引入允许了这种变化,以及一系列其他变化,使得 JavaScript 更容易编写。然而,有一个领域几乎没有创新:服务器端 JavaScript 运行时的文件系统 API。
Node.js: 当今文件系统 API 的起源
Node.js 最初发布于 2009 年,随之诞生了 fs
模块。 fs
模块是围绕 Linux 的核心实用程序构建的,其中的许多方法都反映了它们的 Linux 灵感,如 rmdir
、 mkdir
和 stat
。为此,Node.js 成功创建了一个低级文件系统 API,可以处理开发人员希望在命令行上完成的任何事情。不幸的是,这就是创新的终点。
Node.js 文件系统 API 最大的改变是引入了 fs/promises
,将整个实用程序从基于回调的方法移动到基于 promise 的方法。较小的增量变化包括实现 web 流和确保 reader 也实现了异步迭代器。该 API 仍然使用专有的 Buffer 类来读取二进制数据。(尽管 Buffer
现在是 Uint8Array
的子类,但仍然存在不兼容性,这使得使用 Buffer
s 有问题。)
即使是 Ryan Dhal 在 Node.js 上的继任者 Deno,也没有在文件系统 API 上做太多的改进,它基本上遵循了与 Node.js 中的 fs
模块相同的模式,尽管它使用了 Uint8Array
s,而 Node.js 使用了 Buffer
s,并且在不同的地方使用了异步迭代器,但它仍然采用了与 Node.js 相同的低级 API 方法。
只有 Bun,作为服务器端 JavaScript 运行时生态系统的最新成员,甚至尝试使用 Bun.file()
来更新文件系统 API,这是受 fetch()
的启发。虽然我赞赏这种对如何使用文件的重新思考,但当你处理多个文件时,为每个想要处理的文件创建一个新对象可能会很麻烦(当处理数千个文件时,会有一个巨大的性能损失)。除此之外,Bun 希望你使用 Node.js fs 模块进行其他操作。
一个现代的文件系统 API 会是什么样子?
在花费数年时间在维护 ESLint 的同时与 Node.js fs
模块斗争之后,我问自己,一个现代的文件系统 API 会是什么样子?
- 通常情况下会很简单。至少 80%的时间,我不是读取文件就是写入文件,或者检查文件是否存在,差不多就是这样,然而这些操作充满了危险,因为我需要检查各种东西以避免错误或记住额外的属性(例如
{ encoding: "utf8" }
)。 - 错误将很少发生。我对
fs
模块最大的抱怨就是它抛出错误的频率。在不存在的文件上调用fs.stat()
会抛出错误,这意味着你实际上需要将每个调用包装在try-catch
中。为什么?对于大多数应用程序来说,缺少文件并不是不可恢复的错误。 - 行动将是可观察的。在测试文件系统操作时,我真的只是想要一种方法来验证我期望发生的事情是否确实发生了。我不想与其他一些实用程序建立间谍网络,这些实用程序可能会也可能不会改变我正在观察的方法的实际行为。
- 模拟很容易。我总是惊讶于模拟文件系统操作的难度。最后我只能使用 proxyquire 之类的东西,否则就需要设置迷宫般的模拟,花上一段时间才能弄好。对于文件系统操作来说,这是一个很常见的需求,竟然还没有解决方案。
带着这些想法,我开始设计 fsx。
FSX 基础知识
fsx 库是我围绕现代高级文件系统 API 应该是什么样子的想法的结晶。 在这一点上,它专注于支持最常见的文件系统操作,而把较少使用的操作(例如 chmod
)抛在后面。 (我并不是说这些操作在将来不会被添加,但对我来说,从最常见的情况开始,然后以与初始方法相同的谨慎方式构建更多的功能是很重要的。)
使用 fsx 运行时包
首先,fsx API 在三个运行时包中可用。这些包都包含相同的功能,但绑定到不同的底层 API。这些包是:
fsx-node
- Node.js 中 fsx API 的绑定fsx-deno
- fsx API 的 Deno 绑定fsx-memory
- 适用于任何运行时(包括 web 浏览器)的内存实现
所以,开始时,你需要使用最适合你用例的运行时包。 为了本文的目的,我将专注于 fsx-node
,但相同的 API 存在于所有运行时包中. 所有运行时包都导出一个 fsx
单例,你可以以类似于 fs
的方式使用它。
import { fsx } from "fsx-node";
使用 fsx 读取文件
文件是通过使用返回特定数据类型的方法来读取的:
fsx.text(filePath)
读取给定的文件并返回一个字符串。fsx.json(filePath)
读取给定的文件并返回一个 JSON 值。fsx.arrayBuffer(filePath)
读取给定的文件并返回一个ArrayBuffer
。
这里有一些例子:
// read plain text
const text = await fsx.text("/path/to/file.txt");
// read JSON
const json = await fsx.json("/path/to/file.json");
// read bytes
const bytes = await fsx.arrayBuffer("/path/to/file.png");
如果文件不存在,每个方法都会返回 undefined
而不是抛出错误。这意味着您可以使用 if
语句而不是 try-catch
,并且可以选择使用 nullish 合并运算符来指定默认值,如下所示:
// read plain text
const text = (await fsx.text("/path/to/file.txt")) ?? "default value";
// read JSON
const json = (await fsx.json("/path/to/file.json")) ?? {};
// read bytes
const bytes =
(await fsx.arrayBuffer("/path/to/file.png")) ?? new ArrayBuffer(16);
我觉得这种方法在 2024 年比不断担心不存在的文件出错更有 JavaScript 风格。
使用 fsx 写文件
要写文件,调用 fsx.write()
方法。这个方法接受两个参数:
filePath:string
- 写入的路径value:string|ArrayBuffer
- 写入文件的值
这里有一个例子:
// write a string
await fsx.write("/path/to/file.txt", "Hello world!");
const bytes = new TextEncoder().encode("Hello world!").buffer;
// write a buffer
await fsx.write("/path/to/file.txt", buffer);
作为额外的好处,fsx.write()
将自动创建任何尚不存在的目录。这是我经常遇到的另一个问题,我认为它应该在现代文件系统 API 中“正常工作”。
使用 fsx 检测文件
要确定一个文件是否存在,使用 fsx.isFile(filePath)
方法,如果给定的文件存在,则返回 true
,否则返回 false
。
if (await fsx.isFile("/path/to/file.txt")) {
// handle the file
}
与 fs.stat()
不同,如果文件不存在,这个方法会返回 false
,而不是抛出错误。
try {
const stat = await fs.stat(filePath);
return stat.isFile();
} catch (ex) {
if (ex.code === "ENOENT") {
return false;
}
throw ex;
}
删除文件和目录
fsx.delete()
方法接受一个参数,即要删除的路径,并且对文件和目录都有效。
// delete a file
await fsx.delete("/path/to/file.txt");
// delete a directory
await fsx.delete("/path/to");
fsx.delete()
方法故意过于激进:它会递归地删除目录,即使它们不是空的(实际上是 rmdir -r
)。
fsx 日志
fsx 的一个关键特性是,由于其内置的日志系统,很容易确定哪些方法被调用,并使用了哪些参数。要启用 fsx
实例的日志记录,请调用 logStart()
方法并传入一个日志名称。当你完成日志记录时,请调用 logEnd()
并传入相同的名称来检索日志条目的数组。
fsx.logStart("test1");
const fileFound = await fsx.isFile("/path/to/file.txt");
const logs = fsx.logEnd("test1");
每个日志条目都是一个包含以下属性的对象:
timestamp
- 创建日志的数字时间戳type
- 描述日志类型的字符串data
- 与日志相关的附加数据
对于方法调用,日志条目的 type
是 call
,而 data
属性是一个对象,包含:
methodName
- 被调用的方法的名称args
- 传递给方法的参数数组。
对于前面的例子, logs
将包含一个条目:
// example log entry
{
timestamp: 123456789,
type: "call",
data: {
methodName: "isFile",
args: ["/path/to/file.txt"]
}
}
了解这一点后,您可以轻松地在测试中设置日志记录,然后检查调用了哪些方法,而无需使用第三方间谍库。
使用 fsx impls
fsx 的设计是这样的,抽象的核心功能包含在 fsx-core 包中,每个运行时包都扩展了该功能,使用特定于运行时的文件系统操作实现,这些操作被包装在一个称为 impl 的对象中。
fsx
单例- 一个构造函数,可以创建
fsx
的另一个实例(比如fsx-node
中的NodeFsx
) - 一个构造函数,可以创建运行时包的
impl
实例(如node-fsx
中的NodeFsxImpl
)。
这可以让您只使用所需的功能。
fsx 中的 base impls 和 active impls
每个 fsx
实例都有一个 base 类实现,它定义了 fsx
对象在生产环境中的行为。active impls 是在任何给定时间使用的实现,它可能也是 base 类实现,也可能不是。你可以调用 fsx.setImpl()
来改变 active impls。
import { fsx } from "fsx-node";
fsx.setImpl({
json() {
throw Error("This operation is not supported");
},
});
// somewhere else
await fsx.json("/path/to/file.json"); // throws error
在此示例中,基本实现被替换为自定义实现,该自定义实现在调用 fsx.json()
方法时会引发错误。这使得您可以轻松地模拟测试方法,而不必担心它可能如何影响整个包含的 fsx
对象。
交换 impls 进行测试
假设你有一个名为 readConfigFile()
的函数,它使用了来自 node-fsx
的 fsx
单例来读取名为 config.json
的文件,当测试这个函数时,你不想让它实际访问文件系统,你可以把 fsx
的实现换成 fsx-memory
提供的内存文件系统实现,如下:
import { fsx } from "fsx-node";
import { MemoryFsxImpl } from "fsx-memory";
import { readConfigFile } from "../src/example.js";
import assert from "node:assert";
describe("readConfigFile()", () => {
beforeEach(() => {
fsx.setImpl(new MemoryFsxImpl());
});
afterEach(() => {
fsx.resetImpl();
});
it("should read config file", async () => {
await fsx.write("config.json", JSON.stringify({ found: true });
const result = await readConfigFile();
assert.isTrue(result.found);
});
});
这就是使用 fsx 在内存中模拟整个文件系统是多么容易。您不必像模块加载器拦截那样担心导入所有测试模块的顺序,也不需要经历包含模拟库的过程以确保一切正常。您只需更换测试的 impl,然后再重置它。通过这种方式,您可以以更高性能且不易出错的方式测试文件系统操作。
命名注意事项
不幸的是,在我发布 fsx 的时候,亚马逊发布了一款名为 FSx 的产品。如果它获得任何支持,我可能会重命名这个库,欢迎提出建议。
希望得到结论和反馈
长期以来,我们一直在使用 JavaScript 运行时中笨拙的低级文件系统 API。fsx 库是我尝试重新想象现代文件系统 API 的样子,如果我们花一些时间关注最常见的情况,并改进 JavaScript 语言目前提供的人体工学设计。通过从头开始重新思考,我认为 fsx 为我们提供了一种更愉快的文件系统体验。
基础库只关注我最常用的方法,但我计划在了解和思考用例后添加更多方法。您今天就可以试用,欢迎反馈。我很想知道你的想法!