Contextを引数でバケツリレーするのはもう辞めよう
はじめに
下記のように、データベースのコネクションやアプリケーション全体で共有する値がある場合、
関数の引数として延々とバケツリレーしていった経験をしたことはありませんか。
// あくまで例示のためにコネクションの解放などは省略しています。
app.get('/user/:id', (c) => {
const result = userDetailsHandler(
{
requestId: generateUniqueId(),
timestamp: Date.now(),
database: pool.connect(),
},
c.req.param('id'),
);
return c.json(result);
});
type Context = {
requestId: string;
timestamp: number;
database: Database;
};
const userDetailsHandler = (context: Context, id: string): User => {
console.log(`User details requested: ${id}`, { requestId: context.requestId, timestamp: context.timestamp });
const user = findUserById(context, id);
if (!user) {
throw new Error('User not found');
}
return user;
};
const findUserById = (context: Context, id: string): User | undefined => {
console.log(`Fetching user: ${id}`, { requestId: context.requestId, timestamp: context.timestamp });
return context.database.query('SELECT * FROM users WHERE id = ?', [id]);
};
PrAhaではこういった問題を解消する為に、Node.jsの組み込みAPIであるAsyncLocalStorageを活用し下記のような形で管理していました。
import { AsyncLocalStorage } from 'node:async_hooks';
const traceStorage = new AsyncLocalStorage<{ requestId: string; timestamp: number }>();
const trace = () => {
const store = traceStorage.getStore();
if (!store) {
throw new Error('No trace context available');
}
return store;
};
const databaseStorage = new AsyncLocalStorage<Database>();
const database = () => {
const store = databaseStorage.getStore();
if (!store) {
throw new Error('No database context available');
}
return store;
};
app.get('/user/:id', (c) => {
const result = traceStorage.run({ requestId: generateUniqueId(), timestamp: Date.now() }, () => {
return databaseStorage.run(pool.connect(), () => {
return userDetailsHandler(c.req.param('id'));
});
});
return c.json(result);
});
const userDetailsHandler = (id: string): User => {
console.log(`User details requested: ${id}`, trace());
const user = findUserById(id);
if (!user) {
throw new Error('User not found');
}
return user;
};
const findUserById = (id: string): User | undefined => {
console.log(`Fetching user: ${id}`, trace());
return database().query('SELECT * FROM users WHERE id = ?', [id]);
};
しかし、AsyncLocalStorageはAPIがシンプルであるが故に、上記のようにボイラープレートが多くなってしまうという課題がありました。
そこで、PrAhaではこの問題を解決するために、AsyncLocalStorageをラップした軽量なライブラリである@praha/divaを開発しました。
上記のコードを@praha/divaを使って書き換えると、下記のようにシンプルになります。
import { createContext, withContexts } from '@praha/diva';
const [trace, withTrace] = createContext<{ requestId: string; timestamp: number }>();
const [database, withDatabase] = createContext<Database>();
app.get('/user/:id', (c) => {
const result = withContexts([
withTrace(() => ({ requestId: generateUniqueId(), timestamp: Date.now() })),
withDatabase(() => pool.connect()),
], () => userDetailsHandler(c.req.param('id')));
return c.json(result);
});
const userDetailsHandler = (id: string): User => {
console.log(`User details requested: ${id}`, trace());
const user = findUserById(id);
if (!user) {
throw new Error('User not found');
}
return user;
};
const findUserById = (id: string): User | undefined => {
console.log(`Fetching user: ${id}`, trace());
return database().query('SELECT * FROM users WHERE id = ?', [id]);
};
そこで、本記事では@praha/divaの簡単な使い方と、内部実装について紹介します。
@praha/diva とは
@praha/divaは、TypeScript向けの軽量で型安全な依存性注入ライブラリです。
divaはDependency Injection Valueの略で、AsyncLocalStorageを活用して関数の引数に依存関係を渡すことなく、値を取得できるようにすることを目的としています。
@praha/divaの特徴は以下の通りです。
- 軽量: 依存関係はNode.js標準APIのみ(AsyncLocalStorage)
- シンプル: 複雑な設定が不要で、学習コストが低い
- テスタビリティ: モックの設定が簡単に行える
基本的な使い方
インストール
お好みのパッケージマネージャーを使用して、@praha/divaをインストールしてください。
npm install @praha/diva
Contextの作成
createContext()を使ってContextを作成します。
これにより、値を取得するResolverと、値を提供するProviderのペアが返されます。
import { createContext } from '@praha/diva';
// データベースのContextを作成
const [database, withDatabase] = createContext<Database>();
// Providerでスコープを作成し値を提供する
const users = withDatabase(() => new Database(), () => {
// Resolver経由で値を取得
const db = database();
return db.query('SELECT * FROM users');
});
// Providerの外ではコンテキストが利用不可
const db = database(); // ❌ Error: Context not provided
カリー化による再利用
Providerはカリー化形式もサポートしており、設定を再利用できます。
const [logger, withLogger] = createContext<Logger>();
// 再利用可能なスコープ関数を作成
const runWithLogger = withLogger(() => new ConsoleLogger());
// 複数の場所で同じ設定を使用
runWithLogger(() => {
logger().info('First call');
});
runWithLogger(() => {
logger().info('Second call');
});
Optionalなコンテキスト
デフォルトではContextが提供されていない場合に、Resolverを使用するとエラーをスローしますが、 Contextが提供されていない場合にundefinedを返すコンテキストも作成できます。
const [config, withConfig] = createContext<Config>({ required: false });
// Providerスコープの外で呼び出してもエラーにならない
const maybeConfig = config(); // undefined
withConfig(() => new Config(), () => {
const cfg = config(); // configが提供されているので値が返る
});
スコープの概念
@praha/divaには2種類のスコープがあります。
それぞれのスコープは、Provider内でResolverがどのように値を解決するかを制御します。
デフォルトの動作
デフォルトでは、Providerスコープ内で作成された値はキャッシュされます。
つまり、同じスコープ内で複数回Resolverを呼び出すと、最初に作成されたインスタンスが返されます。
withDatabase(() => new Database(), () => {
const db1 = database(); // 新しいインスタンスを作成
const db2 = database(); // 同じインスタンスを返す(キャッシュ)
console.log(db1 === db2); // true
});
これは、データベース接続のような、同じスコープ内で共有すべきリソースに適しています。
Transientモード
毎回新しいインスタンスが必要な場合は、transient関数を使います。
withDate.transient(() => new Date(), () => {
const date1 = date(); // 新しいインスタンス
const date2 = date(); // 別の新しいインスタンス
console.log(date1 === date2); // false
});
状態を持たず、呼び出しごとに異なる値が必要な場合に便利です。
スコープのネスト
スコープはネストでき、内側のスコープが外側をオーバーライドします。
withDatabase(() => new Pool(), async () => {
const tx = database().connect();
await tx.query('BEGIN');
try {
withDatabase(() => tx, () => {
const tx = database(); // 内側のスコープが優先される
});
await tx.query('COMMIT');
} catch (error) {
await tx.query('ROLLBACK');
throw error;
}
const db = database(); // 外側のスコープに戻る
});
複数コンテキストの合成
複数の依存関係を管理する場合、withContextsヘルパー関数を使うとネストを避けられます。
import { createContext, withContexts } from '@praha/diva';
const [database, withDatabase] = createContext<Database>();
const [logger, withLogger] = createContext<Logger>();
const [auth, withAuth] = createContext<Auth>();
// ネストを避けてフラットに複数のコンテキストを提供できる
withContexts([
withDatabase(() => new Database()),
withLogger(() => new Logger()),
withAuth(() => new Auth()),
], () => {
const db = database();
const log = logger();
const authService = auth();
});
これにより、依存関係が増えてもコードがフラットに保たれます。
また、Providerは配列の順序で評価されるため、後ろのProviderで前のProviderの値を参照出来ます。
withContexts([
withTrace(() => ({ requestId: generateUniqueId(), timestamp: Date.now() })),
withLogger(() => new Logger(trace())), // withTraceが先に評価されるのでtrace()が利用できる
], () => {
const log = logger();
});
テストでのモック
テストでは、mockContextヘルパー関数を使うことで、Providerスコープなしでモック出来ます。
import { createContext } from '@praha/diva';
import { mockContext } from '@praha/diva/test';
const [database, withDatabase] = createContext<Database>();
// モックを設定(スコープ不要)
mockContext(withDatabase, () => new MockDatabase());
// Resolverがモックを返す
const db = database(); // MockDatabaseのインスタンス
// Transientなモックも可能
mockContext.transient(withDatabase, () => new MockDatabase());
const db1 = database(); // 新しいインスタンス
const db2 = database(); // 別のインスタンス
これにより、ユニットテストをシンプルに書くことができます。
内部実装の解説
@praha/divaは、Node.jsの組み込みAPIであるAsyncLocalStorageを活用して、Contextの管理しています。
ただ、AsyncLocalStorageだけでは、ネストされたコンテキストの管理が出来ないため、いくつかの工夫がされています。
AsyncLocalStorageによるスコープの分離
Node.jsのAsyncLocalStorageは、非同期実行フローを隔離するための機能です。
これにより、並行して実行される複数のリクエストが互いに干渉することなく、独立したコンテキストを持つことができます。
import { AsyncLocalStorage } from 'node:async_hooks';
import { setTimeout } from 'node:timers/promises';
const storage = new AsyncLocalStorage<string>();
await Promise.all([
// リクエストA
storage.run('request-a', async () => {
await setTimeout(100);
console.log(storage.getStore()); // 'request-a'
}),
// リクエストB
storage.run('request-b', async () => {
await setTimeout(100);
console.log(storage.getStore()); // 'request-b'
}),
]);
これにより、異なるHTTPリクエスト間でコンテキストが混ざらず、async/awaitを使った非同期処理でもコンテキストが正しく伝播されます。
StackStorageクラス
@praha/divaの内部では、AsyncLocalStorageをそのまま使わずに、StackStorageクラスを実装しています。
これにより、ネストされたProviderの内部でも、Resolverを呼び出して正しい値を取得できるようになっています。
class StackStorage<T> {
private storage = new AsyncLocalStorage<T[]>();
// 現在のスタックを取得
getStack(): T[] {
return this.storage.getStore() ?? [];
}
// スタックからアイテムをポップ(Disposable でラップ)
getItem(): (T & Disposable) | undefined {
const stack = this.getStack();
const builder = stack.pop();
return builder && Object.assign(builder, {
[Symbol.dispose]: () => stack.push(builder)
});
}
// 新しいスコープでアイテムを追加して実行
run<R>(item: T, fn: () => R): R {
return this.storage.run([...this.getStack(), item], fn);
}
}
getItemメソッドでスタックから一時的にbuilderを取り出す事で、providerの内部でbuilderを呼び出した際に無限ループへ陥るのを防いでいます。
また、Disposableパターンを使い、スタックから取り出したアイテムは自動的に元のスタックへ戻されるようになっています。
キャッシュ機構
デフォルトのモードでは、値がキャッシュされます。
const provider: Provider<T> = (builder: () => T, fn?: () => R) => {
const providerFn = (fn: () => R) => {
let cache: T;
let initialized = false;
const builderFn = (): T => {
if (!initialized) {
cache = builder(); // 初回のみ実行
initialized = true;
}
return cache; // 2回目以降はキャッシュを返す
};
return storage.run(builderFn, fn);
};
// カリー化対応
return fn ? providerFn(fn) : providerFn;
};
一方、Transientモードでは都度builder関数を実行しています。
provider.transient = (builder: () => T, fn?: () => R) => {
const providerFn = (fn: () => R) => {
return storage.run(builder, fn); // 都度実行
};
// カリー化対応
return fn ? providerFn(fn) : providerFn;
};
まとめ
@praha/divaを使うことで、Node.js/TypeScriptのバックエンドアプリケーションで、依存関係の管理がシンプルかつ型安全に行えるようになります。
InversifyJSやtsyringeのような多機能なDIコンテナと比べて、@praha/divaは必要最低限の機能に絞ることで、学習コストを大幅に削減しています。
DIコンテナが必要だけど、複雑な設定や大量の機能は必要ない。そんな方はぜひ@praha/divaを試してみてください。
もし気に入っていただけたら、GitHubで⭐️をいただけると嬉しいです!
また、PrAhaでは他にもTypeScript向けのResult型ライブラリや、エラー定義を簡単に行えるライブラリなども開発しています。
興味がある方はぜひチェックしてみてください!
Discussion