【Node.js実戦習得】第9回:Expressミドルウェアの深淵を覗く!共通ロギングと集中エラーハンドリング設計

技術ブログ

1. ミドルウェアの本質:数珠つなぎの「パイプライン処理」

酒本先輩、第8回で Express を使ってカート API の基本ルーティングを作れるようになりました!

でも、app.use(express.json()) とか next() とか、呪文みたいに使っている『ミドルウェア』って、実際の中身はどういう仕組みになっているんですか?

いい疑問ね!Express の正体は、一言で言えば『ミドルウェア関数が順番に実行されるパイプライン(数珠つなぎの鎖)』なのよ。

 

HTTP リクエストが届くと、Express は登録されたミドルウェアを上から順番に通過させていくの。工場のベルトコンベアをイメージしてみて!

  • ロギングミドルウェア: 「リクエストの時刻と URL をログに記録するぞ」(次へ next()
  • ボディパケットミドルウェア: 「JSON 文字列を JavaScript オブジェクトにパースするぞ」(次へ next()
  • ルーティング処理: 「カート処理を実行してレスポンスを返すぞ」(ここでレスポンスを出力して終了!)
[HTTP Request] 
      │
      ▼
┌───────────────┐
│ 1. Logger     │ → console.log(...)
└───────┬───────┘
        │ next()
        ▼
┌───────────────┐
│ 2. JSON Parser│ → req.body の生成
└───────┬───────┘
        │ next()
        ▼
┌───────────────┐
│ 3. Router     │ → res.json(...) でレスポンス返却!
└───────────────┘

このパイプラインの流れを制御するのが next() 関数よ。もし next()res.send() も呼ばないと、リクエストはパイプラインの途中で止まってブラウザが永遠に待機(タイムアウト)しちゃうの。

2. 【ハンズオン】自作ロギングミドルウェアの実装

まずはパイプラインの挙動を体感するために、全リクエストの処理時間を自動計測する独自のロギングミドルウェア requestLogger を作ってみましょう!

// src/index.ts
import express, { Request, Response, NextFunction } from 'express';

const app = express();
const PORT = 3000;

app.use(express.json());

// ==========================================
//  自作ミドルウェア①:アクセスカウンター&ロガー
// ==========================================
const requestLogger = (req: Request, res: Response, next: NextFunction) => {
  const start = Date.now(); // 処理を開始したタイムスタンプ
  const { method, url } = req;

  // テクニック:レスポンスが「完了(finish)」した時のイベントをフックする
  res.on('finish', () => {
    const duration = Date.now() - start; // かかった時間を計算
    const statusCode = res.statusCode;   // 最終的なステータスコード

    console.log(`[LOG] ${new Date().toISOString()} | ${method} ${url} | Status: ${statusCode} | ${duration}ms`);
  });

  // 超重要:次のミドルウェアへ処理をパスする!
  next();
};

// アプリケーション全体にこのミドルウェアを適用(グローバルミドルウェア)
app.use(requestLogger);

// --- 以下、前回のECカートAPIのデータ構造 ---
interface CartItem {
  id: number;
  productName: string;
  price: number;
  quantity: number;
}

let cartItems: CartItem[] = [
  { id: 1, productName: '熊木用高級キーボード', price: 35000, quantity: 1 }
];

// カート内商品の一覧取得
app.get('/api/cart', (req: Request, res: Response) => {
  res.status(200).json(cartItems);
});

app.listen(PORT, () => {
  console.log(` サーバー起動: http://localhost:${PORT}`);
});

3. 同期処理エラーの自動キャッチと「集中エラーハンドラー」

共通ログが作れたところで、ここからが今回の本題よ。
熊木くん、もしコードの中で思わぬバグがあって、ルーティングの中で例外(Error)が投げられたらどうなると思う?

えっと、すべてのルーティングに try-catch を書いておかないと、エラーが漏れ出ちゃいますよね……?

すべてのルーティングに try-catch を手動で書くなんて、人間だから絶対に漏れが発生するわ。
実はExpressは、ルーティングの中で投げられた例外を自動でキャッチして、専用のミドルウェアへ退避させる仕組みを持っているの。
その受け皿となるのが、Express『集中エラーハンドリングミドルウェア(Error Handling Middleware)』よ!

 

Expressにおけるエラーハンドリングミドルウェアは、通常のミドルウェアとは定義の方法が明確に異なります。
なんと、引数を4つ受け取る特別な構造になっているの。

// 引数が4つあることで、Expressは「あ、これはエラー専用のミドルウェアだな」と自動認識するの
app.use((err: any, req: Request, res: Response, next: NextFunction) => { ... });

さあ、この仕組みを使って集中エラーハンドリング機構を組み込んでみましょう。わざとエラーを発生させるデバッグ用のルーティングも追加してみて!

/* 先ほどのコードの末尾、app.listenの手前に以下を追加 */

//  わざと同期エラーを発生させるデバッグ用ルーティング
app.get('/api/debug-sync-error', (req: Request, res: Response) => {
  // 意図的に例外を発生させる
  throw new Error('データベースとの接続に致命的な障害が発生しました!');
});

// ==========================================
//  特別なミドルウェア②:集中エラーハンドラー
//  必ずすべてのルーティング(app.getなど)よりも下に記述すること!
// ==========================================
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
  // 1. エラーのログを詳細に記録(スタックトレースもコンソールに出力)
  console.error(`[ ERROR OCCURRED] ${new Date().toISOString()}`);
  console.error(err.stack);

  // 2. フロントエンドに対して「500 Internal Server Error」を整ったJSON形式で返却
  // ※セキュリティのため、生のエラー原因(err.message)をそのまま一般ユーザーに見せないのが実務の鉄則!
  res.status(500).json({
    status: 'error',
    message: 'サーバー内部で予期せぬエラーが発生しました。システム管理者に連絡してください。'
  });
});

追加しました!
さっそく、curl で叩いてみます!

$ curl -i http://localhost:3000/api/debug-sync-error

実行結果(curl 側のターミナル)

HTTP/1.1 500 Internal Server Error
Content-Type: application/json; charset=utf-8

{"status":"error","message":"サーバー内部で予期せぬエラーが発生しました。システム管理者に連絡してください。"}

実行結果(サーバー側のターミナル)

[ ERROR OCCURRED] 2026-08-10T02:20:00.000Z
Error: データベースとの接続に致命的な障害が発生しました!
    at /Users/kumaki/nodejs-sample/node-ts-core/src/index.ts:48:9
    ...(スタックトレースが続く)
[LOG] 2026-08-10T02:20:00.000Z | GET /api/debug-sync-error | Status: 500 | 8ms

お、おおお!

try-catch を一切書いていないのに、Expressが勝手にエラーを拾って最下部の集中エラーハンドラーに飛ばしてくれました!

curl 側にも即座に500エラーのJSONが返っています!

これが集中エラーハンドラーの効果よ!
ルーティング内で例外が発生すると、Expressはベルトコンベアの通常ルートから『エラー専用避難ルート(引数4つのミドルウェア)』へリクエストを即座にワープさせてくれるのよ

4. 非同期コールバック内エラーによるプロセス停止問題

同期処理のエラーは集中エラーハンドラーで拾えることがわかりました。
では、setTimeout などの非同期処理の中でエラーを投げるエンドポイントを作ってみます!

// コールバック内で直接 throw を実行するテスト用ルーティング
app.get('/api/debug-async-error', (req: Request, res: Response) => {
  setTimeout(() => {
    throw new Error('非同期エラー');
  }, 1000);
});

curl を送信してみます。

curl -i http://localhost:3000/api/debug-async-error

実行結果(curl 側のターミナル)

curl: (52) Empty reply from server

実行結果(サーバー側のターミナル)

Error: 非同期エラー
    at Timeout._onTimeout (/var/docker/nodejs-sample/node-ts-demo/src/index.ts:62:11)
    at listOnTimeout (node:internal/timers:573:17)
    at process.processTimers (node:internal/timers:514:7)

Node.js v20.11.1

あれ!?

集中エラーハンドラーで拾われずに curl: (52) Empty reply from server と表示され、サーバープロセス自体がクラッシュしてしまいました……

これが非同期コールバック(イベントループ)の仕組みによる問題よ。

  • setTimeout のコールバックは、Express のミドルウェアの実行スタックが終了した後に実行される。
  • タイマー発火時のコンテキストには Express のエラーハンドラが存在しないため、未補足の例外(uncaughtException)となり Node.js プロセスごとクラッシュする。
  • レスポンスを出力する前にプロセスが停止するため、curl 側は接続を途中で切断されて Empty reply from server になる。

5. 【修正版】Promise による安全なエラー伝播と完全コード

非同期処理でのエラーを集中エラーハンドラーに安全に渡すには、非同期処理を Promise 化して reject を呼ぶか、async/await の文脈に乗せる必要があるわ。

タイマー処理を Promise で包み、reject() を呼ぶように修正しましょう。

// src/index.ts (完全版)
import express, { Request, Response, NextFunction } from 'express';

const app = express();
const PORT = 3000;

app.use(express.json());

// 1. 自作アクセスカウンター&ロガー
const requestLogger = (req: Request, res: Response, next: NextFunction) => {
  const start = Date.now();
  const { method, url } = req;

  res.on('finish', () => {
    const duration = Date.now() - start;
    console.log(`[LOG] ${new Date().toISOString()} | ${method} ${url} | Status: ${res.statusCode} | ${duration}ms`);
  });

  next();
};

app.use(requestLogger);

// カート一覧取得
app.get('/api/cart', (req: Request, res: Response) => {
  res.json([{ id: 1, productName: '熊木用高級キーボード', price: 35000, quantity: 1 }]);
});

// 2. 同期エラーのデバッグルーティング
app.get('/api/debug-sync-error', (req: Request, res: Response) => {
  throw new Error('データベースとの接続に致命的な障害が発生しました!');
});

// 3. 非同期エラーの修正版(Promise 化して reject を使用)
app.get('/api/debug-async-error', async (req: Request, res: Response) => {
  await new Promise((_, reject) => {
    setTimeout(() => {
      reject(new Error('非同期エラーの爆弾'));
    }, 1000);
  });
  res.json({ message: '成功' });
});

// 4. 404 ハンドラー
app.use((req: Request, res: Response, next: NextFunction) => {
  res.status(404).json({ error: 'リクエストされたリソースが見つかりませんでした。' });
});

// 5. 集中エラーハンドラー(最下段に配置)
app.use((err: any, req: Request, res: Response, next: NextFunction) => {
  console.error(`[ ERROR OCCURRED] ${new Date().toISOString()}`);
  console.error(err.stack);

  res.status(500).json({
    status: 'error',
    message: 'サーバー内部で予期せぬエラーが発生しました。システム管理者に連絡してください。'
  });
});

app.listen(PORT, () => {
  console.log(` サーバー起動: http://localhost:${PORT}`);
});

修正後の curl 実行結果

curl -i http://localhost:3000/api/debug-async-error
HTTP/1.1 500 Internal Server Error
Content-Type: application/json; charset=utf-8

{"status":"error","message":"サーバー内部で予期せぬエラーが発生しました。システム管理者に連絡してください。"}

Promise 化して reject を渡すことで、サーバーが落ちることなく集中エラーハンドラーで JSON 形式の 500 レスポンスを返せるようになりました!

本日のまとめ

  • ミドルウェアのパイプライン: リクエストからレスポンスまでの処理を順次実行する。next() で後続へ渡す。
  • 集中エラーハンドラー: 引数が 4 つ (err, req, res, next) のミドルウェア。最下段に配置し、エラー処理を一元化する。
  • 非同期コールバックの注意点: setTimeout などのコールバック内での直接 throw は Express の外で例外が発生するため、Node.js プロセスが停止して Empty reply from server となる。Promise 化して reject することで安全にハンドリングする。

次回:第10回「Prisma×SQLiteで始める型安全データベース操作とORM入門」に続く

タイトルとURLをコピーしました