TypeScriptでデコレーター駆動の依存性注入コンテナを構築する
Takashi Yamamoto
Infrastructure Engineer · Leapcell

TypeScriptでデコレーター駆動の依存性注入コンテナを構築する
はじめに
AngularやNestJSのようなフレームワークで構築されたモダンなWebアプリケーションは、モジュール性とテスト容易性に大きく依存しています。これらの品質を可能にする重要なパターンが依存性注入(DI)です。フレームワークはしばしば洗練されたDIシステムを標準で提供しますが、カスタムシステムを理解し実装すること、特にTypeScriptのデコレーターの力を利用することは、非常に有益です。これにより、これらのシステムがどのように機能するかについての深い理解が得られ、より小さく、より専門的なアプリケーションやライブラリを構築する際に、きめ細かな制御が可能になります。この記事では、TypeScriptのデコレーターを使用して、シンプルでありながら強力な自動依存性注入コンテナを作成するプロセスをガイドし、クリーンで保守可能でテスト可能なコードベースをどのように実現できるかを説明します。
コアコンセプトの理解
実装に入る前に、関連する主要な用語について共通の理解を確立しましょう。
- 依存性注入 (DI): コンポーネントが依存関係を自身で作成するのではなく、外部ソースから受け取るデザインパターン。これにより、疎結合が促進され、コンポーネントはより独立し、再利用可能で、テスト可能になります。
- IoCコンテナ (Inversion of Control Container): DIコンテナと同義であることが多いですが、アプリケーション内のオブジェクトのインスタンス化とライフサイクルを管理するフレームワークまたはライブラリです。オブジェクト作成の制御をコンポーネント自身からコンテナに「反転」させます。
- デコレーター: クラス宣言、メソッド、アクセサ、プロパティ、またはパラメーターにアタッチできる特別な種類の宣言。デコレーターは
@expression
の形式を使用します。ここでexpression
は、実行時にデコレートされた宣言に関する情報とともに呼び出される関数に評価される必要があります。TypeScriptでは、メタデータを追加したり、デクララティブな方法でクラスとそのメンバーの動作を変更したりするための強力な方法を提供します。 - サービス/Injectable: 特定のタスクを実行し、他のコンポーネントに注入できるクラスまたはオブジェクト。DIの文脈では、これらはコンテナがインスタンスを管理するコンポーネントです。
- プロバイダー: DIコンテナに特定の依存関係のインスタンスをどのように作成するかを指示するメカニズム。これは、クラス、値、またはファクトリ関数である可能性があります。
デコレーター駆動のDIコンテナの基本的な原則は、デコレーターを使用してクラスを注入可能としてマークし、そのコンストラクターの依存関係を識別することです。その後、コンテナは実行時にこのメタデータを使用して、必要なサービスを自動的に解決およびインスタンス化します。
依存性注入コンテナの実装
DIコンテナは、いくつかの主要なコンポーネントで構成されます。注入可能なクラスをマークするためのデコレーター、依存関係の注入方法を指定するためのデコレーター、そしてインスタンスを管理する責任を持つコンテナ自体です。
1. @Injectable
デコレーター
このデコレーターは2つの目的を果たします。まず、クラスをコンテナが管理できるサービスとしてマークし、次に、その依存関係に関するメタデータを格納します。
// reflect-metadata は、デコレーターがお互いの型情報で機能するために必要なポリフィルです import 'reflect-metadata'; // コンストラクターパラメーターの型を格納するためのシンボル const INJECT_METADATA_KEY = Symbol('design:paramtypes'); /** * DIコンテナによって注入可能であることをクラスにマークします。 * このデコレーターは、クラスのコンストラクターパラメーターに関するメタデータを格納し、 * コンテナがその依存関係を解決できるようにします。 * @returns クラスデコレーター */ function Injectable(): ClassDecorator { return (target: Function) => { // 現在、ここでは明示的なアクションは必要ありません。 // TypeScriptの emitDecoratorMetadata 機能は、コンストラクターパラメーターの // design:paramtypes を格納することを自動的に処理します。 // 実行されることを確認するだけで十分です。 }; }
説明: Injectable
デコレーター自体は、その本体で直接的にはあまり多くのことを行いません。その主な役割は、TypeScriptの emitDecoratorMetadata
機能(tsconfig.json
で有効にする必要があります)をトリガーすることです。emitDecoratorMetadata
が true の場合、TypeScriptはクラスのコンストラクターのパラメーターの型に関するメタデータを自動的に発行し、reflect-metadata
ポリフィルを介して design:paramtypes
キー(よく知られたシンボル)を使用して格納します。私たちのコンテナは、後でこのメタデータを読み取ります。
2. コンテナコア
ここに魔法があります。Container
クラスがサービスを管理します。
import 'reflect-metadata'; // これはグローバルで一度だけインポートされることを確認してください type Constructor<T> = new (...args: any[]) => T; /** * サービスインスタンスを管理するためのシンプルな依存性注入コンテナ。 */ class Container { private static instance: Container; private readonly providers = new Map<Constructor<any>, any>(); // シングルトンインスタンスまたはファクトリ関数を格納します private constructor() {} /** * Containerのシングルトンインスタンスを取得します。 */ public static getInstance(): Container { if (!Container.instance) { Container.instance = new Container(); } return Container.instance; } /** * コンテナにクラスをプロバイダーとして登録します。 * デフォルトではシングルトンとして登録されます。 * @param target 登録するクラスコンストラクター。 */ public register<T>(target: Constructor<T>): void { if (this.providers.has(target)) { console.warn(`Service ${target.name} is already registered.`); return; } // ここでは、コンストラクター自体を登録するだけです。 // インスタンスは最初の解決時に作成されます。 this.providers.set(target, target); } /** * コンテナから指定されたクラスのインスタンスを解決します。 * 依存関係の再帰的な解決を処理します。 * @param target 解決するクラスコンストラクター。 * @returns 要求されたクラスのインスタンス。 */ public resolve<T>(target: Constructor<T>): T { // インスタンスが既に作成されている場合(シングルトン)、それを返します。 // 簡単にするために、ここでは基本的なシングルトンパターンを直接実装します。 // より高度なコンテナは、明示的なライフサイクル管理フラグを持つ場合があります。 if (this.providers.has(target) && (this.providers.get(target) instanceof target)) { return this.providers.get(target); } // reflect-metadata を使用してコンストラクターパラメーターの型(依存関係)を取得します。 const paramTypes: Constructor<any>[] = Reflect.getMetadata('design:paramtypes', target) || []; const dependencies = paramTypes.map(paramType => { if (!paramType) { // プリミティブ型や emitDecoratorMetadata がオフになっている場合に発生する可能性があります。 throw new Error(`Cannot resolve dependency for ${target.name}. ` + `Ensure 'emitDecoratorMetadata' is true in tsconfig.json ` + `and all dependencies are also @Injectable.`); } // 依存関係を再帰的に解決します return this.resolve(paramType); }); // 解決された依存関係とともに、ターゲットクラスの新しいインスタンスを作成します。 const instance = new target(...dependencies); // 新しく作成されたシングルトンインスタンスを格納します。 this.providers.set(target, instance); return instance; } } // 便利なインスタンスをエクスポートします const container = Container.getInstance(); export { container, Injectable };
説明:
Container.getInstance()
: コンテナのシングルトンパターンを実装しています。すべてのサービスを管理するには、1つのセントラルインスタンスのみが必要です。register(target: Constructor<T>)
: このメソッドを使用すると、クラスをコンテナに明示的に登録できます。resolve
メソッドは依存関係を暗黙的に検索できますが、事前に登録することは明確な構成に役立ちます。この基本的な例では、register
はコンストラクター自体を格納するだけで、実際のインスタンスは最初のresolve
で作成されます。resolve(target: Constructor<T>): T
: これはDIコンテナのコアです。- まず、
target
のインスタンスが既に存在するかどうかを確認します(基本的なシングルトン動作を実装しています)。 - 次に、
Reflect.getMetadata('design:paramtypes', target)
を使用して、コンストラクターパラメーターの型を取得します。ここでreflect-metadata
ポリフィルとemitDecoratorMetadata
が役割を果たします。 - 依存関係のインスタンスを取得するために、各パラメーター型に対して
this.resolve()
を再帰的に呼び出します。 - 最後に、解決された依存関係をコンストラクター引数として使用して、
new
演算子でtarget
クラスをインスタンス化します。 - 新しく作成されたインスタンスは、後続のリクエストのために
providers
に格納され、シングルトンとして機能します。
- まず、
3. 使用例
いくつかのサンプルサービスでコンテナの使用方法を説明しましょう。
// services.ts import { container, Injectable } from './container'; // container.ts が同じディレクトリにあると仮定します @Injectable() class LoggerService { log(message: string): void { console.log(`[Logger]: ${message}`); } } @Injectable() class DataService { constructor(private logger: LoggerService) {} // LoggerService は依存関係です getData(): string { this.logger.log('Fetching data...'); return 'Hello from DataService!'; } } @Injectable() class ApplicationService { constructor(private dataService: DataService, private logger: LoggerService) {} // DataService と LoggerService は依存関係です run(): void { this.logger.log('Application starting...'); const data = this.dataService.getData(); this.logger.log(`Received data: ${data}`); this.logger.log('Application finished.'); } } // main.ts import { container } from './container'; import { ApplicationService } from './services'; // すべてのサービスをコンテナに登録します(オプションですが、明確にするために良い習慣です)。 // より大きなアプリでは、モジュールシステムや自動検出を使用する可能性があります。 container.register(LoggerService); container.register(DataService); container.register(ApplicationService); // トップレベルのアプリケーションサービスを解決します const app = container.resolve(ApplicationService); app.run(); // シングルトン動作を確認します const anotherLogger = container.resolve(LoggerService); const firstLogger = container.resolve(LoggerService); console.log('Are loggers the same instance?', anotherLogger === firstLogger); // true になるはずです
このコードを実行するには、以下が必要です。
reflect-metadata
をインストールします:npm install reflect-metadata
tsconfig.json
でemitDecoratorMetadata
とexperimentalDecorators
を有効にします。{ "compilerOptions": { "target": "es2016", "module": "commonjs", "emitDecoratorMetadata": true, "experimentalDecorators": true, "outDir": "./dist", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true } }
- TypeScriptをコンパイルします:
tsc
- コンパイルされたJavaScriptを実行します:
node dist/main.js
次のような出力が表示されるはずです。
[Logger]: Application starting...
[Logger]: Fetching data...
[Logger]: Received data: Hello from DataService!
[Logger]: Application finished.
Are loggers the same instance? true
これは、ApplicationService
が DataService
と LoggerService
のインスタンスをコンストラクターに明示的に作成することなく、自動的に受け取る方法を示しています。さらに、DataService
も LoggerService
を受け取ります。すべてのサービスは、コンテナによってシングルトンとして管理されます。
アプリケーションシナリオ
- マイクロサービスおよびAPIゲートウェイ: 異なるマイクロサービス間で、サービスクライアント、認証プロバイダー、および共通ユーティリティサービスを簡単に管理および注入できます。
- コマンドラインツール: 特定の構成またはヘルパークラスを必要とするさまざまなコマンドまたはモジュールで複雑なCLIアプリケーションを構築します。
- カスタムフレームワーク/ライブラリの構築: 独自のライブラリに軽量なDIソリューションを提供し、コンシューマーがコンポーネントをより簡単に拡張および統合できるようにします。
- テスト容易性: 最大のメリットです。コンポーネントは依存関係を受け取るため、単体テスト中にこれらの依存関係を簡単にモックまたは置き換えることができ、分離された効率的なテストにつながります。
結論
デコレーターの力を活用して、TypeScriptで基本的でありながら機能的な依存性注入コンテナを構築することに成功しました。クラスを@Injectable
でマークし、コンテナにパラメーター解決を任せることで、非常にモジュラーで疎結合でテスト可能なコードベースを実現します。このアプローチは、コードの整理と保守性を大幅に向上させ、よりクリーンなアプリケーションアーキテクチャを可能にします。自動的に依存関係を管理する機能は、モダンなJavaScript開発にとって不可欠な資産です。