前回
前回までで、NestJSのルーティングがどのように実現されているのかを見ました。@Controllerや@Getといったデコレータによって付与されたメタデータをもとに、RoutesResolverやRouterExplorerの働きによってルートパスとハンドラがHTTPサーバーに登録されます。
今回はNestJSのもう一つの重要トピックである依存性注入 (DI)を見ていきます。以前の記事で見た通り、鍵となるのはDependenciesScanner#insertModuleです。
@Injectableのおさらい
NestJSにおけるDIでは@Injectableデコレータが重要です。以前の記事で見た通り、このデコレータは以下のようにReflect.defineMetadataで修飾先のクラスのコンストラクタ関数にメタデータを付与する役割を持っています。
export function Injectable(options?: InjectableOptions): ClassDecorator {
return (target: object) => {
Reflect.defineMetadata(INJECTABLE_WATERMARK, true, target);
Reflect.defineMetadata(SCOPE_OPTIONS_METADATA, options, target);
};
}
二つ目のSCOPE_OPTIONS_METADATAは@Injectableで指定するinjection scopeを表します。
DIを追う上ではあまり重要ではないので無視して、INJECTABLE_WATERMARKを追っていくことにします。リポジトリを検索すると、DependenciesScannerクラスで定義されたisInjectableメソッドに行き当たります。このメソッドはモジュールクラスのクラスコンストラクタを受け取り、それが@Injectable修飾されているかを返します。
isInjectableの利用元であるinsertModuleは以前の記事でも登場しました。
public async insertModule(
moduleDefinition: any,
scope: Type<unknown>[],
): Promise<
| {
moduleRef: Module;
inserted: boolean;
}
| undefined
> {
const moduleToAdd = this.isForwardReference(moduleDefinition)
? moduleDefinition.forwardRef()
: moduleDefinition;
if (
this.isInjectable(moduleToAdd) ||
this.isController(moduleToAdd) ||
this.isExceptionFilter(moduleToAdd)
) {
throw new InvalidClassModuleException(moduleDefinition, scope);
}
return this.container.addModule(moduleToAdd, scope);
}
insertModuleは、モジュールのクラスコンストラクタであるmoduleDefinitionをDIコンテナであるNestConteinerのインスタンスであるthis.containerに登録し、モジュールクラスをインスタンス化して返します。
insertModuleがどのように使われているかを通じてDependenciesScannerの全体像を理解しましょう。
DependenciesScanner
DependenciesScannerは、Nestアプリケーションの依存関係を解決し、DIコンテナにそれを登録する役割を果たします。NestFactoryクラスのinitialzeで利用され、アプリケーションのエントリーポイントになるモジュール(AppModule)を起点にモジュールを再起的にスキャンします。
private async initialize(
module: any, // エントリーポイントになるAppModuleのコンストラクタ
container: NestContainer,
graphInspector: GraphInspector,
config = new ApplicationConfig(),
options: NestApplicationContextOptions = {},
httpServer: HttpServer = null,
) {
// 省略
await dependenciesScanner.scan(module);
await instanceLoader.createInstancesOfDependencies();
dependenciesScanner.applyApplicationProviders();
//省略
ここで、scanがメインの処理であるAppModuleからの再起的な依存解決を担当し、applyApplicationProvidersはグローバルに扱われるプロバイダーのうちEnhancer(Intercepter, Pipe, Guard, Filter)であるものの登録を担当しています。applyApplicationProvidersはひとまず置いておいて、scanを見ていきましょう。
dependenciesScanner.scan(module)
実装を見て見ましょう。各処理の責務をコメントで補足しています。
public async scan(
module: Type<any>, // AppModuleのクラスコンストラクタ
options?: { overrides?: ModuleOverride[] },
) {
// InternalCoreModuleFactory.createで生成されるモジュールに対するscanForModules
await this.registerCoreModule(options?.overrides);
// メイン処理 (再起呼び出しされる)
await this.scanForModules({
moduleDefinition: module,
overrides: options?.overrides,
});
// Moduleの解決結果をもとに、各モジュールでImport, Provider, Controller, Exportを反映
await this.scanModulesForDependencies();
// ルートモジュールから出発し、Importsを辿ってモジュール間の「遠さ」を計算して登録
// 初期化順序や依存関係解決の最適化に利用
this.calculateModulesDistance();
// グローバルなEnhancerのスコープ情報をコントローラ, プロバイダーのメタデータに追加する
this.addScopedEnhancersMetadata();
// 各モジュールにグローバルスコープのプロバイダーをバインドし、利用可能にする
this.container.bindGlobalScope();
}
scanForModules
メイン処理であるscanForModulesを見ていきます。理解優先で一部単純化してあります。重要なのは冒頭のthis.insertOrOverrideModuleで、これは先述のinsertModuleを実行し、DIコンテナであるNestContainerへのクラスの登録とインスタンス化を行います。this.insertOrOverrideModule以降は、import辿って再起的にこれを実行するためのもろもろです。引数のscopeはAppModuleから現在のモジュールに至るまでの参照解決経路で登場する全てのモジュールのクラスコンストラクタを配列化したものになっています。
public async scanForModules({
moduleDefinition, // 現在着目しているモジュールのクラスコンストラクタ
scope = [],
ctxRegistry = [], // クラスコンストラクタ配列 (処理済みモジュール)
overrides = [],
}: ModulesScanParameters): Promise<Module[]> {
// モジュールを追加、ないし上書きする
// Overrideのことを一旦無視すると、`insertModule`を実行しているだけ
const { moduleRef: moduleInstance, inserted: moduleInserted } =
(await this.insertOrOverrideModule(moduleDefinition, overrides, scope)) ??
{};
// 無駄な処理をスキップするために処理済みのものを管理
ctxRegistry.push(moduleDefinition);
// 着目しているモジュールの@Moduleの引数でimport指定されているモジュール群を取得
const modules = this.reflectMetadata(
MODULE_METADATA.IMPORTS,
moduleDefinition as Type<any>,
);
// 現在のモジュールより下位にあるモジュールのインスタンス全体
let registeredModuleRefs = [];
// importしているモジュールに関してループ
for (const [index, innerModule] of modules.entries()) {
// 処理済みモジュールについてはスキップ
if (ctxRegistry.includes(innerModule)) {
continue;
}
// モジュール探索を再起実行
const moduleRefs = await this.scanForModules({
moduleDefinition: innerModule,
scope: [].concat(scope, moduleDefinition),
ctxRegistry,
overrides,
lazy,
});
registeredModuleRefs = registeredModuleRefs.concat(moduleRefs);
}
if (!moduleInstance) {
return registeredModuleRefs;
}
// グローバルスコープのプロバイダーをバインド
if (moduleInserted) {
this.container.bindGlobalsToImports(moduleInstance);
}
// 現在のモジュールと、それが依存するモジュールに関する再起処理結果を結合し、インスタンス配列を返す
return [moduleInstance].concat(registeredModuleRefs);
}
まとめると、scanはAppModuleから出発し、@Moduleの引数で指定されたimport情報のメタデータに基づいて再起的に参照解決を行い、NestContainerを更新します。次にこのNestContainerを見ていきます。
NestContainer
NestJS アプリケーション全体のモジュール構成と依存関係解決の中核を担っています。モジュールとプロバイダーの登録や解決、グローバルスコープの管理、動的モジュールの取り扱い、HTTPアダプターの設定などの重要な機能を実装しています。
DependenciesScannerから実行されるaddModuleは以下で定義されます。
public async addModule(
metatype: ModuleMetatype,
scope: ModuleScope,
){
// DIコンテナ内でモジュールを一意に識別するトークン (ハッシュ文字列)を生成する。
// scopeやDynamicModuleの設定によって1つのモジュールクラスに対応した複数のモジュールが存在しうる
const { type, dynamicMetadata, token } =
await this.moduleCompiler.compile(metatype);
// 省略
return {
moduleRef: await this.setModule(
{
token,
type,
dynamicMetadata,
},
scope,
),
inserted: true,
};
}
モジュールの登録処理であるsetModuleは以下で、ここでモジュールをインスタンス化し、一意な識別子をキーとして、MapであるModulesContainerにそのインスタンスを追加しています。
private async setModule(
{ token, dynamicMetadata, type }: ModuleFactory,
scope: ModuleScope,
){
const moduleRef = new Module(type, this);
moduleRef.token = token;
moduleRef.initOnPreview = this.shouldInitOnPreview(type);
// ModulesContainerにトークンをキーとしてモジュールインスタンスを登録
this.modules.set(token, moduleRef);
// type: モジュールのクラスコンストラクタ
const updatedScope = [].concat(scope, type);
// 動的モジュールによって導入された依存関係の処理。
// 動的モジュールのメタデータの保存と、コンテナへのモジュール追加 (addModule)
await this.addDynamicMetadata(token, dynamicMetadata, updatedScope);
if (this.isGlobalModule(type, dynamicMetadata)) {
moduleRef.isGlobal = true;
this.addGlobalModule(moduleRef);
}
return moduleRef;
}
コメントで書いた通り、静的モジュールと動的モジュールは異なる取り扱いを受けます。静的モジュールの依存関係はscanForModules処理されますが、動的モジュールは上記addDynamicMetadata内で別途処理されています。
まとめ
今回は、NestJSのもう一つの重要な機能である依存性注入 (DI) について解説しました。特に、DependenciesScannerクラスがこのプロセスの中核を担っており、scanForModulesメソッドを通じてAppModuleから再帰的に依存関係をスキャンし、DIコンテナであるNestContainerを更新します。このスキャンと登録のプロセスにより、NestJSはモジュール間の依存関係を把握することができます。
次回は、NestContainer#setModuleで各モジュールのインスタンスが生成されたあと、どのように依存関係が解決され、各種プロバイダーのインスタンス化と依存性注入が行われるかを見ることにします。鍵になるのは、NestFactoryのinitializeで登場したInstanceLoader#applyApplicationProvidersです。