使用 Node.js 编写 Shell 脚本
您可以购买本书的离线版本(HTML、PDF、EPUB、MOBI),并支持免费在线版本。
(广告,请不要屏蔽。)

8 在 Node.js 上使用文件系统



本章包含

鉴于本书的重点是 shell 脚本,我们只处理文本数据。

8.1 Node 文件系统 API 的概念、模式和约定

8.1.1 访问文件的方式

  1. 我们可以通过字符串读取或写入文件的全部内容。
  2. 我们可以打开一个用于读取的流或一个用于写入的流,并一次处理一小部分文件。流只允许顺序访问。
  3. 我们可以使用文件描述符或 FileHandles,并通过与流大致相似的 API 获取顺序访问和随机访问。
    • 文件描述符 是表示文件的整数。它们通过以下函数进行管理(仅显示同步名称,还有基于回调的版本 - fs.open() 等)
      • fs.openSync(path, flags?, mode?) 为给定路径的文件打开一个新的文件描述符并返回它。
      • fs.closeSync(fd) 关闭文件描述符。
      • fs.fchmodSync(fd, mode)
      • fs.fchownSync(fd, uid, gid)
      • fs.fdatasyncSync(fd)
      • fs.fstatSync(fd, options?)
      • fs.fsyncSync(fd)
      • fs.ftruncateSync(fd, len?)
      • fs.futimesSync(fd, atime, mtime)
    • 只有同步 API 和基于回调的 API 使用文件描述符。基于 Promise 的 API 有一个更好的抽象,FileHandle,它基于文件描述符。实例通过 fsPromises.open() 创建。各种操作通过方法(而不是函数)提供
      • fileHandle.close()
      • fileHandle.chmod(mode)
      • fileHandle.chown(uid, gid)
      • 等等。

请注意,我们在本章中没有使用 (3) - (1) 和 (2) 就足以满足我们的目的。

8.1.2 函数名称前缀

名称以“l”开头的函数通常对符号链接进行操作

8.1.2.2 前缀“f”:文件描述符

名称以“f”开头的函数通常管理文件描述符

8.1.3 重要的类

几个类在 Node 的文件系统 API 中扮演着重要的角色。

8.1.3.1 URL:字符串中文件系统路径的替代方案

每当 Node.js 函数接受字符串中的文件系统路径(行 A)时,它通常也接受 URL 的实例(行 B)

assert.equal(
  fs.readFileSync(
    '/tmp/text-file.txt', {encoding: 'utf-8'}), // (A)
  'Text content'
);
assert.equal(
  fs.readFileSync(
    new URL('file:///tmp/text-file.txt'), {encoding: 'utf-8'}), // (B)
  'Text content'
);

手动在路径和 file: URL 之间转换看似容易,但却有许多令人惊讶的陷阱:百分号编码或解码、Windows 驱动器号等。相反,最好使用以下两个函数

我们在本章中没有使用文件 URL。它们的用例在 §7.11.1 “类 URL 中有描述。

8.1.3.2 缓冲区

Buffer 表示 Node.js 上的固定长度字节序列。它是 Uint8Array 的子类(TypedArray)。缓冲区主要用于处理二进制文件,因此在本书中不太受关注。

每当 Node.js 接受 Buffer 时,它也接受 Uint8Array。因此,鉴于 Uint8Arrays 是跨平台的而 Buffer 不是,前者更可取。

缓冲区可以做 Uint8Arrays 做不到的一件事:以各种编码对文本进行编码和解码。如果我们需要在 Uint8Arrays 中编码或解码 UTF-8,我们可以使用类 TextEncoder 或类 TextDecoder。这些类在大多数 JavaScript 平台上都可用

> new TextEncoder().encode('café')
Uint8Array.of(99, 97, 102, 195, 169)
> new TextDecoder().decode(Uint8Array.of(99, 97, 102, 195, 169))
'café'
8.1.3.3 Node.js 流

一些函数接受或返回原生 Node.js 流

现在,我们可以在 Node.js 上使用跨平台的*Web 流*,而不是原生流。如何操作在 §10 “在 Node.js 上使用 Web 流” 中有解释。

8.2 读取和写入文件

8.2.1 将文件同步读取到单个字符串中(可选:拆分为行)

fs.readFileSync(filePath, options?)filePath 处的文件读取到单个字符串中

assert.equal(
  fs.readFileSync('text-file.txt', {encoding: 'utf-8'}),
  'there\r\nare\nmultiple\nlines'
);

这种方法的优缺点(与使用流相比)

接下来,我们将研究如何将读取的字符串拆分为行。

8.2.1.1 拆分行而不包含行终止符

以下代码将字符串拆分为行,同时删除行终止符。它适用于 Unix 和 Windows 行终止符

const RE_SPLIT_EOL = /\r?\n/;
function splitLines(str) {
  return str.split(RE_SPLIT_EOL);
}
assert.deepEqual(
  splitLines('there\r\nare\nmultiple\nlines'),
  ['there', 'are', 'multiple', 'lines']
);

“EOL”代表“行尾”。我们接受 Unix 行终止符 ('\n') 和 Windows 行终止符 ('\r\n',如上一个示例中的第一个)。有关更多信息,请参阅 §8.3 “跨平台处理行终止符”

8.2.1.2 拆分行并包含行终止符

以下代码将字符串拆分为行,同时包含行终止符。它适用于 Unix 和 Windows 行终止符(“EOL”代表“行尾”)

const RE_SPLIT_AFTER_EOL = /(?<=\r?\n)/; // (A)
function splitLinesWithEols(str) {
  return str.split(RE_SPLIT_AFTER_EOL);
}

assert.deepEqual(
  splitLinesWithEols('there\r\nare\nmultiple\nlines'),
  ['there\r\n', 'are\n', 'multiple\n', 'lines']
);
assert.deepEqual(
  splitLinesWithEols('first\n\nthird'),
  ['first\n', '\n', 'third']
);
assert.deepEqual(
  splitLinesWithEols('EOL at the end\n'),
  ['EOL at the end\n']
);
assert.deepEqual(
  splitLinesWithEols(''),
  ['']
);

行 A 包含一个带有 后视断言 的正则表达式。它匹配模式 \r?\n 匹配之前的那些位置,但它不捕获任何内容。因此,它不会删除输入字符串拆分成的字符串片段之间的任何内容。

在不支持后视断言的引擎上(请参阅此表),我们可以使用以下解决方案

function splitLinesWithEols(str) {
  if (str.length === 0) return [''];
  const lines = [];
  let prevEnd = 0;
  while (prevEnd < str.length) {
    // Searching for '\n' means we’ll also find '\r\n'
    const newlineIndex = str.indexOf('\n', prevEnd);
    // If there is a newline, it’s included in the line
    const end = newlineIndex < 0 ? str.length : newlineIndex+1;
    lines.push(str.slice(prevEnd, end));
    prevEnd = end;
  }
  return lines;
}

此解决方案很简单,但更冗长。

在这两个版本的 splitLinesWithEols() 中,我们再次接受 Unix 行终止符 ('\n') 和 Windows 行终止符 ('\r\n')。有关更多信息,请参阅 §8.3 “跨平台处理行终止符”

8.2.2 逐行通过流读取文件

我们也可以通过流读取文本文件

import {Readable} from 'node:stream';

const nodeReadable = fs.createReadStream(
  'text-file.txt', {encoding: 'utf-8'});
const webReadableStream = Readable.toWeb(nodeReadable);
const lineStream = webReadableStream.pipeThrough(
  new ChunksToLinesStream());
for await (const line of lineStream) {
  console.log(line);
}

// Output:
// 'there\r\n'
// 'are\n'
// 'multiple\n'
// 'lines'

我们使用了以下外部功能

Web 流是 异步可迭代的,这就是为什么我们可以使用 for-await-of 循环来迭代行。

如果我们对文本行不感兴趣,那么我们不需要 ChunksToLinesStream,可以迭代 webReadableStream 并获取任意长度的块。

更多信息

这种方法的优缺点(与读取单个字符串相比)

8.2.3 将单个字符串同步写入文件

fs.writeFileSync(filePath, str, options?)str 写入 filePath 处的文件。如果该路径下已存在文件,则会被覆盖。

以下代码展示了如何使用此函数

fs.writeFileSync(
  'new-file.txt',
  'First line\nSecond line\n',
  {encoding: 'utf-8'}
);

有关行终止符的信息,请参阅 §8.3 “跨平台处理行终止符”

优缺点(与使用流相比)

8.2.4 将单个字符串追加到文件(同步)

以下代码将一行文本追加到现有文件

fs.appendFileSync(
  'existing-file.txt',
  'Appended line\n',
  {encoding: 'utf-8'}
);

我们也可以使用 fs.writeFileSync() 来执行此任务

fs.writeFileSync(
  'existing-file.txt',
  'Appended line\n',
  {encoding: 'utf-8', flag: 'a'}
);

这段代码与我们用来覆盖现有内容的代码几乎相同(有关详细信息,请参阅上一节)。唯一的区别是我们添加了选项 .flag:值 'a' 表示我们追加数据。其他可能的值(例如,如果文件尚不存在则抛出错误)在Node.js 文档中进行了解释。

注意:在某些函数中,此选项名为 .flag,而在其他函数中则名为 .flags

8.2.5 通过流将多个字符串写入文件

以下代码使用流将多个字符串写入文件

import {Writable} from 'node:stream';

const nodeWritable = fs.createWriteStream(
  'new-file.txt', {encoding: 'utf-8'});
const webWritableStream = Writable.toWeb(nodeWritable);

const writer = webWritableStream.getWriter();
try {
  await writer.write('First line\n');
  await writer.write('Second line\n');
  await writer.close();
} finally {
  writer.releaseLock()
}

我们使用了以下函数

更多信息

优缺点(与写入单个字符串相比)

8.2.6 通过流异步追加多个字符串到文件

以下代码使用流将文本追加到现有文件

import {Writable} from 'node:stream';

const nodeWritable = fs.createWriteStream(
  'existing-file.txt', {encoding: 'utf-8', flags: 'a'});
const webWritableStream = Writable.toWeb(nodeWritable);

const writer = webWritableStream.getWriter();
try {
  await writer.write('First appended line\n');
  await writer.write('Second appended line\n');
  await writer.close();
} finally {
  writer.releaseLock()
}

这段代码与我们用来覆盖现有内容的代码几乎相同(有关详细信息,请参阅上一节)。唯一的区别是我们添加了选项 .flags:值 'a' 表示我们追加数据。其他可能的值(例如,如果文件尚不存在则抛出错误)在Node.js 文档中进行了解释。

注意:在某些函数中,此选项名为 .flag,而在其他函数中则名为 .flags

8.3 跨平台处理行终止符

遗憾的是,并非所有平台都使用相同的行终止符来标记行尾 (EOL)

为了以适用于所有平台的方式处理 EOL,我们可以使用几种策略。

8.3.1 读取行终止符

读取文本时,最好识别两种 EOL。

将文本拆分为多行时,这会是什么样子?我们可以在末尾包含 EOL(任何一种格式)。如果我们修改这些行并将它们写入文件,这将使我们能够尽可能少地进行更改。

使用 EOL 处理行时,有时删除它们很有用 - 例如,通过以下函数

const RE_EOL_REMOVE = /\r?\n$/;
function removeEol(line) {
  const match = RE_EOL_REMOVE.exec(line);
  if (!match) return line;
  return line.slice(0, match.index);
}

assert.equal(
  removeEol('Windows EOL\r\n'),
  'Windows EOL'
);
assert.equal(
  removeEol('Unix EOL\n'),
  'Unix EOL'
);
assert.equal(
  removeEol('No EOL'),
  'No EOL'
);

8.3.2 写入行终止符

在写入行终止符时,我们有两个选择

8.4 遍历和创建目录

8.4.1 遍历目录

以下函数遍历目录并列出其所有后代(其子目录、子目录的子目录等)

import * as path from 'node:path';

function* traverseDirectory(dirPath) {
  const dirEntries = fs.readdirSync(dirPath, {withFileTypes: true});
  // Sort the entries to keep things more deterministic
  dirEntries.sort(
    (a, b) => a.name.localeCompare(b.name, 'en')
  );
  for (const dirEntry of dirEntries) {
    const fileName = dirEntry.name;
    const pathName = path.join(dirPath, fileName);
    yield pathName;
    if (dirEntry.isDirectory()) {
      yield* traverseDirectory(pathName);
    }
  }
}

我们使用了此功能

以下代码显示了 traverseDirectory() 的实际应用

for (const filePath of traverseDirectory('dir')) {
  console.log(filePath);
}

// Output:
// 'dir/dir-file.txt'
// 'dir/subdir'
// 'dir/subdir/subdir-file1.txt'
// 'dir/subdir/subdir-file2.csv'

8.4.2 创建目录(mkdirmkdir -p

我们可以使用以下函数来创建目录

fs.mkdirSync(thePath, options?): undefined | string

options.recursive 确定函数如何在 thePath 处创建目录

这是 mkdirSync() 的实际应用

assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
  ]
);
fs.mkdirSync('dir/sub/subsub', {recursive: true});
assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
    'dir/sub',
    'dir/sub/subsub',
  ]
);

函数 traverseDirectory(dirPath) 列出 dirPath 处目录的所有后代。

8.4.3 确保父目录存在

如果我们想按需设置嵌套文件结构,则在创建新文件时,我们不能总是确定祖先目录是否存在。那么以下函数会有所帮助

import * as path from 'node:path';

function ensureParentDirectory(filePath) {
  const parentDir = path.dirname(filePath);
  if (!fs.existsSync(parentDir)) {
    fs.mkdirSync(parentDir, {recursive: true});
  }
}

在这里,我们可以看到 ensureParentDirectory() 的实际应用(A 行)

assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
  ]
);
const filePath = 'dir/sub/subsub/new-file.txt';
ensureParentDirectory(filePath); // (A)
fs.writeFileSync(filePath, 'content', {encoding: 'utf-8'});
assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
    'dir/sub',
    'dir/sub/subsub',
    'dir/sub/subsub/new-file.txt',
  ]
);

8.4.4 创建临时目录

fs.mkdtempSync(pathPrefix, options?) 创建一个临时目录:它将 6 个随机字符附加到 pathPrefix,在新路径处创建一个目录并返回该路径。

pathPrefix 不应以大写字母“X”结尾,因为某些平台会将尾随的 X 替换为随机字符。

如果我们想在特定于操作系统的全局临时目录中创建临时目录,则可以使用函数 os.tmpdir()

import * as os from 'node:os';
import * as path from 'node:path';

const pathPrefix = path.resolve(os.tmpdir(), 'my-app');
  // e.g. '/var/folders/ph/sz0384m11vxf/T/my-app'

const tmpPath = fs.mkdtempSync(pathPrefix);
  // e.g. '/var/folders/ph/sz0384m11vxf/T/my-app1QXOXP'

重要的是要注意,临时目录不会在 Node.js 脚本终止时自动删除。我们必须自己删除它,或者依赖操作系统定期清理其全局临时目录(它可能会也可能不会这样做)。

8.5 复制、重命名、移动文件或目录

8.5.1 复制文件或目录

fs.cpSync(srcPath, destPath, options?):将文件或目录从 srcPath 复制到 destPath。有趣的选项:

这是该函数的实际应用

assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir-orig',
    'dir-orig/some-file.txt',
  ]
);
fs.cpSync('dir-orig', 'dir-copy', {recursive: true});
assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir-copy',
    'dir-copy/some-file.txt',
    'dir-orig',
    'dir-orig/some-file.txt',
  ]
);

函数 traverseDirectory(dirPath) 列出 dirPath 处目录的所有后代。

8.5.2 重命名或移动文件或目录

fs.renameSync(oldPath, newPath) 将文件或目录从 oldPath 重命名或移动到 newPath

让我们使用此函数重命名目录

assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'old-dir-name',
    'old-dir-name/some-file.txt',
  ]
);
fs.renameSync('old-dir-name', 'new-dir-name');
assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'new-dir-name',
    'new-dir-name/some-file.txt',
  ]
);

在这里,我们使用该函数移动文件

assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
    'dir/subdir',
    'dir/subdir/some-file.txt',
  ]
);
fs.renameSync('dir/subdir/some-file.txt', 'some-file.txt');
assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
    'dir/subdir',
    'some-file.txt',
  ]
);

函数 traverseDirectory(dirPath) 列出 dirPath 处目录的所有后代。

8.6 删除文件或目录

8.6.1 删除文件和任意目录(shell:rmrm -r

fs.rmSync(thePath, options?) 删除 thePath 处的文件或目录。有趣的选项:

让我们使用 fs.rmSync() 删除文件

assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
    'dir/some-file.txt',
  ]
);
fs.rmSync('dir/some-file.txt');
assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
  ]
);

在这里,我们使用 fs.rmSync() 递归删除非空目录。

assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
    'dir/subdir',
    'dir/subdir/some-file.txt',
  ]
);
fs.rmSync('dir/subdir', {recursive: true});
assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
  ]
);

函数 traverseDirectory(dirPath) 列出 dirPath 处目录的所有后代。

8.6.2 删除空目录(shell:rmdir

fs.rmdirSync(thePath, options?) 删除空目录(如果目录不为空,则会抛出异常)。

以下代码显示了此函数的工作原理

assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
    'dir/subdir',
  ]
);
fs.rmdirSync('dir/subdir');
assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
  ]
);

函数 traverseDirectory(dirPath) 列出 dirPath 处目录的所有后代。

8.6.3 清除目录

将输出保存到目录 dir 的脚本通常需要在启动之前清除 dir:删除 dir 中的每个文件,使其为空。以下函数可以做到这一点。

import * as path from 'node:path';

function clearDirectory(dirPath) {
  for (const fileName of fs.readdirSync(dirPath)) {
    const pathName = path.join(dirPath, fileName);
    fs.rmSync(pathName, {recursive: true});
  }
}

我们使用了两个文件系统函数

这是使用 clearDirectory() 的示例

assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
    'dir/dir-file.txt',
    'dir/subdir',
    'dir/subdir/subdir-file.txt'
  ]
);
clearDirectory('dir');
assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
  ]
);

8.6.4 将文件或目录移至回收站

trash 将文件和文件夹移动到回收站。它适用于 macOS、Windows 和 Linux(在 Linux 上,支持有限,需要帮助)。这是其自述文件中的一个示例

import trash from 'trash';

await trash(['*.png', '!rainbow.png']);

trash() 接受字符串数组或字符串作为其第一个参数。任何字符串都可以是 glob 模式(带有星号和其他元字符)。

8.7 读取和更改文件系统条目

8.7.1 检查文件或目录是否存在

fs.existsSync(thePath) 如果 thePath 处存在文件或目录,则返回 true

assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
    'dir/some-file.txt',
  ]
);
assert.equal(
  fs.existsSync('dir'), true
);
assert.equal(
  fs.existsSync('dir/some-file.txt'), true
);
assert.equal(
  fs.existsSync('dir/non-existent-file.txt'), false
);

函数 traverseDirectory(dirPath) 列出 dirPath 处目录的所有后代。

8.7.2 检查文件的统计信息:它是目录吗?它是什么时候创建的?等等。

fs.statSync(thePath, options?) 返回 fs.Stats 的实例,其中包含有关 thePath 处的文件或目录的信息。

有趣的 options

fs.Stats 实例的属性

在以下示例中,我们使用 fs.statSync() 来实现函数 isDirectory()

function isDirectory(thePath) {
  const stats = fs.statSync(thePath, {throwIfNoEntry: false});
  return stats !== undefined && stats.isDirectory();
}

assert.deepEqual(
  Array.from(traverseDirectory('.')),
  [
    'dir',
    'dir/some-file.txt',
  ]
);

assert.equal(
  isDirectory('dir'), true
);
assert.equal(
  isDirectory('dir/some-file.txt'), false
);
assert.equal(
  isDirectory('non-existent-dir'), false
);

函数 traverseDirectory(dirPath) 列出 dirPath 处目录的所有后代。

8.7.3 更改文件属性:权限、所有者、组、时间戳

让我们简要介绍一下用于更改文件属性的函数

用于处理硬链接的函数

用于处理符号链接的函数

以下函数对符号链接进行操作,而不会取消引用它们(请注意名称前缀“l”)

其他实用函数

影响符号链接处理方式的函数选项

8.9 扩展阅读