🐺

Express APIの実行回数を集計するカスタムミドルウェアの実装

に公開

はじめに

TypeScriptとExpressを使用したAPIサーバーにおいて、各エンドポイントの実行回数を効率的に集計するカスタムミドルウェアを実装した話です。

https://github.com/expressjs/express

宣伝:こちらライブラリとしても公開しています。

https://www.npmjs.com/package/express-endpoint-counter

実装の背景と課題

E2Eテストで「APIが期待通り実行されているか」は重要な仕様となります。
実際にシーケンシャルな処理の中で、組織を跨ぐような複雑な呼び出しがあるようなAPIが意図した通りに実行されていない課題がありました。

これらの課題を解決するために、リクエスト毎にエンドポイントの実行回数を記録するミドルウェアの実装に取り組みました。

実装プロセス

基本的なミドルウェア構造の設計

Expressのミドルウェア機能を活用し、すべてのリクエストに対して実行されるカスタムミドルウェアを実装しました。
このミドルウェアでは、リクエスト開始時に時刻を記録し、レスポンス完了時にメトリクスを収集する仕組みを構築しています。

https://expressjs.com/en/guide/using-middleware.html#using-middleware

メイン監視ミドルウェアの実装

エンドポイント毎の実行回数とレスポンス時間を集計するメインのミドルウェアを実装しました

app.use((req, res, next) => {
  const start = Date.now();
  const url = new URL(req.url, `http://${req.headers.host}`);
  
  res.on('finish', () => {
    const duration = Date.now() - start;
    const count = incrementCount(decodeURL(url));
    console.log(`Request to ${req.method} ${req.url} took ${duration}ms - called ${count} times`);
  });
  
  next();
})

実装のポイント

このミドルウェアの設計において、以下の点に特に配慮しました

  1. 非同期処理との適切な連携
    res.on('finish')を使用することで、レスポンス処理が完全に終了した時点でメトリクスを記録します。
  2. ログ出力の可読性
    出力されるログには、HTTPメソッド、URL、累計実行回数を含めることで、運用時の状況把握を容易にしています。

カウンター機能の実装

カウンター機能については、実装例として以下です。

// 呼び出し回数を管理するMapインスタンス
const apiCallCounts = new Map<string, number>()
const requestLogs = new Map<string, any[]>()

// カウンター
export const incrementCount = (endpoint: string, requestData?: any): number => {
  const current = apiCallCounts.get(endpoint) || 0
  const newCount = current + 1
  apiCallCounts.set(endpoint, newCount)

  // リクエスト詳細をログとして保存
  if (requestData) {
    const logs = requestLogs.get(endpoint) || []
    logs.push({
      count: newCount,
      ...requestData
    })
    requestLogs.set(endpoint, logs)
  }

  return newCount
}

// 指定したエンドポイントのカウントを取得
export const getCallCount = (endpoint: string): number => {
  return apiCallCounts.get(endpoint) || 0
}

// 全ての実行結果(デバッグ用)
export const getAllCallCounts = (): Record<string, number> => {
  return Object.fromEntries(apiCallCounts)
}

// シナリオ毎に消せるようリセット
export const resetCallCounts = (): void => {
  apiCallCounts.clear()
  requestLogs.clear()
}

この実装により、各エンドポイントの実行回数をメモリ上で効率的に管理できます。

e2e用のAPI作成

エンドポイントが期待通り動いているか確認をするためのAPIを追加します。

※今回はAPIをmswで定義していたのでサンプルはmswのハンドラーとなっています。

import { HttpResponse, http } from "msw";
import { getAllCallCounts, getCallCount, resetCallCounts } from "../githubApi/mockCounter";

export const trackHandlers = [
  http.get('msw/counters', () => {
    return HttpResponse.json({
      counters: getAllCallCounts()
    })
  }),
  http.get('msw/counter', async ({ request }) => {
    try {
      const url = new URL(request.url);
      const endpoint = url.searchParams.get('endpoint');
      if (!endpoint) {
        return HttpResponse.json({ error: "Endpoint not specified" }, { status: 400 });
      }
      console.log("url", request.url);
      return HttpResponse.json({
        count: getCallCount(endpoint) || 0
      })
    } catch (error) {
      console.error("Failed to parse JSON:", error);
      return HttpResponse.json({ error: "Invalid JSON in request body" }, { status: 400 });
    }
  }),
  http.post('msw/reset', () => {
    console.log("Resetting call counts");
    try {
      // Reset the call counts
      resetCallCounts();
      return HttpResponse.json({ message: "Call counts reset successfully" });
    } catch (error) {
      console.error("Failed to reset call counts:", error);
      return HttpResponse.json({ error: "Failed to reset call counts" }, { status: 500 });
    }
  })
]

E2Eステップの作成

E2Eのステップを定義します。

※ E2Eの実装例はgaugeです。

@Step("API <endpoint> に対して <n> 回の呼び出しが行われたこと")
public async implementation2812739b4a654ca0cbe1(arg0: any, arg1: any) {
  const url = new URL(arg0, process.env.API_URL || "http://localhost:3000");    
  const count: number = await ApiCounterClient.getCallCount(url.pathname);
  const expectedCount = parseInt(arg1, 10);
  assert.strictEqual(count, expectedCount, `Expected ${arg1} API to be called ${expectedCount} times, but got ${count} times.`);
}

mswで定義したAPIを呼び出す

export class ApiCounterClient {
  static async getCallCount(endpoint: string): Promise<number> {
    const response = await fetch(`${MSW_BASE_URL}/msw/counter?pathname=${endpoint}`)
    if (!response.ok) {
      throw new Error(`Failed to get call count: ${response.statusText}`)
    }
    const data: any = await response.json()
    return data.count
  }

  static async getAllCounters(): Promise<Record<string, number>> {
    const response = await fetch(`${MSW_BASE_URL}/msw/counters`)
    if (!response.ok) {
      throw new Error(`Failed to get all counters: ${response.statusText}`)
    }
    const data: any = await response.json()
    return data.counters
  }

  static async resetCounters(): Promise<void> {
    const response = await fetch(`${MSW_BASE_URL}/msw/reset`, { method: 'POST' })
    if (!response.ok) {
      throw new Error(`Failed to reset counters: ${response.statusText}`)
    }
  }
}

まとめ

  • Expressのミドルウェア機能を活用すると簡単にAPIの監視が可能
  • バックグラウンドタスクなどにも活用しやすい

複雑な実装が絡むAPIや外部APIとの連携が多いAPIは呼び出し回数を仕様書(E2E)として落とし込むことも検討すると思います。
その際にミドルウェアをうまく活用することでシンプルな実装が可能です。

今回はExpressにおけるカスタムミドルウェアの実装ではありましたが、他のライブラリへの移行も今後はしていきたいところ。

Discussion