Ở các chương trước, chúng ta đã chạm tới nhiều khía cạnh của Dependency Injection (DI) (tiêm phụ thuộc) và cách nó được dùng trong Nest. Một ví dụ điển hình là dependency injection dựa trên constructor dùng để inject các instance (thường là service provider) vào class. Chắc bạn cũng không bất ngờ khi biết Dependency Injection được xây dựng vào lõi của Nest một cách rất căn bản. Từ trước tới giờ, chúng ta mới chỉ khám phá một pattern chính. Khi ứng dụng của bạn phức tạp hơn, bạn có thể cần tận dụng đầy đủ các tính năng của hệ thống DI, vậy hãy cùng tìm hiểu chi tiết hơn.
DI fundamentals (Kiến thức nền tảng về DI)
Dependency injection là một kỹ thuật đảo ngược điều khiển (IoC) — bạn giao việc khởi tạo các dependency cho IoC container (trong trường hợp của chúng ta là hệ thống runtime của NestJS), thay vì tự làm việc đó một cách “imperative” (mệnh lệnh, chủ động) trong code của mình. Hãy xem lại ví dụ từ chương Providers đang diễn ra như thế nào.
Đầu tiên, ta định nghĩa một provider. Decorator @Injectable() đánh dấu class CatsService là một provider.
import { Injectable } from '@nestjs/common';
import type { Cat } from './interfaces/cat.interface.js';
@Injectable()
export class CatsService {
private readonly cats: Cat[] = [];
findAll(): Cat[] {
return this.cats;
}
}
import { Injectable } from '@nestjs/common';
@Injectable()
export class CatsService {
constructor() {
this.cats = [];
}
findAll() {
return this.cats;
}
}
Tiếp theo, ta yêu cầu Nest inject provider này vào class controller của mình:
import { Controller, Get } from '@nestjs/common';
import { CatsService } from './cats.service.js';
import type { Cat } from './interfaces/cat.interface.js';
@Controller('cats')
export class CatsController {
constructor(private catsService: CatsService) {}
@Get()
async findAll(): Promise<Cat[]> {
return this.catsService.findAll();
}
}
import { Controller, Get, Bind, Dependencies } from '@nestjs/common';
import { CatsService } from './cats.service.js';
@Controller('cats')
@Dependencies(CatsService)
export class CatsController {
constructor(catsService) {
this.catsService = catsService;
}
@Get()
async findAll() {
return this.catsService.findAll();
}
}
Cuối cùng, ta đăng ký provider này với IoC container của Nest:
import { Module } from '@nestjs/common';
import { CatsController } from './cats/cats.controller.js';
import { CatsService } from './cats/cats.service.js';
@Module({
controllers: [CatsController],
providers: [CatsService],
})
export class AppModule {}
Vậy chính xác thì điều gì đang diễn ra bên dưới để làm cho tất cả hoạt động được? Có ba bước chính trong quá trình này:
- Trong
cats.service.ts, decorator@Injectable()khai báo classCatsServicelà một class có thể được quản lý bởi IoC container của Nest. - Trong
cats.controller.ts,CatsControllerkhai báo một dependency vào tokenCatsServicethông qua constructor injection:
constructor(private catsService: CatsService)
- Trong
app.module.ts, ta liên kết tokenCatsServicevới classCatsServicetừ filecats.service.ts. Chúng ta sẽ xem bên dưới chính xác cách mối liên kết này (còn gọi là đăng ký — registration) diễn ra.
Khi IoC container của Nest khởi tạo một CatsController, đầu tiên nó tìm các dependency*. Khi tìm thấy dependency CatsService, nó thực hiện lookup trên token CatsService, trả về class CatsService, theo bước đăng ký (#3 ở trên). Giả sử scope là SINGLETON (hành vi mặc định), Nest sẽ tạo một instance của CatsService, cache lại, rồi trả về; hoặc nếu đã có instance được cache sẵn thì trả về instance đó.
*Giải thích này đã được đơn giản hóa đôi chút để làm rõ ý chính. Một điểm quan trọng mà chúng ta đã lướt qua là quá trình phân tích code để tìm dependency thực ra rất tinh vi, và diễn ra trong lúc ứng dụng bootstrap (khởi động). Một đặc điểm quan trọng là việc phân tích dependency (hay “xây dựng dependency graph”) mang tính bắc cầu (transitive). Trong ví dụ trên, nếu bản thân CatsService cũng có dependency riêng, chúng cũng sẽ được resolve. Dependency graph đảm bảo các dependency được resolve theo đúng thứ tự — về cơ bản là “từ dưới lên” (bottom up). Cơ chế này giúp developer không phải tự quản lý các dependency graph phức tạp như vậy.
Ghi chú người dịch Nếu bạn quen React Context hoặc constructor injection thủ công trong Go, hãy hình dung IoC container như một “global resolver” tự động: bạn không tự gọi new CatsService() rồi truyền tay xuống (như truyền props qua nhiều tầng component, hay truyền db *sql.DB qua từng hàm ở Go), mà chỉ khai báo “tôi cần CatsService” trong constructor, còn việc tìm/khởi tạo/cache instance là việc của Nest. Đây chính là điểm giúp code dễ test hơn (dễ dàng “mock” một dependency) so với việc gọi new trực tiếp bên trong class.
Standard providers (Provider chuẩn)
Hãy xem kỹ hơn decorator @Module(). Trong app.module, ta khai báo:
@Module({
controllers: [CatsController],
providers: [CatsService],
})
Thuộc tính providers nhận vào một mảng các providers. Từ trước tới giờ, ta đã cung cấp các provider này thông qua một danh sách tên class. Thực ra, cú pháp providers: [CatsService] là dạng viết tắt cho cú pháp đầy đủ hơn:
providers: [
{
provide: CatsService,
useClass: CatsService,
},
];
Giờ khi đã thấy cách viết tường minh này, ta có thể hiểu rõ quá trình đăng ký. Ở đây, ta đang liên kết rõ ràng token CatsService với class CatsService. Cú pháp viết tắt chỉ đơn giản là một cách để đơn giản hóa use-case phổ biến nhất, khi token được dùng để yêu cầu một instance của class có cùng tên.
Custom providers (Provider tùy chỉnh)
Điều gì xảy ra khi nhu cầu của bạn vượt ra ngoài những gì Standard providers (Provider chuẩn) cung cấp? Dưới đây là một vài ví dụ:
- Bạn muốn tự tạo một instance tùy chỉnh thay vì để Nest khởi tạo (hoặc trả về instance đã cache) của một class
- Bạn muốn tái sử dụng một class đã có cho một dependency thứ hai
- Bạn muốn override một class bằng phiên bản mock để test
Nest cho phép bạn định nghĩa Custom providers để xử lý các trường hợp này. Nó cung cấp một vài cách để định nghĩa custom provider. Hãy cùng đi qua từng cách.
Gợi ý Nếu bạn gặp vấn đề với việc resolve dependency, bạn có thể set biến môi trường NEST_DEBUG để có thêm log resolve dependency chi tiết lúc khởi động.
Value providers: useValue
Cú pháp useValue hữu ích khi bạn muốn inject một giá trị hằng (constant), đưa một thư viện bên ngoài vào Nest container, hoặc thay thế một implementation thật bằng một mock object. Giả sử bạn muốn ép Nest dùng một CatsService giả (mock) cho mục đích testing.
import { CatsService } from './cats.service.js';
const mockCatsService = {
/* mock implementation
...
*/
};
@Module({
imports: [CatsModule],
providers: [
{
provide: CatsService,
useValue: mockCatsService,
},
],
})
export class AppModule {}
Trong ví dụ này, token CatsService sẽ resolve về mock object mockCatsService. useValue cần một giá trị — ở đây là một literal object có cùng interface với class CatsService mà nó đang thay thế. Nhờ structural typing của TypeScript, bạn có thể dùng bất kỳ object nào có interface tương thích, kể cả một literal object hay một class instance được khởi tạo bằng new.
Non-class-based provider tokens (Token provider không dựa trên class)
Từ trước tới giờ, ta đã dùng tên class làm provider token (giá trị của thuộc tính provide trong một provider nằm trong mảng providers). Cách này khớp với pattern chuẩn dùng cho constructor based injection, nơi token cũng là một tên class. (Xem lại DI Fundamentals để ôn lại khái niệm token nếu chưa thật rõ). Đôi lúc, ta muốn có sự linh hoạt để dùng string hoặc symbol làm DI token. Ví dụ:
import { connection } from './connection.js';
@Module({
providers: [
{
provide: 'CONNECTION',
useValue: connection,
},
],
})
export class AppModule {}
Trong ví dụ này, ta liên kết một token dạng string ('CONNECTION') với một object connection có sẵn mà ta import từ một file bên ngoài.
Chú ý Ngoài việc dùng string làm giá trị token, bạn cũng có thể dùng symbol của JavaScript hoặc enum của TypeScript.
Ta đã thấy trước đó cách inject một provider bằng pattern constructor based injection chuẩn. Pattern này yêu cầu dependency phải được khai báo bằng một tên class. Custom provider 'CONNECTION' dùng token dạng string. Hãy xem cách inject một provider như vậy. Để làm điều đó, ta dùng decorator @Inject(). Decorator này nhận một tham số duy nhất — chính là token.
@Injectable()
export class CatsRepository {
constructor(@Inject('CONNECTION') connection: Connection) {}
}
@Injectable()
@Dependencies('CONNECTION')
export class CatsRepository {
constructor(connection) {}
}
Gợi ý Decorator @Inject() được import từ package @nestjs/common.
Mặc dù trong các ví dụ trên ta trực tiếp dùng string 'CONNECTION' để minh họa, nhưng để tổ chức code sạch hơn, best practice là định nghĩa token trong một file riêng, chẳng hạn constants.ts. Hãy đối xử với chúng giống như cách bạn xử lý symbol hay enum được định nghĩa trong file riêng và import khi cần dùng.
Interfaces and abstract classes (Interface và abstract class)
Các type/interface của TypeScript bị xóa (erased) trong quá trình biên dịch, nên Nest không thể tham chiếu tới chúng ở runtime. Điều này nghĩa là một interface có thể mô tả hình dạng (shape) của một dependency, nhưng bản thân nó không thể dùng làm DI token.
Vì Nest resolve provider dựa trên token ở runtime, hãy dùng token dạng string hoặc Symbol khi đăng ký provider cho một interface:
export interface LoggerService {
log(message: string): void;
}
export const LOGGER_SERVICE = Symbol('LOGGER_SERVICE');
@Injectable()
export class PinoLoggerService implements LoggerService {
log(message: string) {
// implementation details
}
}
@Module({
providers: [
{
provide: LOGGER_SERVICE,
useClass: PinoLoggerService,
},
],
})
export class AppModule {}
Để inject provider này, truyền token đó vào decorator @Inject():
@Injectable()
export class CatsService {
constructor(
@Inject(LOGGER_SERVICE)
private readonly logger: LoggerService,
) {}
}
Không giống interface, abstract class tồn tại ở runtime. Bạn có thể dùng một abstract class vừa làm “hợp đồng” (contract) kiểu TypeScript, vừa làm DI token:
export abstract class LoggerService {
abstract log(message: string): void;
}
@Injectable()
export class PinoLoggerService implements LoggerService {
log(message: string) {
// implementation details
}
}
@Module({
providers: [
{
provide: LoggerService,
useClass: PinoLoggerService,
},
],
})
export class AppModule {}
Với abstract class token, constructor-based injection có thể dùng trực tiếp type abstract class mà không cần @Inject():
@Injectable()
export class CatsService {
constructor(private readonly logger: LoggerService) {}
}
Hãy dùng token dạng string hoặc Symbol khi bạn muốn DI token ở runtime tách biệt khỏi một class cụ thể. Token Symbol đặc biệt hữu ích cho library và ứng dụng lớn, vì mỗi symbol có một định danh (identity) runtime duy nhất, giúp tránh xung đột ngoài ý muốn có thể xảy ra khi các provider không liên quan dùng chung một token string. Khi dùng symbol token, hãy export nó từ một file dùng chung và tái sử dụng cùng một symbol instance ở mọi nơi provider được đăng ký và inject. Hãy dùng abstract class khi một artifact vừa đóng vai trò contract vừa là runtime token, và bạn muốn constructor injection đơn giản hơn. Một interface thuần vẫn là lựa chọn tốt khi type đó chỉ dùng để kiểm tra ở compile-time và không cần DI token.
Ghi chú người dịch Đây là điểm khác biệt căn bản so với Go: ở Go, interface tồn tại ở runtime và bạn có thể dùng trực tiếp làm kiểu tham số constructor để “đảo ngược điều khiển” thủ công. Ở TypeScript, interface chỉ là “hợp đồng ảo” lúc compile — sau khi build ra JS thì biến mất hoàn toàn, nên Nest (hoạt động dựa trên reflection ở runtime) không thể dùng interface làm token được. Đây là lỗi rất hay gặp khi mới học Nest: viết constructor(private logger: LoggerService) với LoggerService là interface, chạy sẽ báo lỗi “Nest can’t resolve dependencies” dù code TypeScript compile hoàn toàn bình thường.
Class providers: useClass
Cú pháp useClass cho phép bạn xác định động (dynamically) một class mà token nên resolve tới. Ví dụ, giả sử ta có một class ConfigService (trừu tượng hoặc mặc định). Tùy vào môi trường hiện tại, ta muốn Nest cung cấp một implementation khác nhau cho configuration service. Đoạn code sau đây hiện thực chiến lược đó.
const configServiceProvider = {
provide: ConfigService,
useClass:
process.env.NODE_ENV === 'development'
? DevelopmentConfigService
: ProductionConfigService,
};
@Module({
providers: [configServiceProvider],
})
export class AppModule {}
Hãy xem qua một vài chi tiết trong đoạn code mẫu này. Bạn sẽ để ý rằng ta định nghĩa configServiceProvider bằng một literal object trước, rồi truyền nó vào thuộc tính providers của module decorator. Đây chỉ là một cách tổ chức code, nhưng về mặt chức năng thì tương đương với các ví dụ ta đã dùng từ đầu chương tới giờ.
Ngoài ra, ta đã dùng tên class ConfigService làm token. Với bất kỳ class nào phụ thuộc vào ConfigService, Nest sẽ inject một instance của class được cung cấp (DevelopmentConfigService hoặc ProductionConfigService), ghi đè lên bất kỳ implementation mặc định nào có thể đã được khai báo ở nơi khác (ví dụ: một ConfigService được khai báo với decorator @Injectable()).
Factory providers: useFactory
Cú pháp useFactory cho phép bạn tạo provider một cách động (dynamically). Provider thực tế sẽ được cung cấp bởi giá trị trả về từ một hàm factory. Hàm factory có thể đơn giản hoặc phức tạp tùy theo nhu cầu. Một factory đơn giản có thể không phụ thuộc vào provider nào khác. Một factory phức tạp hơn có thể tự inject các provider khác mà nó cần để tính ra kết quả. Với trường hợp sau, cú pháp factory provider có một cặp cơ chế liên quan:
- Hàm factory có thể nhận vào các tham số (tùy chọn).
- Thuộc tính
inject(tùy chọn) nhận vào một mảng các provider mà Nest sẽ resolve và truyền vào làm tham số cho hàm factory trong quá trình khởi tạo. Các provider này cũng có thể được đánh dấu là optional (tùy chọn). Hai danh sách này nên tương ứng với nhau: Nest sẽ truyền các instance từ danh sáchinjectlàm tham số cho hàm factory theo đúng thứ tự đó. Ví dụ dưới đây minh họa điều này.
const connectionProvider = {
provide: 'CONNECTION',
useFactory: (optionsProvider: MyOptionsProvider, optionalProvider?: string) => {
const options = optionsProvider.get();
return new DatabaseConnection(options);
},
inject: [MyOptionsProvider, { token: 'SomeOptionalProvider', optional: true }],
// \______________/ \__________________/
// Provider này Provider với token này
// là bắt buộc. có thể resolve về `undefined`.
};
@Module({
providers: [
connectionProvider,
MyOptionsProvider, // class-based provider
// { provide: 'SomeOptionalProvider', useValue: 'anything' },
],
})
export class AppModule {}
const connectionProvider = {
provide: 'CONNECTION',
useFactory: (optionsProvider, optionalProvider) => {
const options = optionsProvider.get();
return new DatabaseConnection(options);
},
inject: [MyOptionsProvider, { token: 'SomeOptionalProvider', optional: true }],
// \______________/ \__________________/
// Provider này Provider với token này
// là bắt buộc. có thể resolve về `undefined`.
};
@Module({
providers: [
connectionProvider,
MyOptionsProvider, // class-based provider
// { provide: 'SomeOptionalProvider', useValue: 'anything' },
],
})
export class AppModule {}
Alias providers: useExisting
Cú pháp useExisting cho phép bạn tạo alias (bí danh) cho các provider đã tồn tại. Điều này tạo ra hai cách để truy cập cùng một provider. Trong ví dụ dưới đây, token (dạng string) 'AliasedLoggerService' là alias cho token (dạng class) LoggerService. Giả sử ta có hai dependency khác nhau, một cho 'AliasedLoggerService' và một cho LoggerService. Nếu cả hai dependency đều có scope SINGLETON, chúng sẽ cùng resolve về một instance.
@Injectable()
class LoggerService {
/* implementation details */
}
const loggerAliasProvider = {
provide: 'AliasedLoggerService',
useExisting: LoggerService,
};
@Module({
providers: [LoggerService, loggerAliasProvider],
})
export class AppModule {}
Non-service based providers (Provider không dựa trên service)
Mặc dù provider thường cung cấp service, chúng không giới hạn ở cách dùng đó. Một provider có thể cung cấp bất kỳ giá trị nào. Ví dụ, một provider có thể cung cấp một mảng các configuration object dựa trên môi trường hiện tại, như bên dưới:
const configFactory = {
provide: 'CONFIG',
useFactory: () => {
return process.env.NODE_ENV === 'development' ? devConfig : prodConfig;
},
};
@Module({
providers: [configFactory],
})
export class AppModule {}
Export custom provider (Export provider tùy chỉnh)
Giống như bất kỳ provider nào, một custom provider có phạm vi (scope) trong module đã khai báo nó. Để các module khác nhìn thấy được, nó phải được export. Để export một custom provider, ta có thể dùng token của nó hoặc dùng cả provider object đầy đủ.
Ví dụ sau đây cho thấy cách export bằng token:
const connectionFactory = {
provide: 'CONNECTION',
useFactory: (optionsProvider: OptionsProvider) => {
const options = optionsProvider.get();
return new DatabaseConnection(options);
},
inject: [OptionsProvider],
};
@Module({
providers: [connectionFactory],
exports: ['CONNECTION'],
})
export class AppModule {}
const connectionFactory = {
provide: 'CONNECTION',
useFactory: (optionsProvider) => {
const options = optionsProvider.get();
return new DatabaseConnection(options);
},
inject: [OptionsProvider],
};
@Module({
providers: [connectionFactory],
exports: ['CONNECTION'],
})
export class AppModule {}
Ngoài ra, bạn có thể export bằng cả provider object đầy đủ:
const connectionFactory = {
provide: 'CONNECTION',
useFactory: (optionsProvider: OptionsProvider) => {
const options = optionsProvider.get();
return new DatabaseConnection(options);
},
inject: [OptionsProvider],
};
@Module({
providers: [connectionFactory],
exports: [connectionFactory],
})
export class AppModule {}
const connectionFactory = {
provide: 'CONNECTION',
useFactory: (optionsProvider) => {
const options = optionsProvider.get();
return new DatabaseConnection(options);
},
inject: [OptionsProvider],
};
@Module({
providers: [connectionFactory],
exports: [connectionFactory],
})
export class AppModule {}