☰ Mục lục series
Tài liệu NestJS tiếng Việt
  1. Nhập môn (Overview)
    1. Giới thiệu
    2. First steps
    3. Controllers
    4. Providers
    5. Modules
    6. Middleware
    7. Exception filters
    8. Pipes
    9. Guards
    10. Interceptors
    11. Custom decorators
  2. Nền tảng (Fundamentals)
    1. Custom providers
    2. Asynchronous providers
    3. Dynamic modules
    4. Injection scopes
    5. Circular dependency
    6. Module reference
    7. Lazy-loading modules
    8. Execution context
    9. Lifecycle events
    10. Discovery service
    11. Platform agnosticism
    12. Testing
  3. Kỹ thuật (Techniques)
    1. Configuration
    2. Database (SQL)
    3. MongoDB
    4. Validation
    5. Caching
    6. Serialization
    7. Versioning
    8. Task scheduling
    9. Queues
    10. Logging
    11. Cookies
    12. Events
    13. Compression
    14. File upload
    15. Streaming files
    16. HTTP module
    17. Session
    18. MVC
    19. Performance (Fastify)
    20. Server-Sent Events
  4. Bảo mật (Security)
    1. Authentication
    2. Authorization
    3. Encryption & Hashing
    4. Helmet
    5. CORS
    6. CSRF
    7. Rate limiting
    8. Vòng đời request
  5. OpenAPI (Swagger)
    1. Introduction
    2. Types and parameters
    3. Operations
    4. Security
    5. Mapped types
    6. Decorators
    7. CLI plugin
    8. Other features
  6. Recipes
    1. REPL
    2. CRUD generator
    3. SWC (fast compiler)
    4. Passport (auth)
    5. Hot reload
    6. MikroORM
    7. Router module
    8. Health checks (Terminus)
    9. CQRS
    10. Prisma
    11. Serve static
    12. Commander
    13. Async local storage
  7. WebSockets
    1. Gateways
    2. Exception filters
    3. Pipes
    4. Guards
    5. Interceptors
    6. Adapters
  8. Microservices
    1. Overview
    2. Redis
    3. MQTT
    4. NATS
    5. RabbitMQ
    6. Kafka
    7. gRPC
    8. Custom transporters
    9. Exception filters
    10. Pipes
    11. Pre-request hooks
    12. Guards
    13. Interceptors
  9. GraphQL
    1. Quick start
    2. Resolvers
    3. Mutations
    4. Subscriptions
    5. Scalars
    6. Directives
    7. Interfaces
    8. Unions and enums
    9. Field middleware
    10. Mapped types
    11. Plugins
    12. Complexity
    13. Extensions
    14. CLI plugin
    15. Generating SDL
    16. Sharing models
    17. Other features
    18. Federation
  10. CLI & triển khai
    1. CLI overview
    2. Workspaces
    3. Libraries
    4. Usage
    5. Scripts
    6. Deployment
    7. Standalone apps
  11. FAQ
    1. Serverless
    2. HTTP adapter
    3. Keep-Alive connections
    4. Global path prefix
    5. Raw body
    6. Hybrid application
    7. HTTPS & multiple servers
    8. Common errors
  12. Phụ lục
    1. Bảng thuật ngữ
Nền tảng (Fundamentals)

Phạm vi inject (Injection scopes)

Bản dịch từ docs.nestjs.com/fundamentals/injection-scopes

Với những ai đến từ các ngôn ngữ lập trình khác, có thể sẽ bất ngờ khi biết rằng trong Nest, hầu như mọi thứ đều được chia sẻ (shared) giữa các request đến. Chúng ta có một connection pool tới database, các service singleton với global state, v.v. Hãy nhớ rằng Node.js không theo mô hình request/response Multi-Threaded Stateless, trong đó mỗi request được xử lý bởi một thread riêng. Do đó, việc dùng các instance singleton là hoàn toàn an toàn cho ứng dụng của chúng ta.

Ghi chú người dịch Nếu bạn quen với Node/Express: Express cũng chỉ tạo một instance app duy nhất và xử lý mọi request trên cùng một event loop, nên bản chất “singleton mặc định” ở đây không có gì lạ. Cái khác là Nest đóng gói việc này thành khái niệm scope tường minh, còn ở Express bạn tự phải cẩn thận không lưu state theo request vào biến module-level.

Tuy nhiên, có những trường hợp ngoại lệ mà vòng đời (lifetime) gắn theo request lại là hành vi mong muốn, ví dụ: cache theo từng request trong ứng dụng GraphQL, request tracking, và multi-tenancy. Phạm vi inject (injection scope) cung cấp cơ chế để có được vòng đời provider mong muốn.

Phạm vi provider (Provider scope)

Một provider có thể có một trong các phạm vi (scope) sau:

DEFAULT Một instance duy nhất của provider được chia sẻ cho toàn bộ ứng dụng. Vòng đời của instance gắn trực tiếp với vòng đời ứng dụng. Sau khi ứng dụng bootstrap xong, tất cả provider singleton đã được khởi tạo. Đây là phạm vi mặc định.
REQUEST Một instance mới của provider được tạo riêng cho mỗi request đến. Instance này sẽ bị garbage-collected sau khi request xử lý xong.
TRANSIENT Provider transient không được chia sẻ giữa các consumer. Mỗi consumer inject một provider transient sẽ nhận được một instance mới, riêng cho mình.

Gợi ý Dùng phạm vi singleton là lựa chọn được khuyến nghị cho hầu hết trường hợp. Việc chia sẻ provider giữa các consumer và giữa các request nghĩa là instance có thể được cache và chỉ khởi tạo một lần duy nhất, lúc ứng dụng khởi động.

Cách dùng

Chỉ định phạm vi inject bằng cách truyền thuộc tính scope vào object option của decorator @Injectable():

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

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

Tương tự, với custom provider, thiết lập thuộc tính scope ở dạng khai báo đầy đủ (long-hand) khi đăng ký provider:

{
  provide: 'CACHE_MANAGER',
  useClass: CacheManager,
  scope: Scope.TRANSIENT,
}

Gợi ý Import enum Scope từ @nestjs/common

Phạm vi singleton được dùng mặc định và không cần khai báo. Nếu bạn thực sự muốn khai báo một provider là singleton, dùng giá trị Scope.DEFAULT cho thuộc tính scope.

Cảnh báo WebSocket gateway không nên dùng provider request-scoped vì chúng phải hoạt động như singleton. Mỗi gateway bọc một socket thực và không thể khởi tạo nhiều lần. Giới hạn này cũng áp dụng cho một số provider khác, như Passport strategy hay Cron controller.

Phạm vi controller (Controller scope)

Controller cũng có thể có scope, áp dụng cho tất cả các hàm xử lý route (route handler) khai báo trong controller đó. Giống như phạm vi provider, phạm vi controller quy định vòng đời của nó. Với controller request-scoped, một instance mới được tạo cho mỗi request đến, và bị garbage-collected khi request xử lý xong.

Khai báo phạm vi controller bằng thuộc tính scope của object ControllerOptions:

@Controller({
  path: 'cats',
  scope: Scope.REQUEST,
})
export class CatsController {}

Phân cấp phạm vi (Scope hierarchy)

Phạm vi REQUEST lan truyền ngược lên (bubbles up) chuỗi inject. Một controller phụ thuộc vào một provider request-scoped thì bản thân controller đó cũng sẽ trở thành request-scoped.

Hãy tưởng tượng đồ thị phụ thuộc sau: CatsController <- CatsService <- CatsRepository. Nếu CatsService là request-scoped (còn các provider khác vẫn là singleton mặc định), CatsController sẽ trở thành request-scoped vì nó phụ thuộc vào service đã được inject. CatsRepository, do không phụ thuộc vào provider nào request-scoped, sẽ vẫn giữ nguyên singleton.

Các phụ thuộc (dependency) transient-scoped không theo quy luật này. Nếu một DogsService singleton-scoped inject một provider LoggerService transient, nó sẽ nhận một instance mới của provider đó. Tuy nhiên, DogsService vẫn giữ nguyên singleton-scoped, nên inject nó ở bất kỳ đâu cũng sẽ không trả về một instance mới của DogsService. Nếu đây là hành vi mong muốn, DogsService cũng phải được đánh dấu tường minh là TRANSIENT.

Ghi chú người dịch Lý do scope “lan ngược lên”: một singleton được tạo một lần lúc khởi động app, còn provider request-scoped chỉ tồn tại khi có request. Singleton không thể giữ tham chiếu tới thứ chưa tồn tại, nên mọi class inject nó (và class inject các class đó…) buộc phải tạo lại theo từng request. Hệ quả thực tế: chỉ cần một service ở tầng dưới dùng Scope.REQUEST (ví dụ để đọc REQUEST), cả chuỗi controller/service phía trên bị tạo mới mỗi request, ảnh hưởng hiệu năng. TRANSIENT thì khác: nó giống factory, mỗi nơi inject nhận một instance riêng ngay lúc khởi động, nên không lan ngược lên.

Request provider

Trong ứng dụng HTTP server-based (ví dụ dùng @nestjs/platform-express hoặc @nestjs/platform-fastify), có thể bạn sẽ muốn truy cập tham chiếu tới request object gốc khi dùng provider request-scoped. Bạn có thể làm điều này bằng cách inject object REQUEST.

Provider REQUEST vốn dĩ đã là request-scoped, nghĩa là bạn không cần khai báo tường minh scope REQUEST khi dùng nó. Thậm chí nếu bạn cố tình khai báo, nó cũng sẽ bị bỏ qua. Bất kỳ provider nào phụ thuộc vào một provider request-scoped sẽ tự động trở thành request-scoped, và hành vi này không thể thay đổi.

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

@Injectable({ scope: Scope.REQUEST })
export class CatsService {
  constructor(@Inject(REQUEST) private request: Request) {}
}

Do có khác biệt về nền tảng/giao thức bên dưới, cách bạn truy cập request đến sẽ hơi khác một chút đối với ứng dụng Microservice hay GraphQL. Trong ứng dụng GraphQL, bạn inject CONTEXT thay vì REQUEST:

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

@Injectable({ scope: Scope.REQUEST })
export class CatsService {
  constructor(@Inject(CONTEXT) private context) {}
}

Sau đó bạn cấu hình giá trị context (trong GraphQLModule) để nó chứa request như một thuộc tính của mình.

Inquirer provider

Nếu bạn muốn lấy ra class nơi một provider được khởi tạo, chẳng hạn trong các provider logging hoặc metrics, bạn có thể inject token INQUIRER.

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

@Injectable({ scope: Scope.TRANSIENT })
export class HelloService {
  constructor(@Inject(INQUIRER) private parentClass: object) {}

  sayHello(message: string) {
    console.log(`${this.parentClass?.constructor?.name}: ${message}`);
  }
}

Rồi dùng như sau:

import { Injectable } from '@nestjs/common';
import { HelloService } from './hello.service.js';

@Injectable()
export class AppService {
  constructor(private helloService: HelloService) {}

  getRoot(): string {
    this.helloService.sayHello('My name is getRoot');

    return 'Hello world!';
  }
}

Trong ví dụ trên, khi AppService#getRoot được gọi, dòng "AppService: My name is getRoot" sẽ được log ra console.

Hiệu năng (Performance)

Dùng provider request-scoped sẽ ảnh hưởng đến hiệu năng ứng dụng. Dù Nest cố gắng cache metadata nhiều nhất có thể, nó vẫn phải tạo một instance của class bạn cho mỗi request. Do đó, nó sẽ làm chậm thời gian phản hồi trung bình và kết quả benchmark tổng thể. Trừ khi provider bắt buộc phải request-scoped, bạn nên ưu tiên dùng phạm vi singleton mặc định.

Gợi ý Dù nghe có vẻ đáng ngại, một ứng dụng được thiết kế tốt, tận dụng provider request-scoped hợp lý sẽ không bị chậm quá ~5% về độ trễ.

Durable provider

Như đã đề cập ở phần trên, provider request-scoped có thể làm tăng độ trễ vì chỉ cần có ít nhất 1 provider request-scoped (được inject vào controller, hoặc sâu hơn - inject vào một trong các provider của nó) cũng khiến controller đó trở thành request-scoped. Điều đó nghĩa là nó phải được tạo lại (khởi tạo) cho từng request riêng lẻ (và bị garbage collect sau đó). Nghĩa là, ví dụ với 30 nghìn request song song, sẽ có 30 nghìn instance tạm thời (ephemeral) của controller (và các provider request-scoped của nó).

Việc có một provider dùng chung mà hầu hết các provider khác đều phụ thuộc vào (ví dụ: một kết nối database, hay một logger service), sẽ tự động biến tất cả các provider đó thành request-scoped luôn. Điều này có thể gây khó khăn trong các ứng dụng multi-tenant, đặc biệt với những ứng dụng có một provider “data source” request-scoped trung tâm, lấy header/token từ request object và dựa vào giá trị đó để lấy ra connection/schema database tương ứng (riêng cho tenant đó).

Ghi chú người dịch Multi-tenant ở đây hiểu đơn giản: một ứng dụng phục vụ nhiều khách hàng (tenant) khác nhau, mỗi khách có database/schema riêng, và server phải tách biệt hoàn toàn dữ liệu giữa các khách — tương tự khái niệm “multi-tenancy” bạn hay gặp trong các hệ thống SaaS B2B.

Ví dụ, giả sử bạn có một ứng dụng được 10 khách hàng khác nhau dùng luân phiên. Mỗi khách hàng có data source riêng, và bạn muốn đảm bảo khách hàng A không bao giờ có thể chạm tới database của khách hàng B. Một cách để làm điều này là khai báo một provider “data source” request-scoped - dựa trên request object - xác định “khách hàng hiện tại” là ai và lấy ra database tương ứng. Với cách này, bạn có thể biến ứng dụng của mình thành ứng dụng multi-tenant chỉ trong vài phút. Nhưng, một nhược điểm lớn của cách này là vì phần lớn các component trong ứng dụng của bạn phụ thuộc vào provider “data source”, chúng sẽ ngầm trở thành “request-scoped”, và do đó chắc chắn bạn sẽ thấy ảnh hưởng đến hiệu năng ứng dụng.

Nhưng nếu có một giải pháp tốt hơn thì sao? Vì chúng ta chỉ có 10 khách hàng, chẳng phải chúng ta có thể có 10 DI sub-tree riêng cho từng khách hàng (thay vì tạo lại toàn bộ cây cho mỗi request) hay sao? Nếu provider của bạn không phụ thuộc vào thuộc tính thực sự riêng biệt cho từng request liên tiếp (ví dụ: request UUID) mà thay vào đó có một số thuộc tính cụ thể cho phép chúng ta gộp nhóm (phân loại) chúng, thì không có lý do gì phải tạo lại DI sub-tree cho mỗi request đến.

Và đó chính xác là lúc durable provider phát huy tác dụng.

Trước khi đánh dấu provider là durable, trước tiên ta phải đăng ký một strategy để chỉ cho Nest biết đâu là “các thuộc tính chung của request”, cung cấp logic để gộp nhóm các request - liên kết chúng với DI sub-tree tương ứng.

import {
  HostComponentInfo,
  ContextId,
  ContextIdFactory,
  ContextIdStrategy,
} from '@nestjs/core';
import { Request } from 'express';

const tenants = new Map<string, ContextId>();

export class AggregateByTenantContextIdStrategy implements ContextIdStrategy {
  attach(contextId: ContextId, request: Request) {
    const tenantId = request.headers['x-tenant-id'] as string;
    let tenantSubTreeId: ContextId;

    if (tenants.has(tenantId)) {
      tenantSubTreeId = tenants.get(tenantId);
    } else {
      tenantSubTreeId = ContextIdFactory.create();
      tenants.set(tenantId, tenantSubTreeId);
    }

    // Nếu cây không phải durable, trả về "contextId" gốc
    return (info: HostComponentInfo) =>
      info.isTreeDurable ? tenantSubTreeId : contextId;
  }
}

Gợi ý Tương tự phạm vi request, tính durable cũng lan truyền ngược lên chuỗi inject. Nghĩa là nếu A phụ thuộc vào B và B được đánh dấu là durable, A cũng ngầm trở thành durable (trừ khi durable được đặt tường minh thành false cho provider A).

Cảnh báo Lưu ý strategy này không lý tưởng cho các ứng dụng vận hành với số lượng tenant lớn.

Giá trị trả về từ phương thức attach cho Nest biết định danh context nào nên được dùng cho một host cụ thể. Trong trường hợp này, chúng ta chỉ định rằng tenantSubTreeId nên được dùng thay cho contextId gốc được tự sinh, khi host component (ví dụ: controller request-scoped) được đánh dấu là durable (bạn có thể xem cách đánh dấu provider là durable bên dưới). Ngoài ra, trong ví dụ trên, sẽ không có payload nào được đăng ký (payload = provider REQUEST/CONTEXT đại diện cho “gốc” - cha của sub-tree).

Nếu bạn muốn đăng ký payload cho một durable tree, hãy dùng cấu trúc sau thay thế:

// Giá trị trả về của phương thức `AggregateByTenantContextIdStrategy#attach`:
return {
  resolve: (info: HostComponentInfo) =>
    info.isTreeDurable ? tenantSubTreeId : contextId,
  payload: { tenantId },
};

Giờ đây bất cứ khi nào bạn inject provider REQUEST (hoặc CONTEXT cho ứng dụng GraphQL) bằng @Inject(REQUEST)/@Inject(CONTEXT), object payload sẽ được inject (gồm một thuộc tính duy nhất - tenantId trong trường hợp này).

Được rồi, với strategy này đã sẵn sàng, bạn có thể đăng ký nó ở đâu đó trong code (vì nó áp dụng toàn cục), ví dụ bạn có thể đặt trong file main.ts:

ContextIdFactory.apply(new AggregateByTenantContextIdStrategy());

Gợi ý Class ContextIdFactory được import từ package @nestjs/core.

Miễn là việc đăng ký diễn ra trước khi bất kỳ request nào chạm tới ứng dụng của bạn, mọi thứ sẽ hoạt động đúng như dự kiến.

Cuối cùng, để biến một provider thông thường thành durable provider, chỉ cần đặt flag durable thành true và đổi scope của nó thành Scope.REQUEST (không cần nếu scope REQUEST đã có sẵn trong chuỗi inject):

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

@Injectable({ scope: Scope.REQUEST, durable: true })
export class CatsService {}

Tương tự, với custom provider, thiết lập thuộc tính durable ở dạng khai báo đầy đủ khi đăng ký provider:

{
  provide: 'foobar',
  useFactory: () => { ... },
  scope: Scope.REQUEST,
  durable: true,
}

Nội dung gốc: NestJS Documentation © Kamil Myśliwiec, giấy phép MIT. Bản dịch và ghi chú: Lê Văn Dũng.