作为一个Node.js开发者,很有可能在某些时候你已经导入了fs 模块,并编写了一些与文件系统交互的代码。
你可能不知道的是,fs 模块是一个功能齐全、基于标准、跨平台的模块,它暴露的不是一个,而是三个API,满足了同步和异步编程风格。
在这篇文章中,我们将彻底探索Windows和Linux系统中的Node.js文件处理世界,重点是fs 模块的基于承诺的API。
在我们开始之前,请注意
本文中的所有例子都是为了在Linux环境中运行,但许多例子也可以在Windows中运行。请注意文章中的注释,这些注释强调了在Windows中无法运行的例子。关于macOS - 在大多数情况下,fs 模块的工作方式与Linux相同,但有一些macOS特有的行为在本文中没有涉及。关于macOS的细微差别,请参考官方Node.js文档。
所有例子的完整源代码都可以在我的GitHub上的briandesousa/node-file-process下找到。
介绍一下fs 模块
fs 模块是Node.js中的一个核心模块。它从一开始就存在,一直可以追溯到最初的Node.js v0.x版本。
从最早期开始,fs 模块就一直与POSIX文件系统标准保持一致。这意味着你写的代码在某种程度上可以在多个操作系统中移植,尽管在不同口味的Unix和Linux之间尤其如此。
尽管Windows不是一个符合POSIX标准的操作系统,但fs 模块的大多数功能仍然可以工作。然而,有些功能是不能移植的,因为某些文件系统的功能在Windows中不存在或以不同方式实现。
当我们回顾fs 模块的功能时,请记住以下功能将返回错误或在Windows上产生意外结果。
- 用于修改文件权限和所有权的功能。
chmod()chown()
- 用于处理硬链接和软链接的功能。
link()symlink()readlink()lutimes()lchmod()lchown()
- 当使用
stat(),一些元数据没有被设置或显示意外的值。lstat()
自Node v10以来,fs 模块包括三种不同的API:同步、回调和承诺。所有这三种API都暴露了相同的文件系统操作集。
本文将重点介绍较新的基于承诺的API。然而,在某些情况下,你可能想要或需要使用同步或回调API。出于这个原因,让我们花点时间来比较这三种API。
比较FS模块的API
同步API
同步 API 暴露了一组函数,这些函数阻断执行以执行文件系统操作。这些函数在你刚开始使用的时候往往是最简单的。
另一方面,它们是线程阻塞的,这与Node.js的非阻塞I/O设计非常相反。不过,有些时候,你还是必须同步处理一个文件。
下面是一个使用同步API来读取文件内容的例子。
import * as fs from 'fs';
const data = fs.readFileSync(path);
console.log(data);
回调API
回调API允许你以异步的方式与文件系统进行交互。每个回调API函数都接受一个回调函数,在操作完成后被调用。例如,我们可以用一个箭头函数调用readFile ,如果出现故障,该函数会接收一个错误,如果文件被成功读取,该函数会接收数据。
import * as fs from 'fs';
fs.readFile(path, (err, data) => {
if (err) {
console.error(err);
} else {
console.log(`file read complete, data: ${data}`);
}
});
这是一种非阻塞的方法,通常更适合于Node.js应用程序,但它也有自己的挑战。在异步编程中使用回调,往往会导致回调地狱。如果你不注意你的代码结构,你可能最终会有一个复杂的嵌套回调函数的堆栈,这可能很难阅读和维护。
答应API
如果同步API应尽可能避免,而回调API可能并不理想,那就只剩下承诺API了。
import * as fsPromises from 'fs/promises';
async function usingPromiseAPI(path) {
const promise = fsPromises.readFile(path);
console.log('do something else');
return await promise;
}
你可能注意到的第一件事是这个导入语句与前面的例子相比有什么不同:承诺API可以从promises 子路径中获得。如果你要导入 promise API 中的所有函数,惯例是将它们导入为fsPromises 。同步和回调API函数通常被导入为fs 。
如果你想保持示例代码的紧凑性,导入语句将在后续的示例中被省略。标准的导入命名惯例将被用来区分API:fs 来访问同步和回调函数,而fsPromises 来访问承诺函数。
promise API允许你利用JavaScript的async/await语法糖,以同步的方式编写异步代码。上面第4行调用的readFile() 函数返回一个承诺。接下来的代码似乎是同步执行的。最后,从函数中返回承诺。await 操作符是可选的,但由于我们已经包含了它,该函数将等待文件操作完成后再返回。
现在是时候测试一下promise API了。适应一下吧。有相当多的函数要讲,包括创建、读取和更新文件和文件元数据的函数。
与文件一起工作
使用文件句柄
promise API提供了两种不同的方法来处理文件。
第一种方法是使用一组接受文件路径的顶级函数。这些函数在内部管理文件和目录资源句柄的生命周期。当你完成对文件或目录的处理时,你不需要担心调用一个close() 的函数。
第二种方法是使用一组在FileHandle 对象上可用的函数。一个FileHandle ,作为对文件系统中的文件或目录的引用。下面是你如何获得一个FileHandle 对象。
async function openFile(path) {
let fileHandle;
try {
fileHandle = await fsPromises.open(path, 'r');
console.log(`opened ${path}, file descriptor is ${fileHandle.fd}`);
const data = fileHandle.read()
} catch (err) {
console.error(err.message);
} finally {
fileHandle?.close();
}
}
在上面第4行,我们使用fsPromises.open() ,为一个文件创建一个FileHandle 。我们传递r 标志,表示该文件应以只读模式打开。任何试图修改该文件的操作都会失败。(你也可以指定其他标志)。
使用read() 函数读取文件的内容,该函数可以直接从文件句柄对象中获得。在第10行,我们需要明确地关闭文件句柄以避免潜在的内存泄漏。
FileHandle 类中的所有函数也都可以作为顶层函数使用。我们将继续探索顶层函数,但知道这种方法也是可用的,这很好。
读取文件
读取一个文件似乎是一个很简单的任务。然而,有几个不同的选项可以指定,这取决于你需要对文件做什么。
// example 1: simple read
const data = await fsPromises.readFile(path);
// example 2: read a file that doesn't exist (creates a new file)
const noData = await fsPromises.readFile(path, { flag: 'w'});
// example 3: read a file and return its contents as a base64-encoded string
const base64data = await fsPromises.readFile(path, { encoding: 'base64' });
// example 4: read a file but abort the operation before it completes
const controller = new AbortController();
const { signal } = controller;
const promise = fsPromises.readFile(path, { signal: signal });
console.log(`started reading file at ${path}`);
controller.abort();
console.log('read operation aborted before it could be completed')
await promise;
例1是最简单的,如果你想做的只是获取一个文件的内容。
在例2中,我们不知道文件是否存在,所以我们传递了w 文件系统标志,以便在必要时先创建它。
例3演示了如何改变返回数据的格式。
例4演示了如何中断一个文件读取操作并中止它。这在读取较大或读取速度较慢的文件时可能很有用。
复制文件
copyFile 函数可以复制一个文件,并给你一些控制,如果目标文件已经存在,会发生什么。
// example 1: create a copy, overwite the destination file if it exists already
await fsPromises.copyFile('source.txt', 'dest.txt');
// example 2: create a copy but fail because the destination file exists already
await fsPromises.copyFile('source.txt', 'dest.txt', fs.constants.COPYFILE_EXCL);
// Error: EEXIST: file already exists, copyfile 'source.txt' -> 'dest.txt'
例1将覆盖dest.txt ,如果它已经存在。在例2中,我们传入COPYFILE_EXCL 标志来覆盖默认行为,如果dest.txt 已经存在,则失败。
写入文件
有三种方法可以向文件写入。
- 追加到一个文件
- 写入文件
- 截断一个文件
这些函数中的每一个都有助于实现不同的用例。
// example 1: append to an existing file
// content of data.txt before: 12345
await fsPromises.appendFile('data.txt', '67890');
// content of data.txt after: 1234567890
// example 2: append to a file that doesn't exist yet
await fsPromises.appendFile('data2.txt', '123');
// Error: ENOENT: no such file or directory, open 'data2.txt'
// example 3: write to an existing file
// content of data3.txt before: 12345
await fsPromises.writeFile('data3.txt', '67890');
// content of data3.txt after: 67890
// example 4: write to a file that doesn't exist yet (new file is created)
await fsPromises.writeFile('data4.txt', '12345');
// example 5: truncate data in an existing file
// content of data5.txt before: 1234567890
await fsPromises.truncate('data5.txt', 5);
// content of data5.txt after: 12345
例1和例2演示了如何使用appendFile 函数将数据追加到现有或新的文件中。如果一个文件不存在,appendFile 将首先创建它。
例3和例4演示了如何使用writeFile 函数向现有或新的文件写入数据。如果一个文件不存在,writeFile 函数也会在写入前创建一个文件。然而,如果文件已经存在并且包含数据,文件的内容就会被覆盖,不会有任何警告。
例5演示了如何使用truncate 函数来修剪一个文件的内容。传递给这个函数的参数一开始会让人困惑。你可能以为truncate 函数会接受要从文件末尾剥离的字符数,但实际上我们需要指定要保留的字符数。在上面的例子中,你可以看到我们向truncate 函数输入了一个值5 ,它从字符串1234567890 中删除了最后五个字符。
观察文件
promise API提供了一个单一的、可执行的watch 函数,可以监视文件的变化。
const abortController = new AbortController();
const { signal } = abortController;
setTimeout(() => abortController.abort(), 3000);
const watchEventAsyncIterator = fsPromises.watch(path, { signal });
setTimeout(() => {
fs.writeFileSync(path, 'new data');
console.log(`modified ${path}`);
}, 1000);
for await (const event of watchEventAsyncIterator) {
console.log(`'${event.eventType}' watch event was raised for ${event.filename}`);
}
// console output:
// modified ./data/watchTest.txt
// 'change' watch event was raised for watchTest.txt
// watch on ./data/watchTest.txt aborted
watch 函数可以无限期地观察一个文件的变化。每次观察到一个变化,就会引发一个观察事件。watch 函数返回一个异步迭代器,这本质上是函数返回一个无界系列承诺的一种方式。在第12行,我们利用for await … of 语法糖来等待并迭代收到的每个观察事件。
你很可能不想无休止地监视一个文件的变化。可以通过使用一个特殊的信号对象来中止观察,该对象可以根据需要被触发。在第1行到第2行,我们创建了一个AbortController 的实例,这让我们可以访问一个AbortSignal 的实例,这个实例最终会传递给watch 函数。在这个例子中,我们在一段固定的时间后(在第3行指定)调用信号对象的abort() 函数,但你可以在需要的时候随意中止。
watch 函数也可以用来监视一个目录的内容。它接受一个可选的recursive 选项,决定是否监视所有子目录和文件。
文件元数据
到目前为止,我们的重点是阅读和修改一个文件的内容,但你可能还需要阅读和更新一个文件的元数据。文件元数据包括其大小、类型、权限和其他文件系统属性。
stat 函数被用来检索文件元数据,或像文件大小、权限和所有权这样的 "统计数据"。
// get all file metadata
const fileStats = await fsPromises.stat('file1.txt');
console.log(fileStats)
// console output:
// Stats {
// dev: 2080,
// mode: 33188,
// nlink: 1,
// uid: 1000,
// gid: 1000,
// rdev: 0,
// blksize: 4096,
// ino: 46735,
// size: 29,
// blocks: 8,
// atimeMs: 1630038059841.8247,
// mtimeMs: 1630038059841.8247,
// ctimeMs: 1630038059841.8247,
// birthtimeMs: 1630038059801.8247,
// atime: 2021-08-27T04:20:59.842Z,
// mtime: 2021-08-27T04:20:59.842Z,
// ctime: 2021-08-27T04:20:59.842Z,
// birthtime: 2021-08-27T04:20:59.802Z
// }
console.log(`size of file1.txt is ${fileStats.size}`);
这个例子演示了可以为一个文件或目录检索的元数据的完整列表。
请记住,这些元数据中的一些是依赖于操作系统的。例如,uid 和gid 属性代表用户和组的所有者--这个概念适用于Linux和macOS文件系统,但不适用于Windows文件系统。在Windows上运行这个函数时,这两个属性的返回值为零。
一些文件元数据可以被操作。例如,utimes 函数被用来更新文件的访问和修改时间戳。
const newAccessTime = new Date(2020,0,1);
const newModificationTime = new Date(2020,0,1);
await fsPromises.utimes('test1.txt', newAccessTime, newModificationTime);
realpath 函数对于解决相对路径和符号链接到完整路径很有用。
// convert a relative path to a full path
const realPath = await fsPromises.realpath('./test1.txt');
console.log(realPath);
// console output: /home/brian/test1.txt
// resolve the real path of a symbolic link pointing to /home/brian/test1.txt
const symLinkRealPath = await fsPromises.realpath('./symlink1');
console.log(symLinkRealPath);
// console output: /home/brian/test1.txt
文件权限和所有权
在我们继续讨论本节时,请记住,文件权限和所有权函数适用于Unix、Linux和macOS操作系统。这些功能在Windows上会产生意想不到的结果。
如果你不确定你的应用程序是否有必要的权限来访问或执行文件系统上的文件,你可以使用access 函数来测试。
// example 1: check if a file can be accessed
try {
await fsPromises.access('test1.txt');
console.log('test1.txt can be accessed');
} catch (err) {
// EACCES: permission denied, access 'test1.txt'
}
// example 2: check if a file can be executed (applies to Unix/Linux-based systems)
try {
await fsPromises.access('test2.txt', fs.constants.X_OK);
} catch(err) {
// EACCES: permission denied, access 'test2.txt'
}
文件权限可以用chmod 函数来修改。例如,我们可以通过传递一个特殊的模式字符串来删除一个文件的执行权限。
// remove all execute access from a file
await fsPromises.chmod('test1.txt', '00666');
00666 模式字符串是一个特殊的五位数,由多个描述文件属性(包括权限)的位掩码组成。最后三位数相当于你可能习惯于在Linux上传递给chmod 的三位数的权限模式。fs 模块文档提供了一个可以用来解释这个模式字符串的位掩码列表。
文件所有权也可以用chown 函数来修改。
// set user and group ownership on a file
const root_uid= 0;
const root_gid = 0;
await fsPromises.chown('test1.txt', root_uid, root_gid);
在这个例子中,我们更新文件,使其为根用户和根组所有。根用户的uid ,根组的gid ,在Linux上总是0 。
使用链接工作
提示。链接功能适用于Unix/Linux操作系统。这些函数在Windows上会产生意想不到的结果。
fs 模块提供了各种函数,你可以用来处理硬链接和软链接,或符号链接。我们已经看到的许多文件函数都有用于处理链接的同等版本。在大多数情况下,它们的操作也是相同的。
在我们开始创建链接之前,让我们快速回顾一下我们将要处理的两种类型的链接。
硬链接与软链接
硬链接和软链接是指向文件系统中其他文件的特殊类型的文件。如果被链接的文件被删除,软链接就会失效。
另一方面,指向一个文件的硬链接仍然有效,即使原始文件被删除,也会包含该文件的内容。硬链接并不指向一个文件,而是指向一个文件的基础数据。这个数据在Unix/Linux文件系统中被称为inode。
我们可以用fs 模块轻松地创建软链接和硬链接。使用symlink 函数来创建软链接,使用link 函数来创建硬链接。
// create a soft link
const softLink = await fsPromises.symlink('file.txt', 'softLinkedFile.txt');
// create a hard link
const hardLink = await fsPromises.link('file.txt', 'hardLinkedFile.txt');
如果你想确定一个链接所指向的底层文件该怎么办?这就是readlink 函数的作用。
>// read a soft link
console.log(await fsPromises.readlink('softLinkedFile.txt'));
// output: file.txt
// read a hard link... and fail
console.log(await fsPromises.readLink('hardLinkedFile.txt'));
// output: EINVAL: invalid argument, readlink 'hardLinkedFile.txt'
readlink 函数可以读取软链接,但不能读取硬链接。硬链接与它所链接的原始文件是没有区别的。事实上,所有文件在技术上都是硬链接。readlink 函数基本上将其视为另一个普通文件,并抛出一个EINVAL 错误。
unlink 函数可以同时删除硬链接和软链接。
// delete a soft link
await fsPromises.unlink('softLinkedFile.txt');
// delete a hard link / file
await fsPromises.unlink('hardLinkedFile.txt');
unlink 函数实际上是一个通用函数,也可用于删除普通文件,因为它们在本质上与硬链接相同。除了link 和unlink 函数外,所有其他链接函数都是为了用于软链接。
你可以像修改普通文件的元数据一样修改软链接的元数据。
// view soft link meta data
const linkStats = await fsPromises.lstat(path);
// update access and modify timestamps on a soft link
const newAccessTime = new Date(2020,0,1);
const newModifyTime = new Date(2020,0,1);
await fsPromises.lutimes('softLinkedFile.txt', newAccessTime, newModifyTime);
// remove all execute access from a soft link
await fsPromises.lchmod('softLinkedFile.txt', '00666');
// set user and group ownership on a soft link
const root_uid= 0;
const root_gid = 0;
await fsPromises.lchown('softLinkedFile.txt', root_uid, root_gid);
除了每个函数的前缀是l ,这些函数的操作与它们对应的文件函数完全一样。
与目录一起工作
我们不能只停留在文件处理上。如果你正在处理文件,你也不可避免地需要处理目录。fs 模块为创建、修改和删除目录提供了各种功能。
与我们前面看到的open 函数非常相似,opendir 函数以Dir 对象的形式返回一个目录的句柄。Dir 对象暴露了几个函数,可以用来对该目录进行操作。
let dir;
try {
dir = await fsPromises.opendir('sampleDir');
dirents = await dir.read();
} catch (err) {
console.log(err);
} finally {
dir.close();
}
当你完成对该目录的操作时,请确保调用close 函数来释放该目录的句柄。
fs 模块还包括为你隐藏目录资源句柄的打开和关闭的函数。例如,你可以创建、重命名和删除目录。
// example 1: create a directory
await fsPromises.mkdir('sampleDir');
// example 2: create multiple nested directories
await fsPromises.mkdir('nested1/nested2/nested3', { recursive: true });
// example 3: rename a directory
await fsPromises.rename('sampleDir', 'sampleDirRenamed');
// example 4: remove a directory
await fsPromises.rmdir('sampleDirRenamed');
// example 5: remove a directory tree
await fsPromises.rm('nested1', { recursive: true });
// example 6: remove a directory tree, ignore errors if it doesn't exist
await fsPromises.rm('nested1', { recursive: true, force: true });
例2、例5和例6演示了recursive 选项,如果你在创建或删除一个路径之前不知道它是否会存在,这个选项就特别有用。
有两个选项可以读取一个目录的内容。默认情况下,readdir 函数返回所请求的目录正下方所有文件和文件夹的名称列表。
你可以通过withFileTypes 选项来获得一个Dirent 目录条目对象的列表。这些对象包含了请求目录中每个文件系统对象的名称和类型。比如说。
// example 1: get names of files and directories
const files = await fsPromises.readdir('anotherDir');
for (const file in files) {
console.log(file);
}
// example 2: get files and directories as 'Dirent' directory entry objects
const dirents = await fsPromises.readdir('anotherDir', {withFileTypes: true});
for (const entry in dirents) {
if (entry.isFile()) {
console.log(`file name: ${entry.name}`);
} else if (entry.isDirectory()) {
console.log(`directory name: ${entry.name}`);
} else if (entry.isSymbolicLink()) {
console.log(`symbolic link name: ${entry.name}`);
}
}
readdir 函数没有提供一个递归选项来读取子目录的内容。你必须编写你自己的递归函数或依靠第三方模块,如 recursive-readdir]().
关闭()
现在是时候为本文的资源句柄close() 。我们已经彻底了解了如何使用Node.js的fs 模块来处理文件、链接和目录。文件处理在Node.js中是开箱即用的,功能齐全,随时可以使用。
The postFile processing in Node.js:全面指南》首次出现在LogRocket博客上。