プラットフォーム部の dascarletです。
今回は弊社でも使用している NestJS の Injection scopes の挙動について簡単なコード例と共にご紹介します。
基本的な挙動
公式ドキュメントから引用します。Provider scope は下記の3種類です。
DEFAULT
REQUEST
TRANSIENT
検証してみる
初めてこのドキュメントを読んだ時あまり直感的に理解できませんでした。そのため、実際に簡単なコードを書いて挙動を確認してみました。
NestJS のアプリケーションの雛形作成などは公式ドキュメントを参考にしてください。Node.js
のバージョンは v18.18.1 で、@nestjs/cli
のバージョンは 10.3.2 です。
DEFAULT
の挙動
count
や uuid
などのインスタンス変数を使用していますが挙動を確認するためのものです。
// Controller @Controller() export class AppController { uuid: string = crypto.randomUUID(); constructor( private readonly AService: AppAService, ) { console.log('Controller initialized'); console.log('uid:', this.uuid); } @Get() getMessage(): string { return this.AService.getMessage(); } } // AppAService @Injectable({ scope: Scope.DEFAULT }) export class AppAService { uuid: string = crypto.randomUUID(); count: number = 0; constructor() { console.log('AppAService initialized'); console.log('uid:', this.uuid); } getMessage(): string { this.count++; return 'AppAService count:' + this.count.toString(); } }
nest start --watch
などで起動させると以下のようなログが表示されます。
AppAService initialized uid: 2802db0b-55fd-4e2f-a997-c7a6df97d77d Controller initialized uid: 26ea6777-a2c8-4a4a-95bc-3761cc24ecc0
またブラウザからアクセスするとこのような表示になります。
ここでシークレットモードなどを使用して再度アクセスすると count
が 2 になります。
つまり AppAService
のインスタンスは一つだけ作成され、ライフサイクルはアプリケーションと紐づけられているためリクエストが来るたびに count
の値が増えていきます。また、アプリケーションを停止させれば count
の値もリセットされます。
REQUEST
の挙動
先程のコードの Injection scope を { scope: Scope.REQUEST }
に変更して挙動を確認してみます。
アプリケーション起動時には起動のログのみ表示され、console.log()
でのログが表示されません。
ブラウザから二回アクセスしてみます。すると以下のようなログが表示されます。
# 一度目のリクエスト AppAService initialized uid: 2e7c380e-1db3-4a91-bd72-12f2cd600788 Controller initialized uid: ea7f5b79-2f5b-4b6e-ad32-1a4e6a546e70 # 二度目のリクエスト AppAService initialized uid: 048bd941-2610-4b2f-9cd4-b63e8583d50a Controller initialized uid: 9fe42f41-dd9b-4de5-bee7-91af4fb50970
リクエストごとにインスタンスが生成されていることが確認できました。また、ブラウザでの表示は count: 1 と毎回表示されています。つまり、作成したインスタンスはリクエストごとに生成・破棄されていることを示しています。
Scope hierarchy
例えば AppAService が AppBService を Inject しており AppBService が Scope.REQUEST である場合、 AppAService は必ず Scope.REQUEST になります。これは Scope hierarchy によるものです。
// AppAService @Injectable({ scope: Scope.DEFAULT }) export class AppAService { uuid: string = crypto.randomUUID(); count: number = 0; constructor(private readonly BService: AppBService) { console.log('AppAService initialized'); console.log('uid:', this.uuid); } getMessage(): string { this.count++; return ( 'AppAService count:' + this.count.toString() + this.BService.getMessage() ); } } // AppBService @Injectable({ scope: Scope.REQUEST }) export class AppBService { uuid: string = crypto.randomUUID(); count: number = 0; constructor( @Inject(INQUIRER) private readonly parentClass: object, ) { console.log('AppBService initialized'); console.log('called by:', this.parentClass); console.log('uid:', this.uuid); } getMessage(): string { this.count++; return ' AppBService:' + this.count.toString(); } }
AppAService の Scope を DEFAULT、AppBService の Scope を REQUEST に設定しています。
@Inject(INQUIRER)
については Inquirer provider
に記載がある通り、どのクラスからインスタンスを生成されたか知ることが出来る仕組みです。
このコードを実行しブラウザからアクセスすると AppAService のインスタンスも毎リクエスト生成されます。最初に試したコードとは異なり
AppAService の count
の値はリクエストごとに増加しません。
TRANSIENT
の挙動
最後に TRANSIENT
の挙動を確認していきます。今までのコードとは異なり、Controllerに AppAService, AppBService を Inject
し、それらの Service へ SubService を Inject します。
SubService は TRANSIENT
に設定し、その他は DEFAULT
に設定します。
// Controller @Controller() export class AppController { uuid: string = crypto.randomUUID(); constructor( private readonly AService: AppAService, private readonly BService: AppBService, ) { console.log('Controller initialized'); console.log('uid:', this.uuid); } @Get() getMessage(): string { return this.AService.getMessage() + ' ' + this.BService.getMessage(); } } // AppAService @Injectable({ scope: Scope.DEFAULT }) export class AppAService { uuid: string = crypto.randomUUID(); count: number = 0; constructor(private readonly subService: AppSubService) { console.log('AppAService initialized'); console.log('uid:', this.uuid); } getMessage(): string { this.count++; return ( 'AppAService count:' + this.count.toString() + this.subService.getMessage() ); } } // AppBService @Injectable({ scope: Scope.DEFAULT }) export class AppBService { uuid: string = crypto.randomUUID(); count: number = 0; constructor(private readonly subService: AppSubService) { console.log('AppBService initialized'); console.log('uid:', this.uuid); } getMessage(): string { this.count++; return ( 'AppBService count:' + this.count.toString() + this.subService.getMessage() ); } } // SubService @Injectable({ scope: Scope.TRANSIENT }) export class AppSubService { uuid: string = crypto.randomUUID(); count: number = 0; constructor(@Inject(INQUIRER) private readonly parentClass: object) { console.log('AppSubService initialized'); console.log('called by:', this.parentClass); console.log('uuid:', this.uuid); } getMessage(): string { this.count++; return ' AppSubService count:' + this.count.toString(); } }
このコードを実行すると以下のようなログが表示されます。
AppSubService initialized called by: AppAService {} uuid: a1092bcd-8712-4061-9be2-521f0775cca0 AppSubService initialized called by: AppBService {} uuid: 65e8e84e-cabe-4677-ae93-2b8459dc62c4 AppAService initialized uid: 76b4c518-fb60-41df-8578-942aae6252f9 AppBService initialized uid: 2019ea1d-74c7-4e82-8cf2-88a65ab07a1c Controller initialized uid: ec9899d5-3d30-4653-bdc0-6b9e444d7734
AppSubService
が二回生成されています。つまり TRANSIENT scopeを設定したクラスは Inject されるたびに新しいインスタンスが生成されることが確認できます。
まとめ
簡単なコードを使用しながら NestJS の Injection scopes の挙動についてご紹介しました。以下の点は注意しておくべきかなと思います。