Skip to content

动态模块

一、为什么需要动态模块

静态模块的局限: 每次导入配置完全相同。

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,异步模块还可以用 useClassuseExisting

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 配置不生效在子模块重复调用 forRootforRoot 只调用一次(通常在 AppModule),子模块用 register
异步配置中 inject 找不到 Token依赖未在当前模块 imports确保 imports 包含提供该 Token 的模块
动态模块 Provider 作用域错误多次 register 导致单例失效isGlobal: true 或确保只注册一次

NestJS 深度学习体系