サンプルコードで身につける: NestJS の DI と何が違うか
解説で学んだ概念を、5つの具体例で確認します。素朴な例から実務寄りの例へと進みます。
1コンパイル後の JavaScript に残るもの
レッスン本体の demo.ts を emitDecoratorMetadata を有効にしてコンパイルした出力の一部です(tsc が生成したそのまま)。constructor(repo) に型は無く、型の情報はコンパイラが足した __metadata の行にだけ残っています。型の引数は CatsRepository のままですが、型だった引数は Object になっています。
let WithClass = class WithClass {repo;constructor(repo) {this.repo = repo;}};WithClass = __decorate([Injectable(),__metadata("design:paramtypes", [CatsRepository])], WithClass);let WithInterface = class WithInterface {clock;constructor(clock) {this.clock = clock;}};WithInterface = __decorate([Injectable(),__metadata("design:paramtypes", [Object])], WithInterface);
2NestJS の側で、それを読んでいる場所
design:paramtypes という名前は、NestJS のソースに定数として置かれ、の引数を調べる処理で読まれています。設定の emitDecoratorMetadata は、公式の tsconfig.json で有効になっています。NestJS の公式ドキュメントは、コンパイル時に TypeScript がコンストラクタの引数の型をメタデータとして書き出し、がそれを読む、と説明しています。
// packages/common/constants.tsexport const PARAMTYPES_METADATA = 'design:paramtypes';// packages/core/injector/injector.tspublic reflectConstructorParams(type: Type<unknown> | Function): any[] {const paramtypes = [...(Reflect.getMetadata(PARAMTYPES_METADATA, type) || []),];// typescript-starter/tsconfig.json"emitDecoratorMetadata": true,"experimentalDecorators": true,
3Java ではインターフェース型がそのまま読める
素の Java で、コンストラクタの引数の型を読み、候補のクラスの中からその型に代入できるものを探す形です。引数がインターフェースでも型の名前がそのまま読めるので、トークンを別に用意する必要がありません。その代わり、当てはまる実装が2つになると1つに決められなくなります。
4Spring での実物: 囲いが無い
OrderService は shop.orders、実装の FakeGateway は shop.billing にあり、FakeGateway には が無いので orders 側からは名前で参照できません。それでも exports や imports に当たる宣言なしで注入されました。スキャンするに2つ目の実装のあるものを加えると、同じ OrderService の生成が、候補が2つあるというで失敗します(Java の機能を使わない構成での結果です)。
constructor parameter type at runtime = shop.billing.PaymentGateway[1 impl ] charged 1200 by FakeGateway[2 impls] org.springframework.beans.factory.NoUniqueBeanDefinitionExceptionNo qualifying bean of type 'shop.billing.PaymentGateway' available: expected single matching bean but found 2: fakeGateway,secondGateway
// 4つのファイルを並べたもの(import は省略)
// shop/billing/PaymentGateway.java
package shop.billing;
public interface PaymentGateway {
String charge(int amount);
}
// shop/billing/FakeGateway.java — public が無い。他のパッケージからは名前で参照できない
package shop.billing;
@Component
class FakeGateway implements PaymentGateway {
public String charge(int amount) { return "charged " + amount + " by " + getClass().getSimpleName(); }
}
// shop/orders/OrderService.java — インターフェース型で受け取る。FakeGateway という名前は出てこない
package shop.orders;
@Component
public class OrderService {
private final PaymentGateway gateway;
public OrderService(PaymentGateway gateway) { this.gateway = gateway; }
public String order() { return gateway.charge(1200); }
}
// shop/extra/SecondGateway.java — 2回目だけスキャンに含める、2つ目の実装
package shop.extra;
@Component
public class SecondGateway implements PaymentGateway {
public String charge(int amount) { return "second"; }
}
5違いを表にまとめる
ここまでの違いを並べた表です。優劣ではなく、言語が実行時に何を残すかと、コンテナが見える範囲をどこで区切るかの違いとして読んでください。NestJS の列はドキュメントの記述とコンパイラの出力に基づくもので、NestJS 自体を起動した実測ではありません。
観点 | NestJS(TypeScript) | Spring(Java)---------------------+--------------------------------------------+---------------------------------------------引数の型の出どころ | コンパイラが書き足す design:paramtypes | .class に残る型をリフレクションで読むインターフェース | Object になる。文字列・Symbol・ | 型のまま読める。そのまま注入の鍵になる| 抽象クラスをトークンにする |見える範囲 | モジュールごとに囲い、exports で公開 | 識別子は1つのコンテナの中で一意同じ型が2つあるとき | (ドキュメントでは確認していない) | 1つだけ要求する注入点で絞り込めないと| | NoUniqueBeanDefinitionException区切る手段 | モジュール(providers / exports / imports)| スキャンするパッケージを選ぶ/| | コンテキストを階層にする既定のスコープ | シングルトン | singleton(コンテナごと・定義ごとに1つ)その1つを通るもの | リクエストごとに別スレッドではない | リクエストを受け持つ複数のスレッド