Node.jsでのrecursive readdir

やりたいこと

下記のディレクトリで、files以下に存在するファイルのパスをすべて取得したい。

.
└── files
    ├─ a
   ├─ a.txt
   └─ aa
       └─ aaa
           └─ a-aa-aaa.txt
    ├─ b
   └─ bb
       └─ b-bb.txt
    └─ c
        ├─ c.txt
        ├─ c1
  └─ c-c1.txt
        └─ c2
            └─ c-c2.txt

つまりこのような結果が得られればいい。

[
  "./files/a/a.txt",
  "./files/a/aa/aaa/a-aa-aaa.txt",
  "./files/b/bb/b-bb.txt",
  "./files/c/c.txt",
  "./files/c/c1/c-c1.txt",
  "./files/c/c2/c-c2.txt",
];

これを Node.js で実現することを考える。

Node.js の fs.readdir

Node.js でファイルシステムを扱うためのパッケージfsに、readdir関数があり、これでディレクトリ内のファイルを取得できる。 これが再帰的にディレクトリを探索してくれないかドキュメントを見てみる。

https://nodejs.org/dist/latest-v10.x/docs/api/fs.html#fs_fs_readdir_path_options_callback

どうやら{recursive: true}{depth: 100}のようなオプションを渡すことはできないようだ。

Google で解決策を調べる

Google で「node.js recursive readdir」などと調べると、以下のようなページやパッケージが見つかる

これらの実装例は

  1. fs.readdirでディレクトリ直下のファイルのリストを取得する
  2. 1 の結果をループやイテレータで回し、各ファイルのパスをfs.stat関数に渡し、fs.Statsオブジェクトを得る
  3. fs.StatsオブジェクトにはisDirectory()メソッドがあり、これで対象のファイルがディレクトリかどうか判定する 4-a. 3 の結果、ディレクトリだった場合はそのディレクトリについて、1~3 を行う 4-b. 3 の結果、ファイルだった場合は配列filesに格納し、2 の次のループに行く
  4. ループが終わったら配列filesを返す

という処理を実装している。最小限の実装例を書くと下記のようになる。 なお、ここでは処理の流れを置いやすいように同期実行される関数(fs.readdirSync, fs.statSync)を用いている。

const fs = require("fs");

const readdirRecursively = (dir, files = []) => {
  const paths = fs.readdirSync(dir);
  const dirs = [];
  for (const path of paths) {
    const stats = fs.statSync(`${dir}/${path}`);
    if (stats.isDirectory()) {
      dirs.push(`${dir}/${path}`);
    } else {
      files.push(`${dir}/${path}`);
    }
  }
  for (const d of dirs) {
    files = readdirRecursively(d, files);
  }
  return files;
};

console.log(readdirRecursively("./files"));
// =>
// [
//   './files/a/a.txt',
//   './files/a/aa/aaa/a-aa-aaa.txt',
//   './files/b/bb/b-bb.txt',
//   './files/c/c.txt',
//   './files/c/c1/c-c1.txt',
//   './files/c/c2/c-c2.txt'
// ]

やりたかったことが実現できているのがわかる。

Node.js v10.10 以降での実装

実は Node.js v10.10 以降では、fs.statを持ちいらずとも、fs.readdirだけでこの要件が解決できる方法が用意されている。 fs.readdirfs.readdirSynにオプションとして{withFileTypes: boolean}というパラメーターを渡せるようになった。(該当 PR)

これを利用しfs.readdir('filepath', {withFileTypes: true}, (err, files))とすると、files引数にはfs.Direntオブジェクトの配列(fs.Dirent[])が渡される。

fs.Direntオブジェクトdirentは以下のようなプロパティを持つ(公式ドキュメントより)

  • dirent.isBlockDevice()
  • dirent.isCharacterDevice()
  • dirent.isDirectory()
  • dirent.isFIFO()
  • dirent.isFile()
  • dirent.isSocket()
  • dirent.isSymbolicLink()
  • dirent.name

dirent.isDirectory()は、fs.Stats.isDirectoryと同じくディレクトリだった場合にtrueを返してくれるので、先述の実装をこれを用いて置き換えることができる。

const fs = require("fs");

const readdirRecursively = (dir, files = []) => {
  const dirents = fs.readdirSync(dir, { withFileTypes: true });
  const dirs = [];
  for (const dirent of dirents) {
    if (dirent.isDirectory()) dirs.push(`${dir}/${dirent.name}`);
    if (dirent.isFile()) files.push(`${dir}/${dirent.name}`);
  }
  for (const d of dirs) {
    files = readdirRecursively(d, files);
  }
  return files;
};

console.log(readdirRecursively("./files"));

これにより、fs.stat()によるファイルシステムコールを減らすことができる。

非同期関数を用いる

先述の例では同期関数であるfs.readdirSyncを使っていたが、Node.js には非同期関数が用意されているためそれを用いたい。

また、Node.js v10 では experimental feature として、fs.promisesAPI が提供されているfs.readdirでは、結果を受け取るコールバック関数を第 2 引数に渡していたが、fs.promises.readdirは結果を Promise で返してくれるため、下記のように書くことができる。

const fs = require("fs");
const fsPromises = fs.promises;
const options = {
  // options
};
fsPromises
  .readdir("path", options)
  .then((files) => {
    // Do something
  })
  .catch((err) => {
    // Handle the error
  });

これを用いて、非同期な recursive readdir を実装すると

const fs = require("fs");
const fsPromises = fs.promises;

const readdirRecursively = async (dir, files = []) => {
  const dirents = await fsPromises.readdir(dir, { withFileTypes: true });
  const dirs = [];
  for (const dirent of dirents) {
    if (dirent.isDirectory()) dirs.push(`${dir}/${dirent.name}`);
    if (dirent.isFile()) files.push(`${dir}/${dirent.name}`);
  }
  for (const d of dirs) {
    files = await readdirRecursively(d, files);
  }
  return Promise.resolve(files);
};

(async () => {
  const result = await readdirRecursively("./files").catch((err) => {
    console.error("Error:", err);
  });
  console.log(result);
})();

このような実装例になる。

また、v10 では fs.Promises API は experimental となっているため、これを実行すると警告が出るが、この記事執筆時点で Current version であるv12 では stable となっている。

おまけ dirent という概念

Linux プログラミングにおいて、readdir関数はdirent構造体を返す。 man ページによると、このdirent構造体は

struct dirent {
     ino_t          d_ino;       /* Inode number */
     off_t          d_off;       /* Not an offset; see below */
     unsigned short d_reclen;    /* Length of this record */
     unsigned char  d_type;      /* Type of file; not supported
                                    by all filesystem types */
     char           d_name[256]; /* Null-terminated filename */
};

と定義されていて、d_typeから、DT_DIRDT_REGなどファイルタイプを取得することができる。Node.js のfs.Direntオブジェクトもこれを踏襲していると思われる。

変更履歴

360d0cfd Add icon variations