キカガク プラットフォームブログ

株式会社キカガクのプラットフォームブログです。エンジニアやデザイナー、プロダクトマネージャーなどが記事を書いています。

NestJS の Injection scopes の挙動について

プラットフォーム部の dascarletです。

今回は弊社でも使用している NestJS の Injection scopes の挙動について簡単なコード例と共にご紹介します。

基本的な挙動

公式ドキュメントから引用します。Provider scope は下記の3種類です。

  • DEFAULT
    • いわゆるシングルトンです。一つのインスタンスを共有し、インスタンスのライフサイクルはアプリケーションと同じになります。アプリケーションが立ち上がる時にシングルトンインスタンスは全て生成されます。
    • @Injectable() または @Injectable({ scope: Scope.DEFAULT }) と指定することでこのスコープになります。
  • REQUEST
    • リクエストのたびにインスタンスが生成されます。リクエストごとに新しいインスタンスが生成されるため、リクエストごとに状態を保持することができます。
    • Scope hierarchy があるため、このスコープを使用しているクラスを参照している全てのクラスはリクエストごとに新しいインスタンスが生成されます。
    • @Injectable({ scope: Scope.REQUEST }) と指定することでこのスコープになります。
  • TRANSIENT
    • 基本的な挙動は DEFAULT と同じで一つのインスタンスをアプリケーション立ち上げ時に作成しますが、Inject されるインスタンスごとに異なるインスタンスを作成します。
    • リクエストのたびにインスタンスは立ち上げません。
    • @Injectable({ scope: Scope.TRANSIENT }) と指定することでこのスコープになります。

検証してみる

初めてこのドキュメントを読んだ時あまり直感的に理解できませんでした。そのため、実際に簡単なコードを書いて挙動を確認してみました。

NestJS のアプリケーションの雛形作成などは公式ドキュメントを参考にしてください。Node.js のバージョンは v18.18.1 で、@nestjs/cli のバージョンは 10.3.2 です。

DEFAULT の挙動

countuuid などのインスタンス変数を使用していますが挙動を確認するためのものです。

// 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 の挙動についてご紹介しました。以下の点は注意しておくべきかなと思います。

  • インスタンス変数を使用している場合、Injection scope によって挙動が変わる。
    • 個人的にはそもそもインスタンス変数を使わないのが無難かなと思います。
  • REQUEST scope を設定しているクラスを Inject するインスタンス全てはリクエストごとにインスタンスが生成される。
    • 何層もクラスを Inject している場合、REQUEST を使用することによってパフォーマンスに影響が出るかもしれません。公式ドキュメントにも注意書きがあります。