Skip to content
一、动态模块:设计动机与使用场景
@Module() 装饰器接受的是静态配置。模块的 imports、providers、exports 在编码阶段就已确定,无法根据外部条件改变。对于依赖关系固定、结构清晰的场景,静态配置足够直接。
当模块需要由调用方传入参数来决定自身行为时,静态配置就会碰到边界。例如,配置模块希望在不同运行环境加载不同的配置文件,或者数据库模块需要外部指定连接字符串——此时需要动态模块。
动态模块与静态模块的对比
静态模块的导入方式如下:
typescript
@Module({
imports: [UsersModule],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}UsersModule 是一个类,其内部结构已经完全确定,AppModule 无法向其传递任何配置。
动态模块的约定是在模块类上定义静态方法,该方法返回一个 DynamicModule 对象。DynamicModule 的结构与 @Module() 接收的参数对象一致。这样外部代码就可以调用静态方法并传入选项,模块在运行时构建自身配置。
对比:
typescript
// 静态模块
imports: [UsersModule]
// 动态模块
imports: [ConfigModule.forRoot({ path: '.env' })]ConfigModule.forRoot() 返回的是 DynamicModule,不是模块类本身。动态模块不能在 imports 中直接写类名,必须调用对应的静态方法。
使用时机:按需配置、全局注册与多实例
动态模块主要覆盖以下几种需求。
按需配置。同一套模块代码,在不同部署环境传入不同参数。例如开发环境连接本地数据库,测试环境连接内存数据库,导入时只需传入不同的选项:
typescript
@Module({
imports: [
DatabaseModule.forRoot({
host: process.env.DB_HOST,
port: parseInt(process.env.DB_PORT, 10),
}),
],
})
export class AppModule {}全局注册。配置型模块(日志、缓存等)在整个应用中通常只需要一份实例,且每个子模块都应该能访问到它暴露的提供者。动态模块可以在返回的 DynamicModule 中设置 global: true,或者在模块类上使用 @Global() 装饰器。被标记为全局的模块,只需在根模块注册一次,其 exports 中的提供者即可在整个应用中直接注入,无需再次导入。
typescript
@Global()
@Module({})
export class ConfigModule {
static forRoot(options: ConfigOptions): DynamicModule {
return {
module: ConfigModule,
providers: [{ provide: CONFIG_OPTIONS, useValue: options }],
exports: [CONFIG_OPTIONS],
};
}
}多实例。当同一个模块需要在不同上下文中使用不同配置时,可以多次导入。例如数据库模块同时连接主库和从库:
typescript
@Module({
imports: [
DatabaseModule.forRoot({ name: 'master', host: '...' }),
DatabaseModule.forRoot({ name: 'slave', host: '...' }),
],
})
export class AppModule {}每次调用 forRoot 都会生成一份独立的动态模块配置,内部的提供者会带上各自的令牌进行隔离。
二、亲手实现 forRoot:让模块接收外部配置
静态方法命名没有强制要求,但官方和社区广泛采用 forRoot、forFeature 这类习惯。forRoot 通常用于注册模块的核心配置与单例服务,forFeature 则用来在导入模块的同时附加特性配置,如实体列表。
静态 forRoot 方法的约定与返回值
forRoot 方法接收一个配置对象,返回 DynamicModule。DynamicModule 除了必须提供 module 属性指向模块类本身,其他字段(imports、controllers、providers、exports)与 @Module() 装饰器接收的参数完全一致。
以配置模块为例:
typescript
export interface ConfigOptions {
path: string;
}
export const CONFIG_OPTIONS = 'CONFIG_OPTIONS';
@Module({})
export class ConfigModule {
static forRoot(options: ConfigOptions): DynamicModule {
return {
module: ConfigModule,
providers: [
{
provide: CONFIG_OPTIONS,
useValue: options,
},
],
exports: [CONFIG_OPTIONS],
};
}
}调用方导入:
typescript
imports: [ConfigModule.forRoot({ path: '.env' })]如此一来,其他模块通过注入 CONFIG_OPTIONS 即可拿到配置对象。
注册全局配置与 forFeature 特性配置
要让配置在全局生效,加上 global: true:
typescript
static forRoot(options: ConfigOptions): DynamicModule {
return {
module: ConfigModule,
global: true,
providers: [{ provide: CONFIG_OPTIONS, useValue: options }],
exports: [CONFIG_OPTIONS],
};
}forFeature 模式在 ORM 模块中很常见,例如 TypeORM 的 TypeOrmModule.forFeature([User, Post])。它的目标是在当前模块中使用指定的仓库或实体,而不必每次都配置数据库连接。连接信息由 forRoot 统一管理,forFeature 只关心要用哪些实体。
简化的 forFeature 示例:
typescript
@Module({})
export class DatabaseModule {
static forFeature(entities: any[]): DynamicModule {
const repositories = entities.map((entity) => ({
provide: `${entity.name}Repository`,
useValue: {
/* 数据访问方法 */
},
}));
return {
module: DatabaseModule,
providers: repositories,
exports: repositories,
};
}
}使用者只需关心自己模块内用到的实体集合,连接细节由 forRoot 的配置负责。
三、异步配置:forRootAsync 与 ConfigurableModuleBuilder
forRoot 适用于配置已经同步可用的情况。当配置需要从外部服务(配置中心、数据库、异步文件读取)获取时,配置本身是异步的,这时就需要 forRootAsync 方法。
forRootAsync 的实现
forRootAsync 通常接收一个包含 useFactory、inject 等字段的对象,用法与 Nest 的异步提供者(@Injectable({ useFactory, inject }))类似。
typescript
interface ConfigAsyncOptions {
imports?: any[];
useFactory: (...args: any[]) => Promise<ConfigOptions> | ConfigOptions;
inject?: any[];
}
@Module({})
export class ConfigModule {
static forRootAsync(options: ConfigAsyncOptions): DynamicModule {
return {
module: ConfigModule,
global: true,
imports: options.imports || [],
providers: [
{
provide: CONFIG_OPTIONS,
useFactory: options.useFactory,
inject: options.inject || [],
},
],
exports: [CONFIG_OPTIONS],
};
}
}调用方:
typescript
ConfigModule.forRootAsync({
useFactory: async (http: HttpService) => {
const data = await http.get('/config').toPromise();
return { path: data.envFile };
},
inject: [HttpService],
})模块在被 Nest 初始化时,会先生成 HttpService 实例,再执行工厂函数,得到的配置最终注入到 CONFIG_OPTIONS 中。
使用 ConfigurableModuleBuilder 生成动态模块样板代码
手动为每个模块编写 forRoot / forRootAsync 会产生大量重复代码。Nest 提供了 ConfigurableModuleBuilder 来生成标准化的可配置模块基类。
typescript
import { ConfigurableModuleBuilder } from '@nestjs/common';
export interface ConfigOptions {
path: string;
}
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
new ConfigurableModuleBuilder<ConfigOptions>().build();ConfigurableModuleClass 是一个已包含全局可用的 forRoot 和 forRootAsync 方法的类(默认方法名就是 forRoot 和 forRootAsync)。模块只需继承它:
typescript
@Module({})
export class ConfigModule extends ConfigurableModuleClass {}生成的模块即拥有标准的动态模块能力,无需再编写重复代码。此外,还可以通过链式方法调整生成行为:
setClassMethodName('forConfig')将静态方法名改为forConfig。setExtras可以在工厂函数中接收额外的参数。
如果模块还需要 forFeature 之类的自定义方法,可以继续在子类上添加。
四、生命周期钩子:初始化与清理
Nest 为提供者和控制器提供了一套生命周期接口。每个接口对应一个方法,在特定的应用阶段被自动调用。
onModuleInit、onApplicationBootstrap、onModuleDestroy 等钩子的作用
主要的钩子如下:
| 钩子接口 | 触发时机 |
|---|---|
| OnModuleInit | 模块初始化完成时 |
| OnApplicationBootstrap | 全部模块初始化后,应用启动 |
| OnModuleDestroy | 模块即将销毁时 |
| BeforeApplicationShutdown | 应用关闭前 |
| OnApplicationShutdown | 应用关闭后 |
只要让类实现对应的接口并定义方法,Nest 就会自动调用。方法可以是同步的,也可以是异步的(返回 Promise 或使用 async/await)。
typescript
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
@Injectable()
export class DatabaseService implements OnModuleInit, OnModuleDestroy {
onModuleInit() {
console.log('数据库已连接');
}
onModuleDestroy() {
console.log('数据库连接已断开');
}
}钩子的执行顺序与调用时机
应用启动时,Nest 先完成所有模块的依赖解析和实例化,然后按依赖顺序执行 onModuleInit,最后执行 onApplicationBootstrap。被依赖的模块总是先执行 onModuleInit。同一模块内部,提供者按照注册的先后顺序执行。
关闭时顺序相反:先执行 onModuleDestroy,接着是 beforeApplicationShutdown,最后是 onApplicationShutdown。
注意:只有调用 app.enableShutdownHooks() 之后,进程在收到 SIGTERM、SIGINT 等信号时才会触发关闭钩子。
典型应用:数据库连接初始化与定时任务清理
在 onModuleInit 中建立数据库连接是最常见的场景:
typescript
@Injectable()
export class DatabaseService implements OnModuleInit, OnModuleDestroy {
private connection: Connection;
async onModuleInit() {
this.connection = await createConnection({
host: this.config.host,
port: this.config.port,
});
}
async onModuleDestroy() {
if (this.connection) {
await this.connection.close();
}
}
}定时任务依赖同样的两个钩子:启动时注册定时器,销毁时清除定时器,避免进程结束后残留回调:
typescript
@Injectable()
export class SchedulerService implements OnModuleInit, OnModuleDestroy {
private timer: NodeJS.Timer;
onModuleInit() {
this.timer = setInterval(() => {
// 执行业务逻辑
}, 60_000);
}
onModuleDestroy() {
clearInterval(this.timer);
}
}框架保证了钩子的调用顺序,开发者只需将初始化和清理的逻辑填入对应方法。
五、搭建测试环境与编写单元测试
Nest 的测试工具由 @nestjs/testing 包提供,核心是 Test 类。它可以创建轻量、只包含被测模块及其直接依赖的测试容器,适合编写单元测试。
使用 Test.createTestingModule 构建隔离的测试容器
Test.createTestingModule 返回一个 TestingModuleBuilder 实例,其 API 设计贴近 @Module(),可以添加 imports、providers 等。调用 .compile() 得到 TestingModule,它实现了 INestApplicationContext 接口,可通过 .get() 获取提供者实例。
typescript
const moduleRef = await Test.createTestingModule({
providers: [AppService, LoggerService],
}).compile();
const appService = moduleRef.get(AppService);覆盖提供者与注入 mock 服务
依赖项往往包含外部资源,测试时需要替换为 mock。TestingModuleBuilder 提供了 overrideProvider 方法。
typescript
const mockLoggerService = {
log: jest.fn(),
error: jest.fn(),
};
const moduleRef = await Test.createTestingModule({
providers: [AppService],
})
.overrideProvider(LoggerService)
.useValue(mockLoggerService)
.compile();LoggerService 被替换后,AppService 注入的就会是 mock 对象。类似的可覆盖项还包括 overrideGuard、overrideInterceptor 等。
编写单元测试并断言函数调用
下面是一个依赖 LoggerService 记录日志的 AppService 测试:
typescript
describe('AppService', () => {
let appService: AppService;
let loggerService: { log: jest.Mock; error: jest.Mock };
beforeEach(async () => {
loggerService = { log: jest.fn(), error: jest.fn() };
const moduleRef = await Test.createTestingModule({
providers: [AppService],
})
.overrideProvider(LoggerService)
.useValue(loggerService)
.compile();
appService = moduleRef.get(AppService);
});
it('should call logger.log on getHello', () => {
const result = appService.getHello();
expect(loggerService.log).toHaveBeenCalledWith('getHello called');
expect(result).toBe('Hello World!');
});
});测试完全隔离,不依赖真实的外部服务。mock 函数由 jest.fn() 构造,通过断言其被调用可以验证业务逻辑中的交互是否正确。
六、从请求到响应的 e2e 测试
端到端测试关注整个 HTTP 请求处理链路。Nest 官方推荐 supertest 配合 @nestjs/testing。
搭建 e2e 测试环境
第一步创建测试模块,需要导入完整的应用模块(通常是 AppModule)。然后调用 moduleRef.createNestApplication() 创建真实应用实例并初始化。
typescript
import * as request from 'supertest';
import { Test } from '@nestjs/testing';
import { AppModule } from '../src/app.module';
describe('App (e2e)', () => {
let app;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
});
afterAll(async () => {
await app.close();
});
});使用 supertest 发起真实 HTTP 请求验证全链路
request(app.getHttpServer()) 返回一个 supertest 请求对象,可以链式调用 HTTP 方法和断言。
typescript
it('/GET /', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
});全链路包括中间件、守卫、管道、拦截器、控制器、提供者,每一环都在运行的实例中真实执行。
在 e2e 测试中覆盖动态模块与生命周期钩子
e2e 测试中,app.init() 会触发完整的生命周期。如果使用了动态模块(如数据库连接模块),其配置会被真实加载,onModuleInit 和 onModuleDestroy 也会依次调用。
要验证这些行为,可以通过检测外部副作用。例如让 DatabaseService 在连接时设置一个可观测的标志:
typescript
it('should connect database on init', async () => {
const dbService = app.get(DatabaseService);
expect(dbService.isConnected).toBe(true);
});在 afterAll 中关闭应用后,可断言连接已断开:
typescript
afterAll(async () => {
await app.close();
const dbService = app.get(DatabaseService);
expect(dbService.isConnected).toBe(false);
});七、本章小结与下篇衔接
本章从动态模块的设计动机出发,介绍了 forRoot / forFeature 的约定与实现,以及用 ConfigurableModuleBuilder 快速生成样板代码。随后分析了生命周期钩子的执行顺序,展示了在初始化和清理阶段管理资源的典型做法。测试部分利用 Test 类构建隔离测试容器,结合 supertest 完成了单元测试与 e2e 测试。
掌握了这些机制后,模块的可配置性、资源管理及测试保障就具备了完整的工具链。下一篇将利用这些能力,实际构建一个带数据校验和鉴权的 REST API。
参考链接
- [1] https://docs.nestjs.com/fundamentals/dynamic-modules
- [3] https://docs.nestjs.com/fundamentals/dynamic-modules#configurable-module-builder
- [4] https://docs.nestjs.com/fundamentals/lifecycle-events
- [7] https://docs.nestjs.com/fundamentals/testing
- [9] https://docs.nestjs.com/fundamentals/e2e-testing
- [11] https://github.com/nestjs/nest/blob/master/packages/common/interfaces/hooks/on-module-init.interface.ts
- [12] https://github.com/nestjs/nest/tree/master/packages/testing
