12k
All articles

Node.jsで非同期呼び出しをまたいでコンテキストを保持する

Node.jsのAsyncLocalStorageでasync呼び出しをまたいでリクエストID、ユーザーID、tenant情報を保持する方法。run()とgetStore()を解説。

OpenReplay Team
OpenReplay Team
Node.jsで非同期呼び出しをまたいでコンテキストを保持する

HTTPリクエストの処理で、非同期呼び出しの3階層目まで踏み込んでいるとします。ロガーにはリクエストID、データベースクエリにはユーザーID、キャッシュキーにはテナントIDが必要です。これらを毎回関数のシグネチャを介して渡しますか?それはすぐに煩雑になります。

Node.jsには、これに対するクリーンな解決策が組み込まれています。それがAsyncLocalStorageです。

要点

  • node:async_hooksAsyncLocalStorageは、関数シグネチャを汚すことなく、非同期境界を越えてコンテキストを伝播させます。
  • Node.js 16.4.0以降で安定版となっており、cls-hookedや低レベルなasync_hooks APIを直接利用するよりも推奨されます。
  • リクエストのエントリーポイントで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()呼び出しの内部で開始された非同期処理は、setTimeoutPromiseチェーン、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()でコンテキストが確立されているため、非同期境界を越えて自動的に伝播されます。

コンテキストに何を格納すべきか

AsyncLocalStorageは、リクエストスコープでありながらビジネスロジックの一部ではない、横断的な関心事に適しています。

  • リクエストID:分散トレーシングやログの関連付けに
  • 認証済みユーザーやテナントのメタデータ:マルチテナントアプリケーションに
  • トレースコンテキスト:OpenTelemetryのようなツールに
  • フィーチャーフラグ:リクエスト時に解決されるもの

大きなオブジェクトや頻繁に変更されるものを格納するのは避けてください。ストアは小さく保ち、初期化後は読み取り中心として扱いましょう。

注意点:コンテキストの喪失

ネイティブでないPromise実装や古いコールバックベースのAPIを使用していると、コンテキストが失われることがあります。予期せずgetStore()undefinedを返す場合は、その非同期処理がrun()の呼び出し内部で開始されたかを確認してください。コールバックベースのコードをutil.promisify()でラップすることで解決することが多いですが、独自の非同期リソースの場合はAsyncResourceが必要になることもあります。

まとめ

AsyncLocalStorageは、現実の問題をエレガントに解決します。リクエストのメタデータをすべての関数呼び出しに引き渡す代わりに、リクエスト境界で一度コンテキストを確立し、必要な場所で読み取るだけで済みます。Node.jsのAPIやSSRアプリケーションにおける、リクエストスコープのロギング、トレーシング、認証コンテキストに最適なツールです。

FAQ

AsyncLocalStorageには目立つパフォーマンスコストがありますか?

Node.jsがストアを伝播させるために非同期リソースを追跡しなければならないため、わずかなオーバーヘッドはありますが、一般的なWebワークロードではそのコストは無視できる程度です。最近のNode.jsバージョンでパフォーマンスは大幅に改善されており、すべての関数呼び出しを介して手動でコンテキストを引き回すことに比べれば、通常はそのトレードオフに見合う価値があります。

1つのアプリケーション内で複数のAsyncLocalStorageインスタンスを使用できますか?

はい。ロギングコンテキスト、トレーシング、テナントデータなど、関心事ごとに別々のインスタンスを作成できます。各インスタンスは独立したストアを保持するため、互いに干渉しません。ただし、コード全体で同じ参照が使われるように、各インスタンスはモジュールレベルのシングルトンとして保持してください。

AsyncLocalStorageはワーカースレッドで安全に使えますか?

各ワーカースレッドは独自の分離されたAsyncLocalStorage状態を持つため、コンテキストはスレッド境界を越えません。ワーカーとリクエストコンテキストを共有する必要がある場合は、ワーカーのメッセージチャネルを通じて関連データを明示的に渡し、ワーカー内部で別のrun()呼び出しによってストアを再確立してください。

AsyncLocalStorageと明示的にコンテキストを渡す方法はどう違いますか?

明示的に渡す方法はより予測可能でテストもしやすいですが、関数シグネチャを煩雑にし、実際にはそのデータを必要としない中間層を汚染します。AsyncLocalStorageはロギングやトレーシングなどの横断的関心事に最適ですが、ビジネス上重要なデータは引数を通じて流すことで、コードの明瞭性とテスタビリティを保つべきです。

DevTools for the frontend

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.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.