JavaScript 的现代文件系统

150 阅读9分钟

Node.js 最初于 2009 年发布,随之而来的是 fs 模块。fs 模块围绕 Linux 的核心实用工具构建,许多方法都反映了它们在 Linux 上的灵感,如 rmdir、mkdir 和 stat。为此,Node.js 成功地创建了一个能够处理开发者在命令行上所期望完成的任何事情的低级文件系统 API。不幸的是,创新就此停止。

对 Node.js 文件系统 API 最大的改变是引入了 fs/promises,将整个实用程序从基于回调的方法转换为基于 Promise 的方法。较小的增量变化包括实现 Web Streams,并确保读取器也实现了异步迭代器。API 仍然使用专有的 Buffer 类来读取二进制数据。(尽管 Buffer 现在是 Uint8Array 的子类,仍然存在不兼容性问题,使得使用 Buffers 变得棘手。)

即使是 Deno,Ryan Dahl 的 Node.js 继任者,也没有太多现代化文件系统 API 的工作。它主要遵循 Node.js fs 模块的相同模式,尽管在各个地方使用 Uint8Arrays 代替 Buffers,并在各个地方使用异步迭代器。即使如此,它仍然是 Node.js 采用的相同低级 API 方法。

只有 Bun,最新加入服务器端 JavaScript 运行时生态系统的条目,甚至尝试通过 Bun.file() 对文件系统 API 进行现代化改进,灵感来自 fetch()。虽然我赞扬这种重新思考如何处理文件的方法,但每次要处理多个文件时(处理数千个文件时性能下降严重),为每个要处理的文件创建一个新对象可能会很麻烦。此外,Bun 仍旧预期您在其他操作上使用旧有的 Node.js fs 模块

在多年的 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 - fsx API 的 Node.js 绑定
  • 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 coalescing 运算符指定默认值,例如:

 // 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)

我认为这种方法比在 4202 年不断担心文件不存在的错误更符合 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', bytes)

作为额外的奖励,fsx.write() 将自动创建任何尚不存在的目录。这是我经常遇到的另一个问题,我认为在现代文件系统 API 中,这种情况应该 “just works (直接能用且好用)”(即:没有额外的东西需要担心和处理)。

使用 fsx 检测文件

要确定文件是否存在,请使用 fsx.isFile(filePath) 方法,如果给定文件存在则返回 true,否则返回 false。

 if (await fsx.isFile('/path/to/file.txt')) {
   // handle the file
 }

与 fs.stat() 不同,如果文件不存在,此方法只是返回 false,而不是引发错误。与等效的 fs.stat () 代码相比:

删除文件和目录

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 - 与日志相关的附加数据

对于方法调用,日志条目的类型是 “call”,data 属性是一个包含:

  • methodName - 被调用的方法的名称
  • args - 传递给方法的参数数组。

对于上述示例,logs 将包含单个条目:

 // example log entry
 {
   "timestamp": 123456789,
   "type": "call",
   "data": {
     "methodName": "isFile",
     "args": ["/path/to/file.txt"]
   }
 }

有了这个信息,您可以轻松设置测试中的日志记录,然后检查调用了哪些方法,而无需第三方库进行 spy。

使用 fsx impl

fsx 的设计是包含在 fsx-core 包中的抽象核心功能。每个运行时包都使用运行时特定的文件系统操作的实现来扩展该功能,这些操作包装在称为 impl 的对象中。每个运行时包实际上导出三个东西:

  • fsx 单例
  • 一个构造函数,可让您创建另一个 fsx 实例(例如 fsx-node 中的 NodeFsx)
  • 一个构造函数,可让您为运行时包创建 impl 实例(例如 fsx-node 中的 NodeFsxImpl)

这样,您可以仅使用您想要的功能。

fsx 中的基本 impl 和活动 impl

每个 fsx 实例都使用定义了在生产中 fsx 对象应如何行为的基本 impl 创建。活动 impl 是在任何给定时间使用的 impl,可以是基本 impl,也可以不是。您可以通过调用 fsx.setImpl() 更改活动 impl。例如:

 import { fsx } from 'fsx-node'

 fsx.setImpl({
   json() {
     throw Error('This operation is not supported')
   },
 })

 // somewhere else

 await fsx.json('/path/to/file.json')

在此示例中,基本 impl 被替换为一个定制的 impl,在调用 fsx.json () 方法时抛出错误。这使得很容易为测试模拟方法,而不必担心它可能影响包含的整个 fsx 对象。

用于测试的 impl 交换

假设您有一个名为 readConfigFile() 的函数,该函数使用来自 fsx-node 的 fsx 单例,读取名为 config.json 的文件。在测试该函数时,您不希望它真的访问文件系统。您可以通过将 fsx 的 impl 替换为由 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 时,亚马逊发布了一个名为 FSx4 的产品。如果这个库有任何影响,我可能会重新命名它,欢迎提供建议。

结论和期望反馈

我们在 JavaScript 运行时中已经使用了同样笨拙、低级的文件系统 API 很长时间了。

fsx 库是我重新构想现代文件系统 API 的尝试,我花了一些时间专注于最常见的情况,并改进了 (JavaScript 语言当今能提供的) 人机工程学。

通过从头开始重新思考,我认为 fsx 提供了对更愉快的文件系统体验的一瞥。

基础库专注于我最频繁使用的方法,但我有计划在了解和思考更多场景 / 用例后,添加更多方法。您可以今天就尝试它,并且欢迎反馈。我很想知道您的想法!