コンテキストの明確化:EventEmitterとAsyncLocalStorageによるリクエストスコープデータフローの構築
Ethan Miller
Product Engineer · Leapcell

はじめに
バックエンド開発、特にNode.jsの世界では、単一リクエスト内の非同期操作全体でコンテキストを管理することは、常に課題です。複数の同時リクエストを処理するWebサーバーを想像してみてください。各リクエストは、非同期で発生する多数のデータベース呼び出し、API統合、ビジネスロジックの実行を伴う可能性があります。すべての関数呼び出しで明示的にパラメータを渡すことなく、意味のあるログを提供したり、ユーザーアクションを追跡したり、リクエスト固有の設定を適用したりすることは、悪夢となり得ます。この明示的なパラメータのドリルダウンは、ボイラープレートコードを生成し、可読性を低下させ、エラーの可能性を高めます。この記事では、Node.jsの2つの強力な機能、EventEmitterとAsyncLocalStorageを組み合わせることで、アプリケーションのライフサイクル全体でリクエストスコープのコンテキストをシームレスに渡すための、堅牢でエレガントなソリューションを確立する方法を掘り下げ、保守性とオブザーバビリティを向上させます。
コアコンセプトの解明
ソリューションに飛び込む前に、アプローチの基盤となる基本的な概念を簡単に紹介しましょう。
EventEmitter
EventEmitterは、イベント駆動型プログラミングを促進するNode.jsのコアモジュールです。これはEventEmitterクラスのインスタンスであり、名前付きイベントのリスナーを登録し、それらのイベントを発行できます。イベントが発行されると、そのイベントに登録されているすべてのリスナーが同期的に呼び出されます。単一プロセス内でのリアクティブプログラミングによく使用されますが、その強みは懸念事項の分離にあります。アプリケーションの一部は、他のどの部分がリッスンまたは反応するかを知らなくてもイベントを発行できます。
AsyncLocalStorage
AsyncLocalStorageはNode.jsの比較的新しい追加機能です(Node.js v13.10.0以降、LTSユーザーはv12.17.0以降で利用可能)。非同期コンテキストにローカルなデータの保存と取得方法を提供します。これは、非同期フローの1つのポイントでデータを「設定」し、コールバックベースまたはPromiseベースの非同期操作全体でコンテキストを維持するのに、明示的に渡す必要がないため、非常に強力です。Node.jsの基盤となる非同期「フック」を活用して、データが正しい論理「フロー」または「リクエスト」に関連付けられていることを保証します。
リクエストスコープデータフローの構築
私たちの目標は、リクエストが開始されたときにリクエスト固有のデータをAsyncLocalStorageインスタンスに注入し、そのデータがイベント発行の境界を越えて、リクエストの非同期実行全体でアクセスできるようにすることです。
従来のイベント発行の問題点
特定HTTPリクエストに関連するすべてのイベントにrequestIdをログに記録したいシナリオを考えてみてください。イベントを直接発行すると、明示的にイベント引数として渡されない限り、リスナーは自動的にrequestIdにアクセスできません。
// app.js (簡略化) const express = require('express'); const EventEmitter = require('events'); const app = express(); const myEmitter = new EventEmitter(); myEmitter.on('userAction', (requestId, action) => { console.log(`[Request: ${requestId}] User performed: ${action}`); }); app.get('/do-something', (req, res) => { const requestId = req.headers['x-request-id'] || 'no-id'; // ... some logic ... myEmitter.emit('userAction', requestId, 'viewed page'); // requestId must be passed res.send('Done'); }); // This approach forces requestId to be part of every event payload.
暗黙的コンテキストのためのAsyncLocalStorageの活用
ここでAsyncLocalStorageが真価を発揮します。リクエストの開始時にrequestIdをAsyncLocalStorageに保存できます。その後、そのリクエストの非同期コンテキスト内で実行されるコードは、それを取得できます。
// app.js const express = require('express'); const EventEmitter = require('events'); const { AsyncLocalStorage } = require('async_hooks'); const app = express(); const myEmitter = new EventEmitter(); const asyncLocalStorage = new AsyncLocalStorage(); // Middleware to initialize AsyncLocalStorage for each request app.use((req, res, next) => { const requestId = req.headers['x-request-id'] || `req-${Date.now()}`; asyncLocalStorage.run({ requestId }, () => { next(); }); }); // A service that uses the emitter and needs request context class MyService { doSomethingComplex() { const store = asyncLocalStorage.getStore(); const requestId = store ? store.requestId : 'unknown'; console.log(`[Service] Performing complex task for request: ${requestId}`); // Potentially emit an event myEmitter.emit('serviceAction', 'complex logic executed'); } } const myService = new MyService(); myEmitter.on('serviceAction', (action) => { const store = asyncLocalStorage.getStore(); const requestId = store ? store.requestId : 'unknown'; console.log(`[Request: ${requestId}] Service performed: ${action}`); }); app.get('/perform-service-action', (req, res) => { myService.doSomethingComplex(); res.send('Service action requested'); }); app.listen(3000, () => { console.log('Server listening on port 3000'); });
この例では:
- ミドルウェアの設定: すべての着信リクエストをインターセプトするミドルウェアがあります。
asyncLocalStorage.run(): このミドルウェア内で、asyncLocalStorage.run({ requestId }, () => { next(); })が重要です。これは、next()関数(および後続のすべてのミドルウェアとルートハンドラ)を新しい非同期コンテキスト内で実行し、{ requestId }オブジェクトをそれにアタッチします。MyServiceでのコンテキスト: リクエストのコンテキスト内でmyService.doSomethingComplex()が呼び出されると、asyncLocalStorage.getStore()はミドルウェアによって設定されたrequestIdを正常に取得します。EventEmitterリスナーでのコンテキスト:'serviceAction'のようなイベントが発行され、そのリスナーが呼び出された場合でも、asyncLocalStorage.getStore()は正しいrequestIdへのアクセスを提供します。これは、AsyncLocalStorageがイベント発行とリスナー実行によって導入された非同期境界を越えてコンテキストを維持する方法を示しています。
このパターンにより、MyServiceやEventEmitterリスナーなどのコンポーネントは、明示的に引数として受け取ることなく、リクエスト固有の情報にアクセスできます。これにより、関数シグネチャが大幅にクリーンアップされ、関心の分離が向上します。
高度なアプリケーション:ログ記録またはトレーシングの強化
ここでは、より堅牢なログ記録ソリューションに拡張することを検討します:
// logger.js const { AsyncLocalStorage } = require('async_hooks'); const asyncLocalStorage = new AsyncLocalStorage(); function getContextualLogger() { const store = asyncLocalStorage.getStore(); const requestId = store ? store.requestId : 'N/A'; const userId = store ? store.userId : 'anonymous'; return (level, message, ...args) => { console.log(`[${new Date().toISOString()}] [${requestId}] [User:${userId}] [${level.toUpperCase()}] ${message}`, ...args); }; } // app.js (modified) // ... (express, emitter, and asyncLocalStorage for previous setup) ... // Use a custom logger based on current context const log = getContextualLogger(); app.use((req, res, next) => { const requestId = req.headers['x-request-id'] || `req-${Date.now()}`; const userId = req.headers['x-user-id'] || 'guest'; // Example for user identification asyncLocalStorage.run({ requestId, userId }, () => { log('info', `Incoming request: ${req.method} ${req.url}`); next(); }); }); myEmitter.on('dataProcessed', (data) => { log('debug', `Processed new data:`, data); }); app.post('/process-data', (req, res) => { log('info', 'Starting data processing...'); // Simulate async operation setTimeout(() => { const processedData = { /* ... */ }; myEmitter.emit('dataProcessed', processedData); log('info', 'Data processing complete.'); res.json({ status: 'success', data: processedData }); }, 100); });
これで、getContextualLogger()を介して生成されるすべてのログメッセージには、現在のリクエストに固有のrequestIdとuserIdが自動的に含まれ、デバッグとトレースがはるかに効率的になります。
結論
Node.jsのEventEmitterとAsyncLocalStorageを組み合わせることで、複雑な非同期フロー全体でリクエストスコープのコンテキストを管理するための、強力でエレガントなパターンを提供します。AsyncLocalStorageは、明示的なパラメータ渡しの負担から解放してくれます。一方、EventEmitterは、分離されたイベント処理のための柔軟なアーキテクチャを提供し続けます。この相乗効果により、オブザーバビリティとコンテキスト認識がアプリケーションのライフサイクル全体で暗黙的に強化され、すべての操作が正しいリクエスト境界内で理解されることが保証されるため、よりクリーンで保守性の高いコードベースが実現します。

