fsx 簡介:適用于 JavaScript 的現(xiàn)代文件系統(tǒng) API
JavaScript 運(yùn)行時(shí)中的文件系統(tǒng) API 已經(jīng)很久沒有這么好了,這是我試圖做出一個(gè)更好的文件系統(tǒng) API 的嘗試。
我們今天擁有的 JavaScript API 比十年前要好得多??紤]一下從 XMLHttpRequest 到 fetch()的轉(zhuǎn)變:開發(fā)者體驗(yàn)顯著改善,允許我們編寫更簡潔、功能性更強(qiáng)的代碼來完成同樣的事情。異步編程的 promises 的引入允許了這種變化,以及一系列其他變化,使得 JavaScript 更容易編寫。然而,有一個(gè)領(lǐng)域幾乎沒有創(chuàng)新:服務(wù)器端 JavaScript 運(yùn)行時(shí)的文件系統(tǒng) API。
Node.js:當(dāng)今文件系統(tǒng) API 的起源
Node.js 最初發(fā)布于 2009 年,隨之誕生了 fs 模塊。fs 模塊是圍繞 Linux 的核心實(shí)用程序構(gòu)建的,其中的許多方法都反映了它們的 Linux 靈感,如 rmdir 、 mkdir 和 stat 。為此,Node.js 成功創(chuàng)建了一個(gè)低級(jí)文件系統(tǒng) API,可以處理開發(fā)人員希望在命令行上完成的任何事情。不幸的是,這就是創(chuàng)新的終點(diǎn)。
Node.js 文件系統(tǒng) API 最大的改變是引入了 fs/promises ,將整個(gè)實(shí)用程序從基于回調(diào)的方法移動(dòng)到基于 promise 的方法。較小的增量變化包括實(shí)現(xiàn) web 流和確保 reader 也實(shí)現(xiàn)了異步迭代器。該 API 仍然使用專有的 Buffer 類來讀取二進(jìn)制數(shù)據(jù)。(盡管 Buffer 現(xiàn)在是 Uint8Array 的子類,但仍然存在不兼容性,這使得使用 Buffers 有問題。)
即使是 Ryan Dhal 在 Node.js 上的繼任者 Deno,也沒有在文件系統(tǒng) API 上做太多的改進(jìn),它基本上遵循了與 Node.js 中的 fs 模塊相同的模式,盡管它使用了 Uint8Arrays,而 Node.js 使用了 Buffer s,并且在不同的地方使用了異步迭代器,但它仍然采用了與 Node.js 相同的低級(jí) API 方法。
只有 Bun,作為服務(wù)器端 JavaScript 運(yùn)行時(shí)生態(tài)系統(tǒng)的最新成員,甚至嘗試使用 Bun.file() 來更新文件系統(tǒng) API,這是受 fetch() 的啟發(fā)。雖然我贊賞這種對(duì)如何使用文件的重新思考,但當(dāng)你處理多個(gè)文件時(shí),為每個(gè)想要處理的文件創(chuàng)建一個(gè)新對(duì)象可能會(huì)很麻煩(當(dāng)處理數(shù)千個(gè)文件時(shí),會(huì)有一個(gè)巨大的性能損失)。除此之外,Bun 希望你使用 Node.js fs 模塊進(jìn)行其他操作。
一個(gè)現(xiàn)代的文件系統(tǒng) API 會(huì)是什么樣子?
在花費(fèi)數(shù)年時(shí)間在維護(hù) ESLint 的同時(shí)與 Node.js fs 模塊斗爭(zhēng)之后,我問自己,一個(gè)現(xiàn)代的文件系統(tǒng) API 會(huì)是什么樣子?
- 通常情況下會(huì)很簡單。至少 80%的時(shí)間,我不是讀取文件就是寫入文件,或者檢查文件是否存在,差不多就是這樣,然而這些操作充滿了危險(xiǎn),因?yàn)槲倚枰獧z查各種東西以避免錯(cuò)誤或記住額外的屬性(例如 { encoding: "utf8" } )。
- 錯(cuò)誤將很少發(fā)生。我對(duì) fs 模塊最大的抱怨就是它拋出錯(cuò)誤的頻率。在不存在的文件上調(diào)用 fs.stat() 會(huì)拋出錯(cuò)誤,這意味著你實(shí)際上需要將每個(gè)調(diào)用包裝在 try-catch 中。為什么?對(duì)于大多數(shù)應(yīng)用程序來說,缺少文件并不是不可恢復(fù)的錯(cuò)誤。
- 行動(dòng)將是可觀察的。在測(cè)試文件系統(tǒng)操作時(shí),我真的只是想要一種方法來驗(yàn)證我期望發(fā)生的事情是否確實(shí)發(fā)生了。我不想與其他一些實(shí)用程序建立間諜網(wǎng)絡(luò),這些實(shí)用程序可能會(huì)也可能不會(huì)改變我正在觀察的方法的實(shí)際行為。
- 模擬很容易。我總是驚訝于模擬文件系統(tǒng)操作的難度。最后我只能使用 proxyquire 之類的東西,否則就需要設(shè)置迷宮般的模擬,花上一段時(shí)間才能弄好。對(duì)于文件系統(tǒng)操作來說,這是一個(gè)很常見的需求,竟然還沒有解決方案。
帶著這些想法,我開始設(shè)計(jì) fsx。
FSX 基礎(chǔ)知識(shí)
fsx庫是我圍繞現(xiàn)代高級(jí)文件系統(tǒng) API 應(yīng)該是什么樣子的想法的結(jié)晶。在這一點(diǎn)上,它專注于支持最常見的文件系統(tǒng)操作,而把較少使用的操作(例如 chmod )拋在后面。(我并不是說這些操作在將來不會(huì)被添加,但對(duì)我來說,從最常見的情況開始,然后以與初始方法相同的謹(jǐn)慎方式構(gòu)建更多的功能是很重要的。)
使用 fsx 運(yùn)行時(shí)包
首先,fsx API 在三個(gè)運(yùn)行時(shí)包中可用。這些包都包含相同的功能,但綁定到不同的底層 API。這些包是:
- fsx-node - Node.js 中 fsx API 的綁定
- fsx-deno - fsx API 的 Deno 綁定
- fsx-memory - 適用于任何運(yùn)行時(shí)(包括 web 瀏覽器)的內(nèi)存實(shí)現(xiàn)
所以,開始時(shí),你需要使用最適合你用例的運(yùn)行時(shí)包。為了本文的目的,我將專注于 fsx-node ,但相同的 API 存在于所有運(yùn)行時(shí)包中. 所有運(yùn)行時(shí)包都導(dǎo)出一個(gè) fsx 單例,你可以以類似于 fs的方式使用它。
import { fsx } from "node-fsx";
使用 fsx 讀取文件
文件是通過使用返回特定數(shù)據(jù)類型的方法來讀取的:
- fsx.text(filePath) 讀取給定的文件并返回一個(gè)字符串。
- fsx.json(filePath) 讀取給定的文件并返回一個(gè) JSON 值。
- fsx.arrayBuffer(filePath) 讀取給定的文件并返回一個(gè) 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");
如果文件不存在,每個(gè)方法都會(huì)返回 undefined 而不是拋出錯(cuò)誤。這意味著您可以使用 if 語句而不是 try-catch,并且可以選擇使用 nullish 合并運(yùn)算符來指定默認(rèn)值,如下所示:
// 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 年比不斷擔(dān)心不存在的文件出錯(cuò)更有 JavaScript 風(fēng)格。
使用 fsx 寫文件
要寫文件,調(diào)用 fsx.write() 方法。這個(gè)方法接受兩個(gè)參數(shù):
- filePath:string - 寫入的路徑
- value:string|ArrayBuffer - 寫入文件的值
這里有一個(gè)例子:
// 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() 將自動(dòng)創(chuàng)建任何尚不存在的目錄。這是我經(jīng)常遇到的另一個(gè)問題,我認(rèn)為它應(yīng)該在現(xiàn)代文件系統(tǒng) API 中“正常工作”。
使用 fsx 檢測(cè)文件
要確定一個(gè)文件是否存在,使用 fsx.isFile(filePath) 方法,如果給定的文件存在,則返回 true ,否則返回 false 。
if (await fsx.isFile("/path/to/file.txt")) {
// handle the file
}
與 fs.stat() 不同,如果文件不存在,這個(gè)方法會(huì)返回 false ,而不是拋出錯(cuò)誤。
try {
const stat = await fs.stat(filePath);
return stat.isFile();
} catch (ex) {
if (ex.code === "ENOENT") {
return false;
}
throw ex;
}
刪除文件和目錄
fsx.delete() 方法接受一個(gè)參數(shù),即要?jiǎng)h除的路徑,并且對(duì)文件和目錄都有效。
// delete a file
await fsx.delete("/path/to/file.txt");
// delete a directory
await fsx.delete("/path/to");
fsx.delete() 方法故意過于激進(jìn):它會(huì)遞歸地刪除目錄,即使它們不是空的(實(shí)際上是 rmdir -r)。
fsx 日志
fsx 的一個(gè)關(guān)鍵特性是,由于其內(nèi)置的日志系統(tǒng),很容易確定哪些方法被調(diào)用,并使用了哪些參數(shù)。要啟用 fsx 實(shí)例的日志記錄,請(qǐng)調(diào)用 logStart() 方法并傳入一個(gè)日志名稱。當(dāng)你完成日志記錄時(shí),請(qǐng)調(diào)用 logEnd() 并傳入相同的名稱來檢索日志條目的數(shù)組。
fsx.logStart("test1");
const fileFound = await fsx.isFile("/path/to/file.txt");
const logs = fsx.logEnd("test1");
每個(gè)日志條目都是一個(gè)包含以下屬性的對(duì)象:
- timestamp - 創(chuàng)建日志的數(shù)字時(shí)間戳
- type - 描述日志類型的字符串
- data - 與日志相關(guān)的附加數(shù)據(jù)
對(duì)于方法調(diào)用,日志條目的 type 是 call ,而 data 屬性是一個(gè)對(duì)象,包含:
- methodName - 被調(diào)用的方法的名稱
- args - 傳遞給方法的參數(shù)數(shù)組。
對(duì)于前面的例子, logs 將包含一個(gè)條目:
// example log entry
{
timestamp: 123456789,
type: "call",
data: {
methodName: "isFile",
args: ["/path/to/file.txt"]
}
}
了解這一點(diǎn)后,您可以輕松地在測(cè)試中設(shè)置日志記錄,然后檢查調(diào)用了哪些方法,而無需使用第三方間諜庫。
使用 fsx impls
fsx 的設(shè)計(jì)是這樣的,抽象的核心功能包含在 fsx-core 包中,每個(gè)運(yùn)行時(shí)包都擴(kuò)展了該功能,使用特定于運(yùn)行時(shí)的文件系統(tǒng)操作實(shí)現(xiàn),這些操作被包裝在一個(gè)稱為 impl 的對(duì)象中。
- fsx 單例
- 一個(gè)構(gòu)造函數(shù),可以創(chuàng)建 fsx 的另一個(gè)實(shí)例(比如 fsx-node 中的 NodeFsx )
- 一個(gè)構(gòu)造函數(shù),可以創(chuàng)建運(yùn)行時(shí)包的 impl 實(shí)例(如 node-fsx 中的 NodeFsxImpl )。
這可以讓您只使用所需的功能。
fsx 中的 base impls 和 active impls
每個(gè) fsx 實(shí)例都有一個(gè) base 類實(shí)現(xiàn),它定義了 fsx 對(duì)象在生產(chǎn)環(huán)境中的行為。active impls 是在任何給定時(shí)間使用的實(shí)現(xiàn),它可能也是 base 類實(shí)現(xiàn),也可能不是。你可以調(diào)用 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
在此示例中,基本實(shí)現(xiàn)被替換為自定義實(shí)現(xiàn),該自定義實(shí)現(xiàn)在調(diào)用 fsx.json() 方法時(shí)會(huì)引發(fā)錯(cuò)誤。這使得您可以輕松地模擬測(cè)試方法,而不必?fù)?dān)心它可能如何影響整個(gè)包含的 fsx 對(duì)象。
交換 impls 進(jìn)行測(cè)試
假設(shè)你有一個(gè)名為 readConfigFile() 的函數(shù),它使用了來自 node-fsx 的 fsx 單例來讀取名為 config.json 的文件,當(dāng)測(cè)試這個(gè)函數(shù)時(shí),你不想讓它實(shí)際訪問文件系統(tǒng),你可以把 fsx 的實(shí)現(xiàn)換成 fsx-memory 提供的內(nèi)存文件系統(tǒng)實(shí)現(xiàn),如下:
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 在內(nèi)存中模擬整個(gè)文件系統(tǒng)是多么容易。您不必像模塊加載器攔截那樣擔(dān)心導(dǎo)入所有測(cè)試模塊的順序,也不需要經(jīng)歷包含模擬庫的過程以確保一切正常。您只需更換測(cè)試的 impl,然后再重置它。通過這種方式,您可以以更高性能且不易出錯(cuò)的方式測(cè)試文件系統(tǒng)操作。
命名注意事項(xiàng)
不幸的是,在我發(fā)布 fsx 的時(shí)候,亞馬遜發(fā)布了一款名為 FSx[2] 的產(chǎn)品。如果它獲得任何支持,我可能會(huì)重命名這個(gè)庫,歡迎提出建議。
希望得到結(jié)論和反饋
長期以來,我們一直在使用 JavaScript 運(yùn)行時(shí)中笨拙的低級(jí)文件系統(tǒng) API。fsx 庫是我嘗試重新想象現(xiàn)代文件系統(tǒng) API 的樣子,如果我們花一些時(shí)間關(guān)注最常見的情況,并改進(jìn) JavaScript 語言目前提供的人體工學(xué)設(shè)計(jì)。通過從頭開始重新思考,我認(rèn)為 fsx 為我們提供了一種更愉快的文件系統(tǒng)體驗(yàn)。
基礎(chǔ)庫只關(guān)注我最常用的方法,但我計(jì)劃在了解和思考用例后添加更多方法。您今天就可以試用,歡迎反饋。我很想知道你的想法!