iT邦幫忙

2021 iThome 鐵人賽

DAY 17
0
Modern Web

NestJS 帶你飛!系列 第 17

[NestJS 帶你飛!] DAY17 - Injection Scopes

Nest 在大多數情況下是採用 單例模式 (Singleton pattern) 來維護各個實例,也就是說,各個進來的請求都共享相同的實例,這些實例會維持到 Nest App 結束為止。但有些情況可能就需要針對各個請求做處理,這時候可以透過調整 注入作用域 (Injection scope) 來決定實例的建立時機。

注意:雖然說可以調整建立實例時機,但如果非必要還是建議採用單例模式,原因是可以提升系統整體效能,若針對每個請求建立實例,將會花費更多資源在處理建立與垃圾回收。

作用域

Nest 共有三種作用域可以使用:

  1. 預設作用域 (Default scope):即單例模式之作用域。
  2. 請求作用域 (Request scope):為每個請求建立全新的實例,在該請求中的 Provider 是共享實例的,請求結束後將會進行垃圾回收。
  3. 獨立作用域 (Transient scope):每個 Provider 都是獨立的實例,在各 Provider 之間不共享。

Provider 設置作用域

Provider 設定作用域只要在 @Injectable 裝飾器中做配置即可,它有提供一個選項參數,透過填入 scope 來做指定,而作用域參數可以透過 Nest 提供的 enum - Scope 來配置。以 app.service.ts 為例:

import { Injectable, Scope } from '@nestjs/common';

@Injectable({ scope: Scope.REQUEST })
export class AppService {

  getHello(): string {
    return 'Hello World!';
  }

}

如果是自訂 Provider 的話,就多一個 scope 的屬性。以 app.module.ts 為例:

import { Module, Scope } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [
  ],
  controllers: [
    AppController
  ],
  providers: [
    AppService,
    {
      provide: 'USERNAME',
      useValue: 'HAO',
      scope: Scope.REQUEST // 添加 scope 屬性
    }
  ]
})
export class AppModule {
}

Controller 設置作用域

Controller 設定作用域只要調整 @Controller 裝飾器的參數即可,同樣使用選項參數來配置,若有路由設定,將其配置在 path 屬性,而作用域則是 scope。以 app.controller.ts 為例:

import { Controller, Get, Scope } from '@nestjs/common';
import { AppService } from './app.service';

@Controller({ scope: Scope.REQUEST })
export class AppController {
  constructor(
    private readonly appService: AppService
  ) {
  }

  @Get()
  getHello() {
    return this.appService.getHello();
  }

}

作用域冒泡

作用域的配置會影響整個注入鏈作用域範圍,什麼意思呢?這裡用下方圖示作為範例:
https://ithelp.ithome.com.tw/upload/images/20210523/201193381IQZTZrN60.png

可以看到 StorageService 分別在 AppModuleBookModule 被使用,而 BookService 又在 AppModule 被使用,此時,如果我們把 StorageService 的作用域設置為「請求作用域」,那麼依賴於 StorageServiceBookServiceAppService 都會變成請求作用域,所以按這樣的邏輯來看,AppController 也會變成請求作用域,因為它依賴了 AppService
https://ithelp.ithome.com.tw/upload/images/20210523/201193380Ba7pCPiPN.png

但如果是把 BookService 設為「請求作用域」,那就僅有 AppServiceAppController 會是請求作用域,因為 StorageService 不依賴於 BookService
https://ithelp.ithome.com.tw/upload/images/20210523/201193387hraBJGFfQ.png

請求作用域與請求物件

由於請求作用域是針對每一個請求來建立實例,所以能透過注入 REQUEST 來取得請求物件。以 app.service.ts 為例:

import { Inject, Injectable, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';

import { Request } from 'express';

@Injectable({ scope: Scope.REQUEST })
export class AppService {

  constructor(
    @Inject(REQUEST) private readonly request: Request
  ) {}

  getHello(): string {
    return 'Hello World!';
  }
}

實例化實驗

這裡來做個簡單的實驗,來驗證各個作用域的實例化時間與實例的共享,會使用上面 AppModuleBookModuleStorageModule 的架構。首先,先來建立 StorageModuleBookModule

$ nest generate module common/storage
$ nest generate service common/storage
$ nest generate module common/book
$ nest generate service common/book

接著,設計一下 storage.service.ts 的內容,在 contructor 印出含有亂數的字串,透過亂數可以讓我們清楚知道該實例是否為同一個實例,也可以運用這樣的方式觀察建立實例的時機,然後設計一套添加資料與取得資料的方法:

import { Injectable } from '@nestjs/common';

@Injectable()
export class StorageService {

  constructor() {
    console.log(`Storage: ${Math.random()}`);
  }

  private list: any[] = [];

  public getItems(): any[] {
    return this.list;
  }

  public addItem(item: any): void {
    this.list.push(item);
  }

}

調整 storage.module.ts 的內容,將 StorageService 匯出:

import { Module } from '@nestjs/common';
import { StorageService } from './storage.service';

@Module({
  providers: [
    StorageService
  ],
  exports: [
    StorageService
  ]
})
export class StorageModule {}

再來我們設計一下 book.service.ts 的內容,將 StorageService 注入並設計一套存取資料的方法,同樣在 constructor 印出含有亂數的字串:

import { Injectable } from '@nestjs/common';
import { StorageService } from '../storage/storage.service';

@Injectable()
export class BookService {

  constructor(
    private readonly storage: StorageService
  ) {
    console.log(`Book: ${Math.random()}`);
  }

  public getBooks(): any[] {
    return this.storage.getItems();
  }

  public addBook(book: any): void {
    this.storage.addItem(book);
  }

}

因為有用到 StorageService,故要引入 StorageModule,這邊我們修改一下 book.module.ts,並將 BookService 匯出:

import { Module } from '@nestjs/common';
import { StorageModule } from '../storage/storage.module';
import { BookService } from './book.service';

@Module({
  imports: [
    StorageModule
  ],
  providers: [
    BookService
  ],
  exports: [
    BookService
  ]
})
export class BookModule {}

最後就是調整 app.service.tsapp.controller.ts 了,這裡我們先改一下 app.service.ts 的內容,將 BookServiceStorageService 注入,並為它們各設計一套存取方法,然後也在 constructor 印出含有亂數之字串:

import { Injectable } from '@nestjs/common';
import { BookService } from './common/book/book.service';
import { StorageService } from './common/storage/storage.service';

@Injectable()
export class AppService {

  constructor(
    private readonly bookService: BookService,
    private readonly storage: StorageService
  ) {
    console.log(`AppService: ${Math.random()}`);
  }

  public addBookToStorage(book: any): void {
    this.storage.addItem(book);
  }

  public addBookToBookStorage(book: any): void {
    this.bookService.addBook(book);
  }

  public getStorageList(): any[] {
    return this.storage.getItems();
  }

  public getBookList(): any[] {
    return this.bookService.getBooks();
  }

}

修改 app.controller.ts,在 constructor 透過 AppService 去呼叫 BookServiceStorageService 的存入方法,並設計一個 /compare 的路由來看看是否存取相同的 StorageService 實例:

import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(
    private readonly appService: AppService
  ) {
    this.appService.addBookToStorage({ name: 'Nest Tutorial' });
    this.appService.addBookToBookStorage({ name: 'Angular Tutorial' });
    console.log(`AppController: ${Math.random()}`);
  }

  @Get('/compare')
  getCompare() {
    return {
      storage: this.appService.getStorageList(),
      books: this.appService.getBookList()
    };
  }

}

預設作用域

預設作用域不需要特別指定,所以我們直接啟動 Nest App 即可,啟動後會在終端機看到下方的訊息:

Storage: 0.5154167235100049
Book: 0.003178436868019663
AppService: 0.19088741578100654
AppController: 0.70972377329212

這代表什麼呢?因為是單例模式,在 Nest 建立的時候所有的依賴項目都會被建立起來,並持續到 Nest 關閉為止,所以我們才會在啟動時就看見這些字串,並且不會再看見它們,直到下次重新啟動。

透過瀏覽器查看 http://localhost:3000/compare,會發現與我們預期是相同的,BookModuleAppModule 會共用同一個 StorageService,所以才會回傳兩個一模一樣的資料:
https://ithelp.ithome.com.tw/upload/images/20210523/20119338jAIDuW7mYX.png

請求作用域

我們將請求作用域配置在 BookService 上,所以理論上 StorageService 會是單例的,而 BookServiceAppServiceAppController 會是請求作用域。這裡修改一下 book.service.ts

import { Injectable, Scope } from '@nestjs/common';
import { StorageService } from '../storage/storage.service';

@Injectable({ scope: Scope.REQUEST })
export class BookService {

  constructor(
    private readonly storage: StorageService
  ) {
    console.log(`Book: ${Math.random()}`);
  }

  public getBooks(): any[] {
    return this.storage.getItems();
  }

  public addBook(book: any): void {
    this.storage.addItem(book);
  }

}

接著重新啟動 Nest App,會在終端機看到下方訊息:

Storage: 0.68586411156073

為什麼只有看到 StorageService 印出來的資訊呢?原因是 StorageService 保持在單例模式,所以在啟動時就會被建立,但 BookService 是請求作用域,當有請求進來的時候才會被實例化,所以才會沒有顯示出來。

透過瀏覽器查看 http://localhost:3000/compare,會發現與預期結果相同,在終端機上會看到下方訊息:

Book: 0.0333570635121212
AppService: 0.6894665444881014
AppController: 0.47336587362981764

然後瀏覽器上顯示的結果與預設作用域相同,不過如果這時候按下重新整理的話,會發現資料變多了,原因是我們只要在 AppController 實例化的時候就添加資訊,所以才會增加資料到 StorageService
https://ithelp.ithome.com.tw/upload/images/20210523/20119338GBZG2rNXf7.png

獨立作用域

這部分我們改成將 StorageService 設置成獨立作用域,所以將 BookServicescope 移除,並修改 storage.service.ts

import { Injectable, Scope } from '@nestjs/common';

@Injectable({ scope: Scope.TRANSIENT })
export class StorageService {

  constructor() {
    console.log(`Storage: ${Math.random()}`);
  }

  private list: any[] = [];

  public getItems(): any[] {
    return this.list;
  }

  public addItem(item: any): void {
    this.list.push(item);
  }

}

重新啟動 Nest App,會在終端機看到以下訊息:

Storage: 0.15469395107871975
Storage: 0.8083162424289829
Book: 0.7182461464132914
AppService: 0.9978782563846749
AppController: 0.5198170904633788

會發現 StorageService 建立了兩個實例,原因是獨立作用域在各個 Provider 之間是不共享實例的,而 StorageServiceBookServiceAppService 各建立了一次,所以會有兩個實例。

透過瀏覽器查看 http://localhost:3000/compare,會發現兩者資料不一致,這與我們預期是相同的,因為它們是不同的實例:
https://ithelp.ithome.com.tw/upload/images/20210523/201193385T9c23eiPl.png

小結

在一般情況下其實不太會去變動注入作用域的範圍,但在某些特定情況下是必要的,雖然說不太會變動,但我認為這篇的內容可以對 Nest 的依賴注入規則有更進一步的理解。這邊附上今天的懶人包:

  1. Nest 預設採用單例模式維護實例。
  2. 透過改變注入作用域的範圍來改變實例的維護規則。
  3. 共有三個作用域規則:預設作用域、請求作用域、獨立作用域。
  4. 預設作用域即單例模式。
  5. 請求作用域會針對各個請求建立實例。
  6. 獨立作用域會使各 Provider 之間不共享。
  7. 請求作用域可以透過注入 REQUEST 來取得請求物件。

上一篇
[NestJS 帶你飛!] DAY16 - Configuration
下一篇
[NestJS 帶你飛!] DAY18 - Lifecycle Hooks
系列文
NestJS 帶你飛!32

尚未有邦友留言

立即登入留言