Dynamic module trong NestJS: forRoot, forRootAsync và vì sao import hai lần thành hai instance Trung cấp
Thời gian đọc ước tính: 9 phút
Vấn đề
Phần tiêu đề “Vấn đề”Team viết PaymentModule bọc cổng thanh toán, dùng lại cho hai app: shop và admin. Mỗi app có API key và base URL riêng (sk_live_shop_abc với …/shop, sk_live_admin_xyz với …/admin). Key không được hard-code: app đọc từ ConfigService lúc khởi động.
Module tĩnh không nhận tham số. Cần module nhận cấu hình lúc import, chờ config sẵn sàng rồi mới tạo PaymentClient.
Câu hỏi: viết module nhận cấu hình thế nào, đặt tên method ra sao, và vì sao gọi forRoot() ở hai chỗ lại sinh hai PaymentClient?
Chọn gì? (tóm tắt trước)
Phần tiêu đề “Chọn gì? (tóm tắt trước)”| Tình huống | Chọn | Điều kiện |
|---|---|---|
| Cấu hình biết sẵn (test, giá trị cố định) | PaymentModule.forRoot({ apiKey, baseUrl }) |
Không đọc secret trực tiếp từ code |
Cấu hình đến từ ConfigService, secret manager |
forRootAsync({ imports, inject, useFactory }) |
imports chứa module export provider được inject (trừ provider global) |
| Viết module cấu hình được, ít boilerplate | ConfigurableModuleBuilder |
Module tự khai báo exports cho provider công khai |
| Cả app dùng một client | Gọi forRoot một lần ở AppModule, isGlobal: true hoặc export lại qua một module chung |
Không gọi forRoot lại ở feature module |
| Mỗi module cần cấu hình riêng | register() ở từng module |
Chấp nhận mỗi lần import là một instance riêng |
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”Static module khai báo metadata cố định trong @Module(). Dynamic module là một object kiểu DynamicModule do một static method trả về; Nest coi object đó như metadata bổ sung cho class module, rồi DI container tạo provider như bình thường.
| Thuộc tính | Ý nghĩa | Trong PaymentModule |
|---|---|---|
module |
Class module được mở rộng (bắt buộc) | PaymentModule |
providers |
Provider sinh theo tham số | Token options + PaymentClient |
exports |
Provider module khác được thấy | PaymentClient |
imports |
Module mà provider bên trong cần | ConfigModule (bản async) |
global |
Đăng ký ở global scope, không cần import lại | Chỉ khi cố ý |
Bản viết tay ngắn nhất, chạy trong demo:
@Module({})class ManualPaymentModule { static forRoot(options: PaymentOptions): DynamicModule { return { module: ManualPaymentModule, providers: [{ provide: 'MANUAL_PAYMENT_OPTIONS', useValue: options }], exports: ['MANUAL_PAYMENT_OPTIONS'], }; }}Tên method là quy ước: docs ghi “không có luật cứng”, nhưng các package @nestjs/* theo hướng dẫn sau, mỗi method thường kèm bản …Async():
| Method | Theo docs | Ví dụ |
|---|---|---|
register() |
Cấu hình cho riêng module gọi nó; mỗi chỗ import có thể khác nhau | HTTP client với base URL theo feature |
forRoot() |
Cấu hình một lần, dùng lại ở nhiều nơi | Kết nối database, PaymentModule của app |
forFeature() |
Dùng cấu hình của forRoot(), chỉnh phần riêng cho module gọi |
Repository của từng entity |
Cách hoạt động
Phần tiêu đề “Cách hoạt động”forRootAsync không tạo gì ngay. Nó trả object chứa một factory provider cho token options.
importsAppModule gọi PaymentModule.forRootAsync(). Hàm chỉ trả một object DynamicModule; chưa provider nào được tạo.
- Import:
forRootAsync()chạy khi decorator củaAppModuleđược đánh giá, trả{ module, imports, providers }. Chưa có instance nào. - Compile:
PaymentModuleimportConfigModulevà có providerPAYMENT_MODULE_OPTIONS(useFactory,inject: [ConfigService]). - Chờ dependency: factory cần
ConfigService.PaymentModulechỉ thấy provider của chính nó và export của module nó import. - Resolve options: factory có thể
async; Nest chờ promise xong rồi gán giá trị cho token. - Tạo client:
PaymentClientnhận options qua@Inject(PAYMENT_MODULE_OPTIONS). Bootstrap xong.
Vì sao phải imports: [ConfigModule]? Factory provider thuộc PaymentModule, không thuộc AppModule. AppModule import ConfigModule không làm PaymentModule thấy ConfigService. Ngoại lệ: ConfigModule.forRoot({ isGlobal: true }) của @nestjs/config đăng ký global, khi đó không cần import lại.
Chi tiết vận hành
Phần tiêu đề “Chi tiết vận hành”ConfigurableModuleBuilder (trong @nestjs/common) sinh boilerplate:
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN: PAYMENT_OPTIONS } = new ConfigurableModuleBuilder<PaymentOptions>({ moduleName: 'Payment' }) .setClassMethodName('forRoot') .setExtras({ isGlobal: false }, (definition, extras) => ({ ...definition, global: extras.isGlobal, })) .build();
@Injectable()export class PaymentClient { constructor(@Inject(PAYMENT_OPTIONS) readonly options: PaymentOptions) {}}
@Module({ providers: [PaymentClient], exports: [PaymentClient] })export class PaymentModule extends ConfigurableModuleClass {}- Mặc định class sinh ra có
register()vàregisterAsync().setClassMethodName('forRoot')đổi thànhforRoot()vàforRootAsync(). MODULE_OPTIONS_TOKEN: cómoduleName: 'Payment'thì là stringPAYMENT_MODULE_OPTIONS, không có thì là chuỗi ngẫu nhiên. Luôn import hằng số.- Bản async nhận
useFactory+inject, hoặcuseClass/useExistingvới methodcreate()(đổi bằngsetFactoryMethodName). setExtrasthêm tham số không thuộc options. Theo source 11.2.6,isGlobalbị loại khỏi giá trị inject quaPAYMENT_OPTIONS; chỉ hàm transform nhận nó.- Builder không tự thêm
exports. Provider công khai phải nằm trongexportscủa@Module()trên class kế thừa. - Nest 11 định danh dynamic module theo tham chiếu object (
moduleIdGeneratorAlgorithmmặc định'reference'). Mỗi lần gọiforRoot()trả object mới, nên là một module mới.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Demo ở examples/src/nestjs-dynamic-modules/ (NestJS 11.2.6). @nestjs/config không có trong examples, nên config.module.ts là bản tối giản: ConfigService đọc process.env, được ConfigModule export.
PaymentModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], useFactory: async (config: ConfigService): Promise<PaymentOptions> => ({ apiKey: config.getOrThrow('PAYMENT_API_KEY'), baseUrl: config.getOrThrow('PAYMENT_BASE_URL'), timeoutMs: Number(config.get('PAYMENT_TIMEOUT_MS') ?? 3000), }),})npx --prefix examples ts-node examples/src/nestjs-dynamic-modules/main.tsOutput thật (object in trên một dòng, lỗi xuống dòng cho dễ đọc):
[2] shop: factory chạy, ConfigService đã sẵn sàng -> PaymentClient được tạo options = { apiKey: 'sk_live_shop_abc', baseUrl: 'https://pay.example.test/shop', timeoutMs: 3000 }[2] admin: factory chạy, ConfigService đã sẵn sàng -> PaymentClient được tạo options = { apiKey: 'sk_live_admin_xyz', baseUrl: 'https://pay.example.test/admin', timeoutMs: 3000 }[3] quên imports: [ConfigModule] -> Nest can't resolve dependencies of the PAYMENT_MODULE_OPTIONS (?). Please make sure that the argument ConfigService at index [0] is available in the PaymentModule module.[4] quên exports -> Nest can't resolve dependencies of the CheckoutService (?). Please make sure that the argument "PAYMENT_CLIENT" at index [0] is available in the NoExportApp module.[5a] forRoot() gọi 2 lần: PaymentClient tạo ra = 2, orders === refunds: false[5b] dùng chung một const: PaymentClient tạo ra = 1, orders === refunds: true[5c] gọi 2 lần + moduleIdGeneratorAlgorithm: 'deep-hash': PaymentClient tạo ra = 1, orders === refunds: true[5d] forRoot({ isGlobal: true }) ở AppModule, feature module không import: PaymentClient tạo ra = 1, orders === refunds: trueỞ [5a], OrdersModule và RefundsModule cùng gọi forRoot(OPTS) với cùng options vẫn ra hai client. [5b]–[5d] là ba cách về một instance.
Đang tải diagram…
Hai client nghĩa là hai connection pool, hai bộ đếm rate limit, hai circuit breaker tách rời.
Phần còn thiếu cho production: validate options lúc khởi động (xem validate env), timeout và retry cho HTTP call, idempotency key cho lệnh charge.
Trade-offs
Phần tiêu đề “Trade-offs”Ưu điểm
-
Một module dùng lại cho nhiều app, cấu hình nằm ở chỗ import.
-
forRootAsynccho phép đọc secret, gọi secret manager trước khi tạo client. -
ConfigurableModuleBuildersinh sẵn token, method sync/async, kiểu options.
< slot="cons"> >
-
Số instance phụ thuộc số lần gọi method, không nhìn thấy từ tên module.
-
Lỗi DI của factory nằm trong module được sinh, thông báo trỏ tới token khó đọc nếu không đặt
moduleName. -
global: truetiện nhưng che mất dependency giữa các module.
Nhược điểm
Checklist production
Phần tiêu đề “Checklist production”- Gọi
forRoot/forRootAsyncmột lần ởAppModule(hoặc một module hạ tầng) cho mỗi client dùng chung. - Feature module chỉ import module đã cấu hình hoặc dựa vào global, không gọi lại
forRoot. forRootAsynckhai báo đủimportscho mọi provider tronginject, trừ provider global.- Đặt
moduleNamecho builder để thông báo lỗi đọc được; khai báoexportscho đúng provider công khai; không export token options nếu feature module không cần. - Validate options trong factory và fail fast; không log nguyên
apiKey. - Chỉ bật
isGlobalcho module hạ tầng dùng khắp nơi (config, logger), không cho module nghiệp vụ. - Viết test kiểm tra identity:
app.get(OrdersService).payments === app.get(RefundsService).payments.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Lỗi đắt nhất là nhân bản provider mà không báo lỗi:
Gọi forRoot ở từng feature module
Hay gặp
@Module({ imports: [PaymentModule.forRoot(OPTS)] }) class OrdersModule {}@Module({ imports: [PaymentModule.forRoot(OPTS)] }) class RefundsModule {}Nên viết
@Module({ imports: [PaymentModule.forRoot({ ...OPTS, isGlobal: true }), OrdersModule, RefundsModule] })class AppModule {}Vì sao
Nest 11 so dynamic module theo tham chiếu object: hai lần gọi là hai module, hai PaymentClient (demo [5a]). Gọi một lần ở gốc, hoặc dùng chung một hằng số PAYMENT_MODULE = PaymentModule.forRoot(...).
Hai lỗi còn lại thường lộ ngay lúc bootstrap:
| Cách hiểu sai | Hệ quả | Cần làm |
|---|---|---|
| “Cùng options thì Nest gộp làm một” | Hai client, hai pool, state tách rời | Gọi một lần; kiểm tra bằng identity |
“AppModule import ConfigModule là đủ cho factory” |
UnknownDependenciesException ở PaymentModule |
Thêm imports trong forRootAsync |
“global: true cho tiện” |
Module nào cũng dùng được, không thấy ai phụ thuộc ai | Chỉ dùng cho hạ tầng |
Tự kiểm tra
Phần tiêu đề “Tự kiểm tra”forRootAsync()được gọi lúc nào, và lúc đóPaymentClientđã tồn tại chưa?- Vì sao lỗi thiếu
ConfigServicenhắcPaymentModulechứ không phảiAppModule? - Hai feature module cùng gọi
forRoot(OPTS)với cùng objectOPTS. Vì sao vẫn ra hai instance, và ba cách nào đưa về một?
Tóm tắt
Phần tiêu đề “Tóm tắt”Tham khảo
Phần tiêu đề “Tham khảo”- NestJS — Dynamic modules: quy ước tên method,
ConfigurableModuleBuilder,setExtras. - NestJS — Modules: global module, vì sao không nên global hoá mọi thứ.
- NestJS — Configuration:
isGlobalcủa@nestjs/config. - Source
configurable-module.builder.ts: sinh token, loại extras, không tự export. - Source
by-reference-module-opaque-key-factory.ts: định danh module theo tham chiếu.