fsx 簡介:適用于 JavaScript 的現代文件系統 API
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 的子類,但仍然存在不兼容性,這使得使用 Buffers 有問題。)
即使是 Ryan Dhal 在 Node.js 上的繼任者 Deno,也沒有在文件系統 API 上做太多的改進,它基本上遵循了與 Node.js 中的 fs 模塊相同的模式,盡管它使用了 Uint8Arrays,而 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 "node-fsx";
使用 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[2] 的產品。如果它獲得任何支持,我可能會重命名這個庫,歡迎提出建議。
希望得到結論和反饋
長期以來,我們一直在使用 JavaScript 運行時中笨拙的低級文件系統 API。fsx 庫是我嘗試重新想象現代文件系統 API 的樣子,如果我們花一些時間關注最常見的情況,并改進 JavaScript 語言目前提供的人體工學設計。通過從頭開始重新思考,我認為 fsx 為我們提供了一種更愉快的文件系統體驗。
基礎庫只關注我最常用的方法,但我計劃在了解和思考用例后添加更多方法。您今天就可以試用,歡迎反饋。我很想知道你的想法!