很长一段时间以来,JavaScript 运行时中的文件系统 API 都不是很好。于是我试图做出更好的尝试。
我们今天拥有的 JavaScript API 比十年前的 API 要好得多。开发人员体验要好得多,使我们能够编写更简洁、功能更强大的代码。然而,有一个领域几乎没有创新:服务器端 JavaScript 运行时中的文件系统。
Node.js:当今文件系统 API 的起源
Node.js 最初于 2009 年发布,随之而来的是 fs
该模块的诞生。该 fs
模块是围绕 Linux 的核心实用程序构建的,例如 rmdir
、 mkdir
和 stat
。Node.js成功地创建了一个低级文件系统 API,可以处理开发人员希望在命令行上完成的任何事情。不幸的是,创新停止了。
Node.js 文件系统 API 的最大变化是引入了 fs/promises
将整个程序从基于回调的方法转移到基于 promise 的方法。较小的更改包括实现 Web 流和异步迭代器。API 仍然使用专有 Buffer
类来读取二进制数据。
甚至连Deno,在现代化文件系统API方面也没有做出太多改变。它主要沿用了Node.js fs模块的相同模式,只是在某些地方将Node.js中的Buffers替换为Uint8Arrays,并使用了异步迭代器。除此之外,它仍然采用了与Node.js相同的底层API设计方法。
只有 Bun 是服务器端 JavaScript 运行时生态系统的最新成员,它尝试使用 Bun.file()
,其灵感来自 fetch()
。但当你处理多个文件时,要处理的每个文件创建一个新对象可能会很麻烦(在处理数千个文件时,性能会下降很多)。
现代文件系统 API 会是什么样子?
在维护 ESLint 的同时,我花了数年时间与 Node.js fs
模块作斗争,我问自己,现代文件系统 API 会是什么样子?
- 常见情况很容易。至少 80% 的时间,我要么在读取文件,要么在写入文件,要么检查文件是否存在。差不多就是这样。然而,这些操作充满了危险,因为我需要检查各种事情以避免错误。
- 错误很少见。我对该
fs
模块最大的抱怨是它抛出错误的频率。调用不存在的文件会引发错误,这意味着您需要将每个调用fs.stat()
包装在try-catch
.为什么?对于大多数应用程序来说,丢失文件并不是不可恢复的错误。 - 操作将是可观察的。在测试文件系统操作时,我真的只是想要一种方法来验证我预期发生的事情是否真的发生了。我不想使用其他一些实用程序来设置网络,这些实用程序可能会也可能不会改变我正在观察的方法的实际行为。
- 模拟很容易。我总是惊讶于模拟文件系统操作是多么困难。我最终使用了类似
proxyquire
的东西。这是文件系统操作的常见要求,令人惊讶的是,没有解决方案存在。
HumanFS 基础知识
humanfs 库是我关于现代高级 filesytem API 所有想法的结晶。在这一点上,它专注于支持最常见的文件系统操作。
使用 humanfs 软件包
首先,humanfs API在四个运行时包中可用。这些包都包含相同的功能,但分别与不同的底层API关联。
这些软件包包括:
@humanfs/node
- Node.js绑定@humanfs/deno
- Deno 绑定@humanfs/web
- Web 浏览器绑定(使用私有文件系统)@humanfs/memory
- 适用于任何运行时(包括 Web 浏览器)
出于本文的目的,我将重点介绍 @humanfs/node
,但所有运行时包上都存在相同的 API。所有运行时包都导出一个 hfs
单例,您可以以类似于 fs
的方式使用该单例。
import { fsx } from "@humanfs/node";
使用 fsx 读取文件
读取文件方法:
hfs.text(filePath)
读取文件并返回一个字符串。hfs.json(filePath)
读取文件并返回 JSON 值。hfs.bytes(filePath)
读取文件并返回Uint8Array
。
以下是一些示例:
// read plain text
const text = await hfs.text("/path/to/file.txt");
// read JSON
const json = await hfs.json("/path/to/file.json");
// read bytes
const bytes = await hfs.bytes("/path/to/file.png");
如果文件不存在,则每个方法都会返回 undefined
,而不是引发错误。这意味着您可以使用 if
语句而不是 try-catch
,并且可以选择使用空值合并运算符来指定默认值,如下所示:
// read plain text
const text = await hfs.text("/path/to/file.txt") ?? "default value";
// read JSON
const json = await hfs.json("/path/to/file.json") ?? {};
// read bytes
const bytes = await hfs.bytes("/path/to/file.png") ?? new Uint8Array();
使用 fsx 写入文件
若要写入文件,请调用该 hfs.write()
方法。此方法接受两个参数:
filePath:string
- 写入路径value:string|ArrayBuffer|ArrayBufferView
- 要写入文件的值
下面是一个示例:
// write a string
await hfs.write("/path/to/file.txt", "Hello world!");
const bytes = new TextEncoder().encode("Hello world!");
// write a buffer
await hfs.write("/path/to/file.txt", bytes);
作为额外的奖励, hfs.write()
将自动创建任何尚不存在的目录。
使用 humanfs 检测文件
若要确定文件是否存在,请使用 hfs.isFile(filePath)
方法,该方法将返回 true
判断给定文件是否存在。
if (await hfs.isFile("/path/to/file.txt")) {
// handle the file
}
与 fs.stat()
不同的是,如果文件不存在,此方法只会返回 false
,而不是抛出错误。与等效 fs.stat()
代码进行比较:
try {
const stat = await fs.stat(filePath);
return stat.isFile();
} catch (ex) {
if (ex.code === "ENOENT") {
return false;
}
throw ex;
}
删除文件和目录
该 hfs.delete()
方法接受单个参数,即要删除的路径,并适用于文件和目录。
// delete a file
await hfs.delete("/path/to/file.txt");
// delete a directory
await hfs.delete("/path/to");
HumanFS 日志记录
humanfs 的一个关键特性是,由于其内置的日志记录系统,确定哪些方法被调用了哪些参数是比较容易。要在 hfs
实例上启用日志记录,请调用该 logStart()
方法并传入日志名称。完成日志记录后,调用 logEnd()
并传入相同的名称以检索日志条目。下面是一个示例:
hfs.logStart("test1");
const fileFound = await hfs.isFile("/path/to/file.txt");
const logs = hfs.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 imples
fsx 的设计是这样的, @humanfs/core
包中包含抽象的核心功能。每个运行时包都使用特定于运行时的文件系统实现来扩展功能,这些实现包含在称为 impl 的对象中。每个运行时包实际上导出三件事:
hfs
单例- 允许您创建另一个实例的
hfs
构造函数(例如NodeHfs
in@humanfs/node
) - 一个构造函数,允许您为运行时包创建 impl 实例(例如
NodeHfsImpl
in@humanfs/node
)
基础 impls 和活动 impls
每个 hfs
实例都使用一个基本 impl 创建,该 impl 定义 hfs
对象在生产中的行为方式。
您可以通过调用 hfs.setImpl()
来更改活动 impl。例如:
import { fsx } from "@humanfs/node";
hfs.setImpl({
json() {
throw Error("This operation is not supported");
}
})
// somewhere else
await hfs.json("/path/to/file.json"); // throws error
在此示例中,基本 impl 被换成自定义 impl 。在调用该 hfs.json()
方法时引发错误。这样可以很容易地模拟测试的方法,而不必担心它可能会对整个 hfs
对象产生什么影响。
使用 impls 进行测试
假设您有一个名为readConfigFile()的函数,该函数利用@humanfs/node中的hfs单例来读取一个名为config.json的文件。当需要测试这个函数时,您肯定不希望它真正去访问文件系统。这时,您可以替换hfs的实现,并用@humanfs/memory提供的内存文件系统实现进行替换,如下所示:
import { hfs } from "@humanfs/node";
import { MemoryHfsImpl } from "@humanfs/memory";
import { readConfigFile } from "../src/example.js";
import assert from "node:assert";
describe("readConfigFile()", () => {
beforeEach(() => {
hfs.setImpl(new MemoryHfsImpl());
});
afterEach(() => {
hfs.resetImpl();
});
it("should read config file", async () => {
await hfs.write("config.json", JSON.stringify({ found: true });
const result = await readConfigFile();
assert.isTrue(result.found);
});
});
这就是使用humanfs在内存中轻松模拟整个文件系统的简便方式。您无需像处理模块加载器拦截那样担心为测试导入所有模块的顺序,也不必经历引入模拟库以确保一切正常工作的过程。只需在测试时替换实现,然后在测试后重置即可。通过这种方式,您可以更高效且更少出错地测试文件系统操作。