Node.js AsyncLocalStorage - requestIdをすべての関数に渡す時代は終わりました
問題
Expressでリクエストごとのロギングを実装する場合、requestIdをすべての関数に引数として渡す必要がありました。
app.get('/users/:id', (req, res) => {
const requestId = crypto.randomUUID();
const user = getUser(req.params.id, requestId);
res.json(user);
});
function getUser(id, requestId) {
logger.info(`[${requestId}] getUser called`);
return queryDB(`SELECT * FROM users WHERE id = $1`, [id], requestId);
}
function queryDB(sql, params, requestId) {
logger.info(`[${requestId}] query: ${sql}`);
// ...
}
ビジネスロジックと関係のないrequestIdが、すべての関数シグネチャに入り込んでしまいます。
解決方法
AsyncLocalStorageを使えば、非同期コールチェーン全体にコンテキストを暗黙的に伝播できます。Node.js組み込みなので、追加パッケージは不要です。
const { AsyncLocalStorage } = require('node:async_hooks');
const crypto = require('node:crypto');
const asyncLocalStorage = new AsyncLocalStorage();
// Expressミドルウェア
function requestContext(req, res, next) {
const store = {
requestId: crypto.randomUUID(),
method: req.method,
path: req.path,
};
asyncLocalStorage.run(store, next);
}
app.use(requestContext);
これで、どこからでもgetStore()でコンテキストにアクセスできます。
// ロガーユーティリティ
function createLogger(module) {
return {
info(msg) {
const store = asyncLocalStorage.getStore();
const requestId = store?.requestId ?? 'no-context';
console.log(JSON.stringify({
timestamp: new Date().toISOString(),
requestId,
module,
msg,
}));
}
};
}
// サービスレイヤー - requestId引数がなくなりました
const logger = createLogger('user-service');
function getUser(id) {
logger.info(`getUser called: ${id}`);
return queryDB(`SELECT * FROM users WHERE id = $1`, [id]);
}
function queryDB(sql, params) {
logger.info(`query: ${sql}`);
// ...
}
同じリクエストのログは同一のrequestIdでまとまるため、デバッグ時にgrepひとつで全体の流れが把握できます。
ポイント
AsyncLocalStorageはNode.js 16.4以降でstable APIです。追加インストールは不要ですrun()内で実行されるすべての非同期コードが同じstoreを共有します。setTimeout、Promise、async/awaitすべて対応しています- パフォーマンスオーバーヘッドはほぼありません。Node.js 23+ではV8ネイティブの
AsyncContextFrameでさらに高速化されています - NestJSは
ClsModule、Fastifyは@fastify/request-contextで同様のパターンを提供しています - ロギング以外にも、認証情報、トランザクションID、マルチテナント識別などに活用できます