Node.jsで非同期呼び出しをまたいでコンテキストを保持する
HTTPリクエストの処理で、非同期呼び出しの3階層目まで踏み込んでいるとします。ロガーにはリクエストID、データベースクエリにはユーザーID、キャッシュキーにはテナントIDが必要です。これらを毎回関数のシグネチャを介して渡しますか?それはすぐに煩雑になります。
Node.jsには、これに対するクリーンな解決策が組み込まれています。それがAsyncLocalStorageです。
要点
node:async_hooksのAsyncLocalStorageは、関数シグネチャを汚すことなく、非同期境界を越えてコンテキストを伝播させます。- Node.js 16.4.0以降で安定版となっており、
cls-hookedや低レベルなasync_hooksAPIを直接利用するよりも推奨されます。 - リクエストのエントリーポイントで
run()を使って一度コンテキストを確立すれば、その後はどこからでもgetStore()で読み取れます。 - リクエストID、トレース情報、テナントメタデータ、認証コンテキストなどに最適ですが、ビジネスロジックの状態管理には向きません。
- ネイティブでないPromiseや旧式のコールバックAPIではコンテキストが失われることがありますが、これは通常
util.promisify()で解決できます。
非同期コンテキスト伝播の問題
同期コードでは、シンプルなグローバルスタックを使ってコンテキストを追跡できます。しかし、非同期関数はこのモデルを崩します。setTimeoutが発火したりPromiseが解決されたりすると、元のコールスタックは消失しています。単純なグローバル変数を使えば、すべての並行リクエスト間で共有されてしまい、実運用のAPIサーバーでは深刻なバグの温床となります。
AsyncLocalStorageが安定版になる前は、開発者はcls-hookedのようなライブラリや、低レベルなasync_hooksモジュールを使った独自実装に頼っていました。どちらのアプローチも脆弱です。生のasync_hooks APIは意図的に低レベルに設計されており、誤用すると無視できないパフォーマンスのオーバーヘッドが発生します。アプリケーションコードでこれを直接ベースに構築すべきではありません。
node:async_hooksの一部であるAsyncLocalStorageは、推奨される高レベルAPIです。Node.js 16.4.0以降で安定しており、AdonisJSのようなフレームワークが内部的にHTTPコンテキストを管理するために使用しています。
AsyncLocalStorageの仕組み
AsyncLocalStorageは、他の言語のスレッドローカルストレージのように動作します。ただし、Node.jsはシングルスレッドなので、「スレッド」は非同期実行コンテキストに置き換わります。run()呼び出しの内部で開始された非同期処理は、setTimeout、Promiseチェーン、await呼び出しを含め、そのコンテキストを自動的に継承します。
import { AsyncLocalStorage } from 'node:async_hooks';
const requestContext = new AsyncLocalStorage();
1つのインスタンスを作成し(通常はモジュールレベルのシングルトンとして)、各リクエストのエントリーポイントでrun()を使ってコンテキストを確立します。
リクエストスコープのロギング:実践的な例
以下は、関数の引数を一切経由せずに、すべてのログ行にリクエストIDを付与する最小限のExpressミドルウェアです。
import express from 'express';
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
const requestContext = new AsyncLocalStorage();
// Middleware: establish context for each request
function contextMiddleware(req, res, next) {
const store = { requestId: randomUUID(), userId: req.headers['x-user-id'] };
requestContext.run(store, next);
}
// Logger: reads context without any arguments
function log(message) {
const ctx = requestContext.getStore();
const prefix = ctx ? `[${ctx.requestId}]` : '[no-context]';
console.log(`${prefix} ${message}`);
}
// Simulated async database query
async function someDbQuery() {
return new Promise((resolve) => setTimeout(resolve, 50));
}
// Route handler: calls async functions freely
async function fetchUserData() {
log('Fetching user data'); // ✅ has request ID
await someDbQuery();
log('Fetched user data'); // ✅ still has request ID
}
const app = express();
app.use(contextMiddleware);
app.get('/user', async (req, res) => {
log('Request received');
await fetchUserData();
res.json({ ok: true });
});
app.listen(3000);
重要なポイントは、fetchUserDataがリクエストIDをパラメータとして受け取っていないことです。run()でコンテキストが確立されているため、非同期境界を越えて自動的に伝播されます。
Discover how at OpenReplay.com.
コンテキストに何を格納すべきか
AsyncLocalStorageは、リクエストスコープでありながらビジネスロジックの一部ではない、横断的な関心事に適しています。
- リクエストID:分散トレーシングやログの関連付けに
- 認証済みユーザーやテナントのメタデータ:マルチテナントアプリケーションに
- トレースコンテキスト:OpenTelemetryのようなツールに
- フィーチャーフラグ:リクエスト時に解決されるもの
大きなオブジェクトや頻繁に変更されるものを格納するのは避けてください。ストアは小さく保ち、初期化後は読み取り中心として扱いましょう。
注意点:コンテキストの喪失
ネイティブでないPromise実装や古いコールバックベースのAPIを使用していると、コンテキストが失われることがあります。予期せずgetStore()がundefinedを返す場合は、その非同期処理がrun()の呼び出し内部で開始されたかを確認してください。コールバックベースのコードをutil.promisify()でラップすることで解決することが多いですが、独自の非同期リソースの場合はAsyncResourceが必要になることもあります。
まとめ
AsyncLocalStorageは、現実の問題をエレガントに解決します。リクエストのメタデータをすべての関数呼び出しに引き渡す代わりに、リクエスト境界で一度コンテキストを確立し、必要な場所で読み取るだけで済みます。Node.jsのAPIやSSRアプリケーションにおける、リクエストスコープのロギング、トレーシング、認証コンテキストに最適なツールです。
FAQ
Node.jsがストアを伝播させるために非同期リソースを追跡しなければならないため、わずかなオーバーヘッドはありますが、一般的なWebワークロードではそのコストは無視できる程度です。最近のNode.jsバージョンでパフォーマンスは大幅に改善されており、すべての関数呼び出しを介して手動でコンテキストを引き回すことに比べれば、通常はそのトレードオフに見合う価値があります。
はい。ロギングコンテキスト、トレーシング、テナントデータなど、関心事ごとに別々のインスタンスを作成できます。各インスタンスは独立したストアを保持するため、互いに干渉しません。ただし、コード全体で同じ参照が使われるように、各インスタンスはモジュールレベルのシングルトンとして保持してください。
各ワーカースレッドは独自の分離されたAsyncLocalStorage状態を持つため、コンテキストはスレッド境界を越えません。ワーカーとリクエストコンテキストを共有する必要がある場合は、ワーカーのメッセージチャネルを通じて関連データを明示的に渡し、ワーカー内部で別のrun()呼び出しによってストアを再確立してください。
明示的に渡す方法はより予測可能でテストもしやすいですが、関数シグネチャを煩雑にし、実際にはそのデータを必要としない中間層を汚染します。AsyncLocalStorageはロギングやトレーシングなどの横断的関心事に最適ですが、ビジネス上重要なデータは引数を通じて流すことで、コードの明瞭性とテスタビリティを保つべきです。
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.