NestJS Dependency Injection: token, provider, useClass vs useExisting Trung cấp
Thời gian đọc ước tính: 7 phút
Cần đọc trước
Vấn đề
Phần tiêu đề “Vấn đề”CheckoutService cần gọi cổng thanh toán. Nếu nó tự new PaymentGateway(), mỗi test phải dựng gateway thật, và đổi gateway nghĩa là sửa cả luồng checkout. DI đảo chiều việc này: module đăng ký provider dưới một token, service chỉ yêu cầu token.
DI không cứu một thiết kế thiếu ranh giới. Nó chỉ làm rõ ai cung cấp dependency nào và instance sống bao lâu. Bài này nói về cơ chế của container. Cách thiết kế port và adapter cho cổng thanh toán nằm ở Adapter pattern.
Bốn việc cần phân biệt
Phần tiêu đề “Bốn việc cần phân biệt”| Khái niệm | Là gì | Ví dụ |
|---|---|---|
| Token | Khoá runtime để tìm provider: class, string hoặc symbol | PAYMENT_PORT = Symbol('PAYMENT_PORT') |
| Provider | Cách tạo giá trị cho token: useClass, useValue, useFactory, useExisting |
{ provide: PAYMENT_PORT, useClass: DemoGateway } |
| Module | Phạm vi nhìn thấy: provider chỉ thấy được trong module khai báo, trừ khi export | exports: [PAYMENT_PORT] |
| Scope | Vòng đời instance: DEFAULT, REQUEST, TRANSIENT |
Xem provider scopes |
TypeScript interface và type biến mất sau compile, nên không dùng làm token được. Dùng class, symbol hoặc string với @Inject(token); symbol an toàn hơn string vì không trùng tên giữa các module.
Đăng ký và resolve
Phần tiêu đề “Đăng ký và resolve”Code thật của di-demo.ts:
const PAYMENT_PORT = Symbol('PAYMENT_PORT');type PaymentPort = { charge(orderId: number): Promise<string> };
@Injectable()class CheckoutService { constructor(@Inject(PAYMENT_PORT) private readonly payments: PaymentPort) {} checkout(orderId: number) { return this.payments.charge(orderId); }}
@Module({ providers: [CheckoutService, { provide: PAYMENT_PORT, useClass: DemoPaymentGateway }],})class CheckoutModule {}Đang tải diagram…
Khi bootstrap, Nest quét graph module, đăng ký provider rồi resolve constructor theo token. Chạy demo in payment-for-42 và same default-scoped instance: true: hai lần app.get trả cùng instance vì scope mặc định. Instance chỉ dùng chung trong một application context; 10 replica là 10 instance, nên Map trong provider không phải shared state giữa replica.
useClass và useExisting
Phần tiêu đề “useClass và useExisting”Hai cách này hay bị nhầm. Cùng đăng ký AuditLog làm provider, rồi tạo thêm một token thứ hai trỏ về nó:
| Cấu hình | Số instance | Kết quả đo (Nest 11) | |
|---|---|---|---|
| Trước | { provide: 'AUDIT_CLASS', useClass: AuditLog } |
2 (mỗi binding tự tạo) | app.get(AuditLog) === app.get('AUDIT_CLASS') là false |
| Sau | { provide: 'AUDIT_ALIAS', useExisting: AuditLog } |
1 (alias) | true |
// Muốn hai token cùng một instance (cùng connection, cùng state): dùng useExisting.providers: [AuditLog, { provide: 'AUDIT_ALIAS', useExisting: AuditLog }]Dùng useClass khi muốn chọn class theo môi trường (ví dụ fake cho test, adapter thật cho production). Dùng useExisting khi muốn alias. Nếu singleton giữ connection hay state quan trọng, hãy kiểm tra identity thay vì suy ra từ tên class.
Provider nâng cao
Phần tiêu đề “Provider nâng cao”Tất cả chạy trong providers-demo.ts; output thật ở dưới.
| Nhu cầu | Cách | Lưu ý |
|---|---|---|
| Giá trị tĩnh, fake cho test | useValue |
Không hard-code secret trong module |
| Khởi tạo cần config hoặc bất đồng bộ | useFactory (có thể async) với inject: [...] |
Nest chờ factory xong rồi mới tạo class phụ thuộc |
| Dependency có thể vắng | @Optional() hoặc { token, optional: true } trong inject |
Thiếu provider thì nhận undefined, không lỗi lúc khởi động |
| Module cần cấu hình lúc import | Dynamic module: static forRoot(options) trả về DynamicModule |
Quy ước của Nest: forRoot cấu hình một lần, register cho module gọi, forFeature dùng cấu hình của forRoot và chỉnh một phần cho module gọi |
| Thứ dùng ở mọi nơi (config, logger) | @Global() |
Chỉ cho thứ thật sự toàn cục; lạm dụng làm mất ranh giới |
@Optional thiếu provider -> options = undefinedAsync provider: DB bắt đầu kết nối -> DB kết nối xong -> Repo được tạoDynamic module -> config = { region: 'ap-southeast-1' }// async provider{ provide: 'DB', useFactory: async () => await connect(),}
// dynamic module@Module({})class ConfigModule { static forRoot(options: { region: string }): DynamicModule { return { module: ConfigModule, providers: [{ provide: 'CONFIG', useValue: options }], exports: ['CONFIG'], }; }}Provider tuỳ chỉnh cũng bị giới hạn trong module khai báo. Muốn module khác dùng, exports bằng token (exports: ['CONFIG']) hoặc bằng chính provider object. Factory chỉ nên khởi tạo; connection cần đóng thì dùng lifecycle hook khi shutdown.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Hai thử nghiệm để thấy lỗi cụ thể: xoá binding { provide: PAYMENT_PORT, ... } thì container không resolve được CheckoutService; đổi @Inject sang Symbol('PAYMENT_PORT') mới cũng lỗi vì hai symbol khác nhau dù cùng nhãn. Với test, Test.createTestingModule() kèm overrideProvider() thay provider mà không sửa source.
Checklist production
Phần tiêu đề “Checklist production”- Dùng symbol hoặc class làm token; không dùng string tự do trùng tên giữa module.
- Mỗi module export tối thiểu cần thiết;
@Global()chỉ cho config và logger. - Dùng
useExistingcho alias, và kiểm tra identity khi singleton giữ state hay connection. - Provider async/factory chỉ khởi tạo; đóng connection ở lifecycle hook khi shutdown.
- Nếu cần
forwardRef()ở nhiều chỗ, xem lại ranh giới module thay vì che bằngforwardRef. - Constructor có quá nhiều dependency là tín hiệu nên tách use case, không phải lý do tạo service locator.
- Không giữ state nghiệp vụ trong field của provider mặc định khi chạy nhiều replica.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Tóm tắt
Phần tiêu đề “Tóm tắt”Tự kiểm tra: (1) Vì sao type PaymentPort không đủ để Nest resolve? (2) Hai token cùng trỏ về một class: khi nào có hai instance? (3) PaymentsModule có provider nhưng quên export: module khác thấy gì?
Chạy demo:
npx --prefix examples ts-node examples/src/nestjs-di/di-demo.tsnpx --prefix examples ts-node examples/src/nestjs-di/providers-demo.ts