Node.jsでファイル操作を行う際、必ず使用するのがfs(File System)モジュールです。しかし、コールバック形式、Promise形式、同期形式と複数のAPIが存在し、どれを使えばよいか迷う方も多いでしょう。

本記事では、Node.js公式が推奨するfs/promisesモジュールを中心に、ファイルの読み書きを非同期で安全に実装する方法を解説します。readFilewriteFileappendFileの基本的な使い方から、エンコーディングの指定、同期APIとの使い分けまで、実践的なコード例を通じて習得できます。

実行環境

項目 バージョン
Node.js 20.x LTS以上
npm 10.x以上
OS Windows/macOS/Linux

前提条件

  • JavaScriptの基礎知識(関数、オブジェクト、async/await)
  • Node.jsの基本操作(スクリプト実行、npm利用)経験
  • Promiseの基本的な理解

fsモジュールの3つのAPI形式

Node.jsのfsモジュールには、3つの異なるAPI形式が用意されています。それぞれの特徴を理解し、適切な場面で使い分けることが重要です。

API形式の比較

API形式 インポート方法 特徴 推奨度
Promise API node:fs/promises async/awaitで記述可能 推奨
Callback API node:fs 従来型、ネスト深くなりがち 互換用
Sync API node:fs 同期処理、イベントループをブロック 限定的

Promise APIを推奨する理由

Node.js公式ドキュメントでは、新規開発にはfs/promisesの使用を推奨しています。主な理由は以下の通りです。

  1. 可読性の向上: async/awaitで同期処理のように書ける
  2. エラーハンドリングの容易さ: try-catchで統一的に処理できる
  3. コールバック地獄の回避: ネストが深くならない
  4. モダンなJavaScript: Promiseベースの他のAPIとの親和性が高い
1
2
3
4
5
// fs/promisesのインポート(推奨)
import { readFile, writeFile } from 'node:fs/promises';

// 従来のfsモジュール(コールバック・同期API用)
import fs from 'node:fs';

node:プレフィックスは、Node.js組み込みモジュールであることを明示的に示します。Node.js 16以降で使用可能で、同名のnpmパッケージとの衝突を防ぐ効果があります。

readFileでファイルを読み込む

readFileは、ファイルの内容を一括で読み込む関数です。小〜中規模のファイル(数MB程度まで)に適しています。

基本的な使い方

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import { readFile } from 'node:fs/promises';

async function readTextFile() {
  try {
    // utf8エンコーディングでテキストとして読み込む
    const content = await readFile('./data/message.txt', 'utf8');
    console.log('ファイル内容:', content);
    return content;
  } catch (error) {
    if (error.code === 'ENOENT') {
      console.error('ファイルが見つかりません:', error.path);
    } else if (error.code === 'EACCES') {
      console.error('ファイルへのアクセス権限がありません');
    } else {
      console.error('ファイル読み込みエラー:', error.message);
    }
    throw error;
  }
}

await readTextFile();

エンコーディングの指定

readFileの第2引数でエンコーディングを指定できます。指定方法は文字列またはオプションオブジェクトの2通りがあります。

1
2
3
4
5
6
7
import { readFile } from 'node:fs/promises';

// 文字列で指定(シンプル)
const text1 = await readFile('./file.txt', 'utf8');

// オプションオブジェクトで指定(詳細な制御)
const text2 = await readFile('./file.txt', { encoding: 'utf8' });

主要なエンコーディング

エンコーディング 用途
utf8 / utf-8 一般的なテキストファイル(推奨)
ascii ASCII文字のみのファイル
base64 Base64エンコードされたデータ
hex 16進数文字列として読み込み
null(未指定) Bufferオブジェクトとして読み込み

Bufferとして読み込む

エンコーディングを指定しない場合、readFileはBufferオブジェクトを返します。バイナリファイル(画像、PDF等)を扱う場合や、エンコーディングを後から決定したい場合に便利です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
import { readFile } from 'node:fs/promises';

async function readBinaryFile() {
  // エンコーディング未指定でBufferとして読み込む
  const buffer = await readFile('./image.png');
  
  console.log('データ型:', buffer.constructor.name); // Buffer
  console.log('サイズ:', buffer.length, 'bytes');
  console.log('先頭8バイト:', buffer.subarray(0, 8));
  
  // 必要に応じて後からテキストに変換可能
  // const text = buffer.toString('utf8');
  
  return buffer;
}

await readBinaryFile();

JSONファイルの読み込み

設定ファイルやデータファイルとしてよく使われるJSONファイルの読み込み例です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { readFile } from 'node:fs/promises';

async function loadConfig(configPath) {
  try {
    const jsonText = await readFile(configPath, 'utf8');
    const config = JSON.parse(jsonText);
    
    console.log('設定を読み込みました:', config);
    return config;
  } catch (error) {
    if (error.code === 'ENOENT') {
      console.log('設定ファイルが見つかりません。デフォルト設定を使用します。');
      return { port: 3000, debug: false };
    }
    if (error instanceof SyntaxError) {
      console.error('JSONの形式が不正です:', error.message);
      throw error;
    }
    throw error;
  }
}

const config = await loadConfig('./config.json');

writeFileでファイルに書き込む

writeFileは、データをファイルに書き込む関数です。ファイルが存在しない場合は新規作成し、存在する場合は内容を上書きします。

基本的な使い方

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import { writeFile } from 'node:fs/promises';

async function writeTextFile() {
  const content = 'Hello, Node.js!\nThis is a test file.';
  
  try {
    await writeFile('./output/message.txt', content, 'utf8');
    console.log('ファイルの書き込みが完了しました');
  } catch (error) {
    if (error.code === 'ENOENT') {
      console.error('ディレクトリが存在しません:', error.path);
    } else {
      console.error('書き込みエラー:', error.message);
    }
    throw error;
  }
}

await writeTextFile();

書き込みオプション

writeFileの第3引数では、詳細な書き込み設定を指定できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import { writeFile } from 'node:fs/promises';

async function writeWithOptions() {
  const data = 'Secure data content';
  
  await writeFile('./secure.txt', data, {
    encoding: 'utf8',
    mode: 0o600,    // 所有者のみ読み書き可能
    flag: 'wx',     // 新規作成のみ(既存ファイルがあればエラー)
    flush: true     // 書き込み後にディスクへ同期
  });
  
  console.log('セキュアファイルを作成しました');
}

主要なフラグ(flag)オプション

フラグ 動作
w 書き込み用に開く。ファイルがあれば上書き、なければ作成(デフォルト)
wx 書き込み用に開く。ファイルが既に存在すればエラー
a 追記用に開く。ファイルがなければ作成
ax 追記用に開く。ファイルが既に存在すればエラー

JSONファイルの書き込み

オブジェクトをJSONファイルとして保存する例です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { writeFile } from 'node:fs/promises';

async function saveConfig(config, configPath) {
  // 読みやすいフォーマットで出力(インデント2スペース)
  const jsonText = JSON.stringify(config, null, 2);
  
  await writeFile(configPath, jsonText, 'utf8');
  console.log('設定を保存しました:', configPath);
}

const config = {
  server: {
    port: 3000,
    host: 'localhost'
  },
  database: {
    name: 'myapp',
    pool: 10
  }
};

await saveConfig(config, './config.json');

Bufferの書き込み

バイナリデータをファイルに書き込む例です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import { writeFile } from 'node:fs/promises';

async function writeBinaryData() {
  // バイナリデータを作成
  const binaryData = Buffer.from([0x48, 0x65, 0x6c, 0x6c, 0x6f]);
  
  // Bufferをそのまま書き込み
  await writeFile('./binary.dat', binaryData);
  
  console.log('バイナリファイルを作成しました');
}

await writeBinaryData();

appendFileでファイルに追記する

appendFileは、既存ファイルの末尾にデータを追加する関数です。ログファイルの記録など、データを蓄積していく用途に適しています。

基本的な使い方

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import { appendFile } from 'node:fs/promises';

async function addLogEntry(message) {
  const timestamp = new Date().toISOString();
  const logEntry = `[${timestamp}] ${message}\n`;
  
  await appendFile('./app.log', logEntry, 'utf8');
  console.log('ログを追記しました');
}

await addLogEntry('アプリケーションが起動しました');
await addLogEntry('ユーザーがログインしました');
await addLogEntry('データを処理しました');

追記時の注意点

appendFileはファイルが存在しない場合、自動的に新規作成します。この動作を利用して、初回実行時にファイルを作成し、以降は追記していくパターンが実装できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import { appendFile } from 'node:fs/promises';

class SimpleLogger {
  constructor(logPath) {
    this.logPath = logPath;
  }
  
  async log(level, message) {
    const timestamp = new Date().toISOString();
    const entry = `${timestamp} [${level.toUpperCase()}] ${message}\n`;
    
    await appendFile(this.logPath, entry, 'utf8');
  }
  
  async info(message) {
    await this.log('info', message);
  }
  
  async error(message) {
    await this.log('error', message);
  }
  
  async warn(message) {
    await this.log('warn', message);
  }
}

const logger = new SimpleLogger('./application.log');
await logger.info('サーバーを起動しています...');
await logger.info('ポート3000で待機中');
await logger.warn('メモリ使用率が80%を超えました');

同期API(Sync系)の使い方

同期APIは、処理が完了するまでイベントループをブロックします。サーバーアプリケーションでは避けるべきですが、特定の場面では有用です。

同期APIが適切な場面

  1. アプリケーション起動時の設定読み込み: 設定がないと動作できない場合
  2. CLIツール: ユーザー入力を待つ単純なスクリプト
  3. ビルドスクリプト: 順序が重要なファイル処理
  4. テストコード: セットアップ・クリーンアップ処理

基本的な使い方

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { readFileSync, writeFileSync, appendFileSync } from 'node:fs';

// 同期的にファイルを読み込む
function loadConfigSync(configPath) {
  try {
    const content = readFileSync(configPath, 'utf8');
    return JSON.parse(content);
  } catch (error) {
    if (error.code === 'ENOENT') {
      console.log('設定ファイルが見つかりません。デフォルト設定を使用します。');
      return { port: 3000 };
    }
    throw error;
  }
}

// アプリケーション起動時に同期的に設定を読み込む
const config = loadConfigSync('./config.json');
console.log('設定を読み込みました:', config);

// 同期的にファイルへ書き込む
writeFileSync('./startup.log', 'アプリケーション起動\n', 'utf8');

// 同期的にファイルへ追記する
appendFileSync('./startup.log', `ポート: ${config.port}\n`, 'utf8');

同期APIと非同期APIの使い分け

以下のフローチャートを参考に、どちらのAPIを使用すべきか判断できます。

flowchart TD
    A[ファイル操作が必要] --> B{サーバーアプリケーション?}
    B -->|Yes| C{起動時の初期化?}
    B -->|No| D{CLIツール?}
    
    C -->|Yes| E[同期API可]
    C -->|No| F[非同期API推奨]
    
    D -->|Yes| G{処理が順序依存?}
    D -->|No| F
    
    G -->|Yes| E
    G -->|No| F
    
    E --> H[readFileSync等を使用]
    F --> I[fs/promisesを使用]

パフォーマンスへの影響

同期APIがイベントループをブロックする影響を理解しておくことが重要です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import { readFile } from 'node:fs/promises';
import { readFileSync } from 'node:fs';

// 非同期API: 他の処理と並行して実行可能
async function asyncExample() {
  console.log('1. 非同期読み込み開始');
  const promise = readFile('./large-file.txt', 'utf8');
  console.log('2. この行は読み込み中でも実行される');
  const content = await promise;
  console.log('3. 読み込み完了');
  return content;
}

// 同期API: 読み込み完了まで他の処理は実行されない
function syncExample() {
  console.log('1. 同期読み込み開始');
  const content = readFileSync('./large-file.txt', 'utf8');
  // ↑ ここでブロックされる
  console.log('2. 読み込み完了後にこの行が実行される');
  return content;
}

エラーハンドリングのベストプラクティス

ファイル操作では様々なエラーが発生する可能性があります。適切なエラーハンドリングを実装することで、堅牢なアプリケーションを構築できます。

よくあるエラーコード

エラーコード 意味 対処方法
ENOENT ファイル/ディレクトリが存在しない パスの確認、デフォルト値の使用
EACCES アクセス権限がない 権限の確認、実行ユーザーの変更
EISDIR ディレクトリに対してファイル操作を実行 パスの確認
ENOTDIR ファイルに対してディレクトリ操作を実行 パスの確認
EEXIST ファイルが既に存在する(wxフラグ使用時) フラグの変更、別名での保存
EMFILE 開いているファイルが多すぎる ファイルハンドルの解放

構造化されたエラーハンドリング

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { dirname } from 'node:path';

class FileOperationError extends Error {
  constructor(operation, path, originalError) {
    super(`${operation}に失敗しました: ${path}`);
    this.name = 'FileOperationError';
    this.operation = operation;
    this.path = path;
    this.code = originalError.code;
    this.originalError = originalError;
  }
}

async function safeReadFile(filePath, options = 'utf8') {
  try {
    return await readFile(filePath, options);
  } catch (error) {
    throw new FileOperationError('ファイル読み込み', filePath, error);
  }
}

async function safeWriteFile(filePath, data, options = 'utf8') {
  try {
    // ディレクトリが存在しない場合は作成
    await mkdir(dirname(filePath), { recursive: true });
    await writeFile(filePath, data, options);
  } catch (error) {
    throw new FileOperationError('ファイル書き込み', filePath, error);
  }
}

// 使用例
async function processFile() {
  try {
    const content = await safeReadFile('./input.txt');
    const processed = content.toUpperCase();
    await safeWriteFile('./output/result.txt', processed);
    console.log('処理が完了しました');
  } catch (error) {
    if (error instanceof FileOperationError) {
      console.error(`${error.operation}エラー`);
      console.error(`パス: ${error.path}`);
      console.error(`コード: ${error.code}`);
    } else {
      throw error;
    }
  }
}

await processFile();

実践的なユースケース

ここまでの知識を組み合わせた、実践的なユースケースを紹介します。

設定ファイルの読み書き

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { dirname } from 'node:path';

class ConfigManager {
  constructor(configPath) {
    this.configPath = configPath;
    this.config = null;
  }
  
  async load() {
    try {
      const content = await readFile(this.configPath, 'utf8');
      this.config = JSON.parse(content);
      console.log('設定を読み込みました');
    } catch (error) {
      if (error.code === 'ENOENT') {
        console.log('設定ファイルが見つかりません。新規作成します。');
        this.config = this.getDefaultConfig();
        await this.save();
      } else {
        throw error;
      }
    }
    return this.config;
  }
  
  async save() {
    await mkdir(dirname(this.configPath), { recursive: true });
    const content = JSON.stringify(this.config, null, 2);
    await writeFile(this.configPath, content, 'utf8');
    console.log('設定を保存しました');
  }
  
  async update(updates) {
    this.config = { ...this.config, ...updates };
    await this.save();
  }
  
  getDefaultConfig() {
    return {
      appName: 'MyApp',
      version: '1.0.0',
      settings: {
        theme: 'light',
        language: 'ja'
      }
    };
  }
}

// 使用例
const configManager = new ConfigManager('./config/app.json');
await configManager.load();
console.log('現在の設定:', configManager.config);

await configManager.update({ 
  settings: { 
    ...configManager.config.settings, 
    theme: 'dark' 
  } 
});

複数ファイルの一括処理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import { readFile, writeFile, readdir, mkdir } from 'node:fs/promises';
import { join, extname, basename } from 'node:path';

async function processAllTextFiles(inputDir, outputDir) {
  // 出力ディレクトリを作成
  await mkdir(outputDir, { recursive: true });
  
  // ディレクトリ内のファイル一覧を取得
  const files = await readdir(inputDir);
  const textFiles = files.filter(file => extname(file) === '.txt');
  
  console.log(`${textFiles.length}個のテキストファイルを処理します`);
  
  // 並列で処理
  const results = await Promise.all(
    textFiles.map(async (file) => {
      const inputPath = join(inputDir, file);
      const outputPath = join(outputDir, `processed_${file}`);
      
      try {
        const content = await readFile(inputPath, 'utf8');
        const processed = content
          .toUpperCase()
          .split('\n')
          .map((line, i) => `${i + 1}: ${line}`)
          .join('\n');
        
        await writeFile(outputPath, processed, 'utf8');
        return { file, success: true };
      } catch (error) {
        return { file, success: false, error: error.message };
      }
    })
  );
  
  // 結果をレポート
  const successful = results.filter(r => r.success);
  const failed = results.filter(r => !r.success);
  
  console.log(`成功: ${successful.length}件`);
  if (failed.length > 0) {
    console.log(`失敗: ${failed.length}件`);
    failed.forEach(r => console.log(`  - ${r.file}: ${r.error}`));
  }
}

await processAllTextFiles('./input', './output');

まとめ

Node.jsのfsモジュールを使ったファイル操作について、以下のポイントを解説しました。

  • fs/promisesを使用する: async/awaitで可読性の高いコードが書ける
  • エンコーディングを意識する: テキストはutf8、バイナリはBuffer
  • エラーハンドリングを適切に行う: エラーコードに応じた処理を実装
  • 同期APIは限定的に使用する: 起動時の設定読み込みやCLIツールに限定

次のステップとして、大容量ファイルを効率的に処理するStreamや、ディレクトリ操作(mkdirreaddirstat)について学ぶことをお勧めします。

参考リンク