humanfs(以前称为 fsx)简介:适用于 JavaScript 的现代文件系统 API

84 阅读8分钟

很长一段时间以来,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在内存中轻松模拟整个文件系统的简便方式。您无需像处理模块加载器拦截那样担心为测试导入所有模块的顺序,也不必经历引入模拟库以确保一切正常工作的过程。只需在测试时替换实现,然后在测试后重置即可。通过这种方式,您可以更高效且更少出错地测试文件系统操作。

原文:humanwhocodes.com/blog/2024/0…