Skip to content

动态模块 (Dynamic Modules)

打造你自己的“万能插座”:NestJS 动态模块深度解析

到目前为止,我们创建的模块都是“静态的”。它们的配置在编码时就已经写死了。

typescript
// 一个典型的静态模块
@Module({
  imports: [SomeOtherModule],
  providers: [MyService],
  exports: [MyService],
})
export class MyModule {}

这种模块就像一个普通的电器,它的插头、电压、功能都是固定的。但如果我们想创建一个“万能插座”呢?一个可以根据插入的电器(配置)不同,而提供不同功能(服务)的插座。

动态模块就是 NestJS 里的“万能插座”。它允许你在导入 (import) 一个模块的时候,动态地向它传递配置,从而改变这个模块的行为,比如它提供的服务 (Provider) 或导入的其他模块。

1. 为什么需要动态模块?—— 可配置性和可重用性的终极追求

想象一下,你要开发一个用于读取配置文件的 ConfigModule

一个糟糕的(静态的)设计可能是这样的:

src/config/config.service.ts

typescript
import { Injectable } from '@nestjs/common';
import * as dotenv from 'dotenv';
import * as fs from 'fs';

@Injectable()
export class ConfigService {
  private readonly envConfig: Record<string, string>;

  constructor() {
    // 问题:配置文件路径被硬编码了!
    const filePath = 'development.env';
    this.envConfig = dotenv.parse(fs.readFileSync(filePath));
  }

  get(key: string): string {
    return this.envConfig[key];
  }
}

这个 ConfigService 能用,但它有一个致命缺陷:它永远只能读取 development.env 这个文件。如果我想在生产环境读取 production.env 怎么办?如果另一个项目想用这个模块,但它的配置文件叫 .my-app.env 呢?

我们真正想要的,是能够像这样使用它:

typescript
// 在 AppModule 中
@Module({
  imports: [
    // 我希望在导入时,能告诉 ConfigModule 去哪个路径找文件!
    ConfigModule.register({ path: '.env' }),
  ],
  // ...
})
export class AppModule {}

这就是动态模块的核心目标:创建可配置、可重用的模块

2. 创建你的第一个动态模块:可配置的 ConfigModule

一个动态模块本质上是一个普通的类,但它会提供一个静态方法(按照约定,通常命名为 register()forRoot())。这个静态方法会返回一个 DynamicModule 类型的对象。

DynamicModule 接口@Module() 装饰器里的对象长得几乎一模一样,它可以包含 module, providers, imports, exports 等属性。

让我们来动手改造 ConfigModule

第一步:定义模块和服务的骨架

src/config/config.module.ts

typescript
import { Module, DynamicModule } from '@nestjs/common';
import { ConfigService } from './config.service';

@Module({}) // 模块本身可以留空
export class ConfigModule {
  // 我们将在这里添加静态方法
}

src/config/config.service.ts

typescript
import { Injectable, Inject } from '@nestjs/common';
import * as dotenv from 'dotenv';
import * as fs from 'fs';
import { CONFIG_OPTIONS } from './config.constants'; // 稍后创建

export interface ConfigOptions {
  path: string;
}

@Injectable()
export class ConfigService {
  private readonly envConfig: Record<string, string>;

  // 我们不再在构造函数里直接读取文件,而是注入配置选项
  constructor(@Inject(CONFIG_OPTIONS) options: ConfigOptions) {
    const filePath = options.path;
    this.envConfig = dotenv.parse(fs.readFileSync(filePath));
  }

  get(key: string): string {
    return this.envConfig[key];
  }
}

第二步:创建静态 register() 方法

这是最关键的一步。register 方法接收一个 options 对象,并利用这些 options 动态地构建出提供者 (Provider)。

前置知识:自定义提供者 useValueprovide 令牌

为了让 ConfigService 能够注入我们传入的 options 对象,我们需要将这个 options 对象本身变成一个提供者。这里就要用到我们之前学过的 useValue。我们会创建一个唯一的令牌 (Token)(通常是一个字符串常量),用它作为 provide 的键,用传入的 options 作为 useValue 的值。

src/config/config.constants.ts

typescript
export const CONFIG_OPTIONS = 'CONFIG_OPTIONS';

现在,来完成 register 方法。

src/config/config.module.ts (完整版)

typescript
import { Module, DynamicModule, Global } from '@nestjs/common';
import { ConfigService } from './config.service';
import { CONFIG_OPTIONS } from './config.constants';
import { ConfigOptions } from './config.service';

@Global() // 使用 @Global() 使其成为全局模块,无需在每个模块中都导入
@Module({})
export class ConfigModule {
  static register(options: ConfigOptions): DynamicModule {
    return {
      module: ConfigModule,
      providers: [
        {
          // 动态创建这个提供者
          provide: CONFIG_OPTIONS, // 使用字符串令牌
          useValue: options, // 提供传入的配置
        },
        ConfigService, // 同时注册 ConfigService
      ],
      exports: [ConfigService], // 导出 ConfigService,让其他模块可以使用
    };
  }
}

代码分析

  1. static register(options: ConfigOptions): 定义一个静态方法,接收外部传入的配置。
  2. return { ... }: 返回一个 DynamicModule 对象。
  3. module: ConfigModule: 指明这个动态模块是属于 ConfigModule 的。
  4. providers: [...]: 这是核心。我们在这里动态地构建了提供者数组。
    • 第一个提供者 { provide: CONFIG_OPTIONS, useValue: options } 把我们的配置对象注册到了 DI 容器中。
    • 第二个提供者 ConfigService 注册了服务本身。当 NestJS 实例化 ConfigService 时,会发现它需要注入 CONFIG_OPTIONS,于是 DI 容器就把我们刚刚提供的 options 对象传给了它。
  5. exports: [ConfigService]: 导出服务,这样在 AppModule 中导入 ConfigModule 后,AppModule 内部的其他服务才能注入 ConfigService
  6. @Global(): 这是一个非常有用的装饰器。像 ConfigModule 这种需要在很多地方使用的模块,标记为全局后,只需要在根模块 (AppModule) 中导入一次,应用内的任何其他模块就都可以直接注入 ConfigService,无需在自己的 imports 数组中再次声明 ConfigModule

第三步:在根模块中使用它

现在,我们可以非常优雅地在 AppModule 中使用我们可配置的 ConfigModule 了。

src/app.module.ts

typescript
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule } from './config/config.module';

@Module({
  imports: [
    // 动态调用 register 方法,并传入配置!
    ConfigModule.register({ path: '.env' }),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

大功告成!现在我们的 ConfigModule 变成了一个可重用、可配置的“万能插座”。

3. 更进一步:异步配置 (registerAsync)

如果获取配置的过程本身是异步的怎么办?比如,配置信息需要从另一个服务或数据库中获取。动态模块同样支持异步配置。

我们通常会提供一个 registerAsync 方法,它会使用 useFactory

src/config/config.module.ts (添加 registerAsync)

typescript
// ... 其他 import
import { AsyncConfigOptions } from './config.interfaces'; // 假设我们定义了一个异步配置接口

export class ConfigModule {
  static register(options: ConfigOptions): DynamicModule {
    /* ... 同上 ... */
  }

  static registerAsync(options: AsyncConfigOptions): DynamicModule {
    return {
      module: ConfigModule,
      imports: options.imports || [], // 导入工厂函数可能依赖的模块
      providers: [
        {
          provide: CONFIG_OPTIONS,
          // 使用异步工厂函数
          useFactory: options.useFactory,
          // 注入工厂函数所依赖的提供者
          inject: options.inject || [],
        },
        ConfigService,
      ],
      exports: [ConfigService],
    };
  }
}
  • imports: 如果你的 useFactory 依赖于其他模块的服务(比如 DbService),你需要在这里导入对应的模块 (DbModule)。
  • useFactory: 一个 async 函数,它的返回值是我们的配置对象。
  • inject: useFactory 函数的依赖项数组。

使用起来是这样的:

typescript
// app.module.ts
import { OtherService } from './other.service';

@Module({
  imports: [
    ConfigModule.registerAsync({
      imports: [OtherModule], // 假设工厂函数依赖 OtherModule 的服务
      useFactory: async (otherService: OtherService) => {
        // 可以在这里执行异步操作
        const path = await otherService.getConfigPath();
        return {
          path: path,
        };
      },
      inject: [OtherService],
    }),
  ],
  // ...
})
export class AppModule {}

总结

动态模块是 NestJS 框架的精髓所在,也是所有高质量 NestJS 库(如 @nestjs/typeorm, @nestjs/config)的基石。

  • 核心目的:创建可配置、可重用的模块。
  • 实现方式:通过一个静态方法(如 register, forRoot)返回一个 DynamicModule 对象。
  • 关键技术:在静态方法内部,利用自定义提供者(特别是 useValueuseFactory)来动态地构建服务和它们的依赖。
  • 常见约定
    • forRoot(): 用于在根模块配置一次,提供全局范围的服务(如数据库连接)。
    • forFeature(): 用于在特性模块中配置,通常会复用 forRoot 的配置,但提供针对该特性的服务(如某个数据表的 Repository)。

初次接触可能会觉得概念层层嵌套,但只要你亲手实现一个可配置的 ConfigModule,就能立刻体会到它的强大之处。它能让你的代码库变得更加整洁、灵活和专业。