Node.jsによるシェルスクリプティング
この本のオフライン版(HTML、PDF、EPUB、MOBI)を購入して、無料のオンライン版をサポートできます。
(広告、ブロックしないでください。)

8章 Node.jsでのファイルシステムの操作



本章の内容

この本の焦点はシェルスクリプティングであるため、テキストデータのみを扱います。

8.1 Node.jsのファイルシステムAPIの概念、パターン、および規則

8.1.1 ファイルへのアクセス方法

  1. ファイルの全内容を文字列で読み書きできます。
  2. 読み取り用または書き込み用のストリームを開き、一度に1つずつ、より小さな部分でファイルを処理できます。ストリームは順次アクセスのみを許可します。
  3. ファイル記述子またはFileHandleを使用して、ストリームと似ている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.jsのファイルシステム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ドライブ文字など。代わりに、次の2つの関数を使用することをお勧めします。

本章ではファイルURLを使用しません。それらのユースケースについては、§7.11.1 「クラス`URL`」で説明しています。

8.1.3.2 バッファ

クラス`Buffer`は、Node.js上の固定長のバイトシーケンスを表します。これは`Uint8Array`(TypedArray)のサブクラスです。バッファは主にバイナリファイルの操作に使用されるため、この本ではあまり関心がありません。

Node.jsがBufferを受け入れる場合、Uint8Arrayも受け入れます。したがって、Uint8Arrayはクロスプラットフォームで、Bufferはクロスプラットフォームではないため、前者の方が優れています。

バッファは、Uint8Arrayではできないことを1つ実行できます。さまざまなエンコーディングでのテキストのエンコードとデコードです。Uint8Arrayで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?)`は、`filePath`のファイルに`str`を書き込みます。そのパスにファイルが既に存在する場合は、上書きされます。

次のコードは、この関数の使用方法を示しています。

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 改行文字の書き込み

改行文字の書き込みに関しては、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?) は一時ディレクトリを作成します。pathPrefixに6文字のランダムな文字を追加し、新しいパスにディレクトリを作成して、そのパスを返します。

一部のプラットフォームでは、末尾のXをランダムな文字に置き換えるため、pathPrefixは大文字の「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 ファイルと任意のディレクトリの削除(シェル: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 空のディレクトリの削除(シェル: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});
  }
}

2つのファイルシステム関数を使用しました。

これは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 ではサポートが限られており、ヘルプが必要とされています)。これは、READMEファイルからの例です。

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?) は、thePathにあるファイルまたはディレクトリに関する情報を含むfs.Statsのインスタンスを返します。

興味深い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 さらに読む