动态模块
一、为什么需要动态模块
静态模块的局限: 每次导入配置完全相同。
typescript
// ❌ 静态模块:所有导入方共用同一配置
@Module({
providers: [{ provide: 'DB_URL', useValue: 'postgres://localhost/mydb' }],
})
export class DatabaseModule {}如果不同应用需要不同的数据库 URL、不同的日志级别、不同的第三方 API Key,静态模块就无法满足需求。
动态模块: 调用方通过参数定制模块行为。
typescript
// ✅ 动态模块:调用方传入配置
DatabaseModule.forRoot({ url: process.env.DATABASE_URL })
DatabaseModule.forRoot({ url: 'postgres://test-db/test' }) // 测试环境二、命名约定
NestJS 官方和社区遵循以下约定:
| 方法名 | 用途 | 是否全局 | 返回类型 |
|---|---|---|---|
forRoot(options) | 应用级全局配置(调用一次) | 通常是 | DynamicModule |
forRootAsync(options) | 异步版 forRoot(需要从 ConfigService 读取) | 通常是 | DynamicModule |
forFeature(entities) | 特性模块级配置(可多次调用) | 否 | DynamicModule |
register(options) | 简单注册(无全局/特性之分) | 否 | DynamicModule |
registerAsync(options) | 异步注册 | 否 | DynamicModule |
三、forRoot / forFeature 模式
这是 NestJS 官方模块(TypeORM、Mongoose、Passport 等)普遍采用的模式:
typescript
@Module({})
export class DatabaseModule {
/**
* forRoot:应用启动时调用一次,建立全局数据库连接
*/
static forRoot(options: DatabaseOptions): DynamicModule {
return {
module: DatabaseModule,
global: true, // 注册为全局模块,子模块无需 imports
providers: [
{
provide: 'DATABASE_OPTIONS',
useValue: options,
},
{
provide: DataSource,
useFactory: async (opts: DatabaseOptions) => {
const ds = new DataSource(opts);
await ds.initialize();
return ds;
},
inject: ['DATABASE_OPTIONS'],
},
],
exports: [DataSource],
};
}
/**
* forFeature:各特性模块按需注册自己的 Repository
*/
static forFeature(entities: EntityClassOrSchema[]): DynamicModule {
const providers = entities.map(entity => ({
provide: getRepositoryToken(entity),
useFactory: (ds: DataSource) => ds.getRepository(entity),
inject: [DataSource],
}));
return {
module: DatabaseModule,
providers,
exports: providers, // 将 providers 数组直接 exports(含工厂 Provider 对象)
};
}
}使用方:
typescript
// app.module.ts:全局配置一次
@Module({
imports: [
DatabaseModule.forRoot({
type: 'postgres',
url: process.env.DATABASE_URL,
entities: [User, Post],
}),
],
})
export class AppModule {}
// users.module.ts:只注册自己需要的 Repository
@Module({
imports: [DatabaseModule.forFeature([User])],
providers: [UserService],
})
export class UsersModule {}
// UserService 中注入
@Injectable()
export class UserService {
constructor(
@InjectRepository(User)
private userRepo: Repository<User>,
) {}
}四、异步动态模块(forRootAsync)
当配置需要从 ConfigService 或环境变量(在 ConfigModule 初始化之后才可用)动态读取时:
typescript
@Module({})
export class DatabaseModule {
static forRootAsync(asyncOptions: DatabaseAsyncOptions): DynamicModule {
return {
module: DatabaseModule,
global: true,
imports: asyncOptions.imports || [], // 允许注入 ConfigModule 等依赖
providers: [
{
provide: 'DATABASE_OPTIONS',
useFactory: asyncOptions.useFactory,
inject: asyncOptions.inject || [],
},
{
provide: DataSource,
useFactory: async (opts: DatabaseOptions) => {
const ds = new DataSource(opts);
await ds.initialize();
return ds;
},
inject: ['DATABASE_OPTIONS'],
},
],
exports: [DataSource],
};
}
}
// 类型定义
interface DatabaseAsyncOptions {
imports?: any[];
useFactory: (...args: any[]) => DatabaseOptions | Promise<DatabaseOptions>;
inject?: any[];
}使用:
typescript
@Module({
imports: [
DatabaseModule.forRootAsync({
imports: [ConfigModule], // 注入 ConfigModule
useFactory: (config: ConfigService) => ({
type: 'postgres',
url: config.get<string>('DATABASE_URL'),
ssl: config.get('NODE_ENV') === 'production',
}),
inject: [ConfigService], // 注入 ConfigService
}),
],
})
export class AppModule {}五、useClass 和 useExisting 异步选项
除了 useFactory,异步模块还可以用 useClass 或 useExisting:
typescript
// useClass:通过类工厂提供配置
@Injectable()
class DatabaseConfigFactory implements DatabaseOptionsFactory {
createOptions(): DatabaseOptions {
return { url: process.env.DATABASE_URL };
}
}
DatabaseModule.forRootAsync({
useClass: DatabaseConfigFactory,
})
// useExisting:复用现有 Provider(无需重新创建实例)
DatabaseModule.forRootAsync({
imports: [ConfigModule],
useExisting: ConfigService, // ConfigService 实现了 DatabaseOptionsFactory 接口
})六、完整示例:自定义 HTTP 客户端模块
实现一个可复用的带基础 URL 配置的 HTTP 客户端模块:
typescript
// http-client.module.ts
export interface HttpClientOptions {
baseURL: string;
timeout?: number;
headers?: Record<string, string>;
}
export const HTTP_CLIENT_OPTIONS = 'HTTP_CLIENT_OPTIONS';
@Module({})
export class HttpClientModule {
static register(options: HttpClientOptions): DynamicModule {
return {
module: HttpClientModule,
providers: [
{
provide: HTTP_CLIENT_OPTIONS,
useValue: options,
},
HttpClientService,
],
exports: [HttpClientService],
};
}
static registerAsync(asyncOptions: {
imports?: any[];
useFactory: (...args: any[]) => HttpClientOptions | Promise<HttpClientOptions>;
inject?: any[];
}): DynamicModule {
return {
module: HttpClientModule,
imports: asyncOptions.imports || [],
providers: [
{
provide: HTTP_CLIENT_OPTIONS,
useFactory: asyncOptions.useFactory,
inject: asyncOptions.inject || [],
},
HttpClientService,
],
exports: [HttpClientService],
};
}
}
// http-client.service.ts
@Injectable()
export class HttpClientService {
constructor(
@Inject(HTTP_CLIENT_OPTIONS)
private options: HttpClientOptions,
) {}
async get<T>(path: string): Promise<T> {
const url = `${this.options.baseURL}${path}`;
const response = await fetch(url, {
headers: this.options.headers,
signal: AbortSignal.timeout(this.options.timeout ?? 5000),
});
return response.json();
}
}
// 使用:不同模块连接不同 API
@Module({
imports: [
HttpClientModule.register({
baseURL: 'https://api.github.com',
headers: { Authorization: `token ${process.env.GITHUB_TOKEN}` },
}),
],
})
export class GithubModule {}
@Module({
imports: [
HttpClientModule.registerAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
baseURL: config.get('STRIPE_API_URL'),
headers: { Authorization: `Bearer ${config.get('STRIPE_SECRET_KEY')}` },
}),
inject: [ConfigService],
}),
],
})
export class PaymentModule {}七、DynamicModule 的完整类型
typescript
interface DynamicModule extends ModuleMetadata {
module: Type<any>; // 模块类本身(必填)
global?: boolean; // 是否注册为全局模块
// 继承自 ModuleMetadata:
imports?: Array<...>;
controllers?: Array<...>;
providers?: Array<...>;
exports?: Array<...>;
}注意: DynamicModule 继承了静态模块的所有字段,因此动态模块可以同时包含静态 Provider 和动态 Provider。
八、常见陷阱
陷阱 1:forRoot 调用多次
typescript
// ❌ 同一个动态模块被多次 forRoot 导入
@Module({
imports: [
DatabaseModule.forRoot({ url: 'postgres://...' }),
// 另一个子模块的 forRoot 调用
DatabaseModule.forRoot({ url: 'postgres://...' }), // 冲突!
],
})解决: forRoot 设置 global: true,只在 AppModule 调用一次;子模块使用 forFeature。
陷阱 2:exports 工厂 Provider 时的写法
typescript
// ❌ 只 export Token 字符串,不工作
exports: ['DATABASE_OPTIONS']
// ✅ export 整个 Provider 对象,或 export 工厂的 provide Token
const optionsProvider = {
provide: 'DATABASE_OPTIONS',
useValue: options,
};
return {
providers: [optionsProvider],
exports: [optionsProvider], // ✅ 整个对象
};陷阱 3:忘记 imports asyncOptions.imports
typescript
// ❌ 工厂函数依赖 ConfigService,但没有 imports ConfigModule
static forRootAsync(opts) {
return {
providers: [{
provide: 'OPTS',
useFactory: (config: ConfigService) => ..., // 找不到 ConfigService!
inject: [ConfigService],
}],
};
}
// ✅ 必须在模块级别 imports ConfigModule
static forRootAsync(opts) {
return {
imports: opts.imports || [], // 调用方传入 [ConfigModule]
providers: [{ provide: 'OPTS', useFactory: opts.useFactory, inject: opts.inject }],
};
}可运行 Demo:
practice/07-extensions— CacheModule.register / BullModule.forRootAsync 动态模块示例
常见错误
| 错误 | 原因 | 解决 |
|---|---|---|
forRoot 配置不生效 | 在子模块重复调用 forRoot | forRoot 只调用一次(通常在 AppModule),子模块用 register |
异步配置中 inject 找不到 Token | 依赖未在当前模块 imports | 确保 imports 包含提供该 Token 的模块 |
| 动态模块 Provider 作用域错误 | 多次 register 导致单例失效 | 用 isGlobal: true 或确保只注册一次 |