Skip to main content

动态模块

在《模块》章节中,介绍了 Nest 模块的基础知识,并简要介绍了动态模块。 本章将深入探讨动态模块的主题。 完成后,您应该能够很好地理解它们是什么,以及何时以及如何使用它们。

介绍

文档概述部分的大多数应用代码示例都使用常规或静态模块。 模块定义了组件(如提供者控制器)的分组,它们作为整个应用程序的模块化部分一起配合。 它们为这些组件提供执行上下文或范围。 例如,在模块中定义的提供者可以在模块的其他成员中可见,无需导出它们。 当需要在模块外部可见提供者时,首先要将它从其宿主模块导出,然后导入到其使用模块中。

让我们通过一个熟悉的示例来了解。

首先,我们将定义一个UsersModule来提供和导出UsersServiceUsersModuleUsersService的宿主模块。

import { Module } from '@nestjs/common';
import { UsersService } from './users.service';

@Module({
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}

接下来,我们将定义一个AuthModule, 它导入UsersModule,从而使UsersModule导出的提供者在AuthModule内部可用:

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersModule } from '../users/users.module';

@Module({
imports: [UsersModule],
providers: [AuthService],
exports: [AuthService],
})
export class AuthModule {}

这些构造允许我们在AuthModule中注入UsersService, 例如,AuthService托管在AuthModule中:

import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';

@Injectable()
export class AuthService {
constructor(private usersService: UsersService) {}
/*
利用 this.usersService 的实现
*/
}

我们将这称为静态模块绑定。 Nest 需要将模块连接在一起的所有信息都已在宿主模块和使用模块中声明。 让我们解开此过程中发生的事情。

Nest 通过以下步骤在AuthModule中使UsersService可用

  1. 实例化UsersModule,包括传递导入UsersModule的其他模块以及传递解析任何依赖项(请参阅自定义提供者)。
  2. 实例化AuthModule,并使UsersModule导出的提供者对AuthModule中的组件可用(就像它们已在AuthModule中声明一样)。
  3. AuthService中注入UsersService的实例。

动态模块使用场景

通过静态模块绑定,使用模块没有机会影响宿主模块的提供者如何配置。 为什么这很重要呢? 考虑这样一种情况:我们有一个通用模块,需要在不同的用例中以不同的方式运行。 这类似于许多系统中“插件”的概念,其中通用设施在被使用之前需要一些配置。

在 Nest 中,一个很好的例子是配置模块。 许多应用程序通过使用配置模块来将配置细节外部化,从而在不同的部署中轻松更改应用程序设置: 例如,开发人员使用的开发数据库,用于测试/演练环境的演练数据库等。 通过将配置参数的管理委托给配置模块,应用程序源代码保持独立于配置参数。

挑战在于配置模块本身,因为它是通用的(类似于“插件”),需要由其使用模块定制。 这就是动态模块发挥作用的地方。 使用动态模块功能,我们可以使配置模块变得动态,以便使用模块可以在导入时使用 API 控制如何定制配置模块。

换句话说,动态模块为将一个模块导入到另一个模块提供了一个 API, 并在导入时自定义该模块的属性和行为,而不是使用我们到目前为止所见的静态绑定。

配置模块示例

在本节中,我们将使用配置章节的示例代码的基本版本。 截至本章结束时的完成版本可以在此处找到。

我们的要求是使ConfigModule接受一个options对象来自定义它。 以下是我们希望支持的功能。基本示例将.env 文件的位置硬编码为项目根文件夹。 假设我们希望将其配置为可配置,以便您可以在选择的任何文件夹中管理.env文件。 例如,假设您希望将各种.env文件存储在项目根目录的config文件夹下(即src的同级文件夹)。 您希望在不同项目中使用ConfigModule时能够选择不同的文件夹。

动态模块使我们能够将参数传递到导入的模块中,以便我们可以更改其行为。 让我们看看这是如何工作的。如果我们从使用模块的模块的角度开始,然后逆向工作,会更有帮助。 首先,让我们快速回顾一下静态导入ConfigModule的示例(即无法影响导入的模块行为的方法)。 请特别注意@Module()装饰器中的imports数组:

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

@Module({
imports: [ConfigModule],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

让我们考虑动态导入模块的情况,我们在其中传递了一个配置对象。 比较这两个示例中的imports数组的差异:

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

@Module({
imports: [ConfigModule.register({ folder: './config' })],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

让我们看看上面的动态示例中发生了什么。有哪些组成部分?

  • ConfigModule是一个普通的类,因此我们可以推断它必须有一个名为register()的静态方法。 我们知道它是静态的,因为我们在ConfigModule类上调用它,而不是在类的实例上调用。 注意:这个方法,我们将很快创建它,可以有任意的名称,但按照约定,我们应该将其命名为forRoot()register()
  • register() 方法由我们定义,因此我们可以接受任何我们喜欢的输入参数。 在这种情况下,我们将接受一个带有适当属性的简单选项对象,这是典型的情况。
  • 我们可以推断register()方法必须返回类似于模块的东西,因为它的返回值出现在熟悉的imports列表中, 到目前为止,我们已经看到这个列表包含模块列表。

实际上,我们的register()方法将返回一个DynamicModule。 动态模块实际上只是在运行时创建的模块,其属性与静态模块完全相同,再加上一个额外的属性称为module。 让我们快速回顾一下一个样本静态模块声明,特别注意传递给装饰器的模块选项:

@Module({
imports: [DogsModule],
controllers: [CatsController],
providers: [CatsService],
exports: [CatsService]
})

动态模块必须返回一个具有相同接口的对象,再加上一个额外的属性称为modulemodule属性用作模块的名称,应该与模块的类名相同,如下例所示。

tip

对于动态模块,模块选项对象的所有属性都是可选的,除了 module。

静态register()方法怎么样呢? 我们现在可以看到它的工作是返回一个具有DynamicModule接口的对象。 当我们调用它时,我们实际上是提供了一个模块到imports列表中, 类似于在静态情况下通过列出模块类名来执行的方式。 换句话说,动态模块 API 简单地返回一个模块,但与其通过@Module()装饰器固定属性不同, 我们以编程方式指定它们。

仍然有一些细节需要介绍,以帮助形成完整的图片:

  1. 我们现在可以说@Module()装饰器的imports属性不仅可以接受模块类名(例如imports: [UsersModule]), 还可以接受返回动态模块的函数(例如 imports: [ConfigModule.register(...)])。
  2. 动态模块本身可以导入其他模块。在这个例子中我们不会这样做,但如果动态模块依赖于其他模块的提供者, 您可以使用可选的imports属性导入它们。 同样,这与您将元数据声明为使用@Module()装饰器的静态模块的方式完全类似。

有了这个理解,我们现在可以看看我们的动态ConfigModule声明必须是什么样子的。让我们试试。

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

@Module({})
export class ConfigModule {
static register(): DynamicModule {
return {
module: ConfigModule,
providers: [ConfigService],
exports: [ConfigService],
};
}
}

现在应该清楚这些部分是如何相互关联的。 调用ConfigModule.register(...)返回一个具有与我们迄今为止 通过@Module() 装饰器提供的元数据完全相同的属性的DynamicModule对象。

tip

从 @nestjs/common 导入 DynamicModule。

然而,我们的动态模块还不够有趣,因为我们还没有引入任何配置它的功能,而我们之前说过我们想要这样做。 让我们接下来解决这个问题。

模块配置

定制ConfigModule行为的明显解决方案是在静态register()方法中传递一个选项对象, 就像我们上面猜测的那样。让我们再次看一下我们消费模块的imports属性:

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

@Module({
imports: [ConfigModule.register({ folder: './config' })],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}

这很好地处理了将options对象传递给我们的动态模块。 然后我们如何在ConfigModule中使用该options对象呢?让我们思考一下。 我们知道我们的ConfigModule基本上是一个提供和导出可注入服务ConfigService的主机,供其他提供者使用。 实际上,我们的ConfigService需要读取options对象以自定义其行为。 暂时假设我们知道如何以某种方式将optionsregister()方法传递到ConfigService。有了这个假设, 我们可以对服务进行一些更改,根据options对象中的属性自定义其行为。 (注意:当前我们还没有确定如何传递它,所以我们将选项硬编码。我们一会儿就会解决这个问题。)

import { Injectable } from '@nestjs/common';
import * as dotenv from 'dotenv';
import * as fs from 'fs';
import * as path from 'path';
import { EnvConfig } from './interfaces';

@Injectable()
export class ConfigService {
private readonly envConfig: EnvConfig;

constructor() {
const options = { folder: './config' };

const filePath = `${process.env.NODE_ENV || 'development'}.env`;
const envFile = path.resolve(__dirname, '../../', options.folder, filePath);
this.envConfig = dotenv.parse(fs.readFileSync(envFile));
}

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

现在我们的ConfigService知道如何在我们在选项中指定的文件夹中找到.env文件。

我们的剩余任务是以某种方式将选项对象从register()步骤注入到ConfigService中。 当然,我们将使用依赖注入来完成这个任务。这是一个关键点,所以确保您理解它。 我们的ConfigModule提供ConfigServiceConfigService反过来依赖于仅在运行时提供的选项对象。 因此,在运行时, 我们首先需要将选项对象绑定到Nest IoC 容器然后让Nest将其注入到我们的ConfigService中。 请记住在自定义提供者章节中提到,提供者可以包含任何值,不仅仅是服务, 因此我们可以使用依赖注入来处理一个简单的选项对象。

首先让我们解决将选项对象绑定到 IoC 容器的问题。 我们在静态register()方法中完成这项工作。 记住我们正在动态构建一个模块,模块的属性之一是其提供者列表。 因此,我们需要将我们的选项对象定义为提供者。 这将使其成为可注入到ConfigService中的对象,我们将在下一步中利用这一点。 在下面的代码中,注意providers数组:

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

@Module({})
export class ConfigModule {
static register(options: Record<string, any>): DynamicModule {
return {
module: ConfigModule,
providers: [
{
provide: 'CONFIG_OPTIONS',
useValue: options,
},
ConfigService,
],
exports: [ConfigService],
};
}
}

现在我们可以通过将'CONFIG_OPTIONS'提供者注入到ConfigService中来完成整个过程。 回想一下,当我们使用非类令牌定义提供者时,我们需要使用@Inject()装饰器,如这里所述。

import * as dotenv from 'dotenv';
import * as fs from 'fs';
import * as path from 'path';
import { Injectable, Inject } from '@nestjs/common';
import { EnvConfig } from './interfaces';

@Injectable()
export class ConfigService {
private readonly envConfig: EnvConfig;

constructor(@Inject('CONFIG_OPTIONS') private options: Record<string, any>) {
const filePath = `${process.env.NODE_ENV || 'development'}.env`;
const envFile = path.resolve(__dirname, '../../', options.folder, filePath);
this.envConfig = dotenv.parse(fs.readFileSync(envFile));
}

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

最后需要注意的一点是,为了简化起见,我们在上面的示例中使用了基于字符串的注入令牌 ('CONFIG_OPTIONS'), 但最佳做法是将其定义为一个常量(或符号)在一个单独的文件中,并导入该文件。例如:

export const CONFIG_OPTIONS = 'CONFIG_OPTIONS';

示例

本章中代码的完整示例可以在这里找到。

社区指南

您可能已经看到了一些@nestjs/包中使用forRootregisterforFeature等方法的用法, 并且可能想知道所有这些方法的区别是什么。关于这个没有硬性规定,但@nestjs/包尝试遵循这些指南:

  • 使用register创建模块时,您希望使用特定配置来配置仅由调用模块使用的动态模块。 例如,在 Nest 的@nestjs/axios 中:HttpModule.register({ baseUrl: 'someUrl' })。 如果在另一个模块中使用 HttpModule.register({ baseUrl: 'somewhere else' }), 它将具有不同的配置。您可以为所需数量的模块执行此操作。

  • 使用forRoot创建模块时,您希望配置一个动态模块一次,并在多个位置重用该配置 (尽管可能并不知道,因为它被抽象掉了)。 这就是为什么您有一个 GraphQLModule.forRoot(),一个 TypeOrmModule.forRoot() 等的原因。

  • 使用forFeature创建模块时,您希望使用动态模块的forRoot配置, 但需要修改一些特定于调用模块需求的配置(即这个模块应该访问哪个存储库,或者记录器应该使用的上下文)。

通常,所有这些方法都有它们的async对应物,比如registerAsyncforRootAsyncforFeatureAsync, 它们的含义相同,但也使用 Nest 的依赖注入来进行配置。

可配置模块构建器

手动创建高度可配置的动态模块并公开async方法(如 registerAsyncforRootAsync 等) 相当复杂,尤其对于初学者来说。因此,Nest 提供了 ConfigurableModuleBuilder 类, 简化了这个过程,让您可以在几行代码中构建一个模块的“蓝图”。

例如,让我们以前面使用的示例(ConfigModule)为例,并将其转换为使用 ConfigurableModuleBuilder。 在开始之前,确保我们创建一个专门表示 ConfigModule 接受的选项的接口。

// interfaces/config-module-options.interface.ts
export interface ConfigModuleOptions {
folder: string;
}

有了这个接口,创建一个新的专用文件(与现有的 config.module.ts 文件并列), 并将其命名为 config.module-definition.ts。 在这个文件中,让我们利用 ConfigurableModuleBuilder 来构建 ConfigModule 的定义。

// config.module-definition.ts
import { ConfigurableModuleBuilder } from '@nestjs/common';
import { ConfigModuleOptions } from './interfaces/config-module-options.interface';

export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
new ConfigurableModuleBuilder<ConfigModuleOptions>().build();

现在,打开 config.module.ts 文件,并修改其实现以利用自动生成的 ConfigurableModuleClass

// config.module.ts
import { Module } from '@nestjs/common';
import { ConfigService } from './config.service';
import { ConfigurableModuleClass } from './config.module-definition';

@Module({
providers: [ConfigService],
exports: [ConfigService],
})
export class ConfigModule extends ConfigurableModuleClass {}

扩展 ConfigurableModuleClass 意味着 ConfigModule 现在不仅提供了register方法 (与以前的自定义实现相同),还提供了 registerAsync 方法,允许使用者异步配置该模块, 例如,通过提供异步工厂:

// app.module.ts
@Module({
imports: [
ConfigModule.register({ folder: './config' }),
// 或者使用:
// ConfigModule.registerAsync({
// useFactory: () => {
// return {
// folder: './config',
// }
// },
// inject: [...任何额外的依赖项...]
// }),
],
})
export class AppModule {}

最后,让我们更新 ConfigService 类,以注入生成的模块选项的提供者, 而不是我们迄今为止使用的 'CONFIG_OPTIONS'

// config.service.ts
import { Injectable, Inject } from '@nestjs/common';
import { ConfigModuleOptions } from './interfaces/config-module-options.interface';
import { MODULE_OPTIONS_TOKEN } from './config.module-definition';

@Injectable()
export class ConfigService {
constructor(@Inject(MODULE_OPTIONS_TOKEN) private options: ConfigModuleOptions) { ... }
}

自定义方法键

ConfigurableModuleClass 默认提供 register 及其对应的 registerAsync 方法。 要使用不同的方法名称,请使用 ConfigurableModuleBuilder#setClassMethodName 方法, 如下所示:

// config.module-definition.ts
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
new ConfigurableModuleBuilder<ConfigModuleOptions>().setClassMethodName('forRoot').build();

这个构造将指示 ConfigurableModuleBuilder 生成一个公开 forRootforRootAsync 方法的类。 示例:

// app.module.ts
@Module({
imports: [
ConfigModule.forRoot({ folder: './config' }), // <-- 注意使用 "forRoot" 而不是 "register"
// 或者使用:
// ConfigModule.forRootAsync({
// useFactory: () => {
// return {
// folder: './config',
// }
// },
// inject: [...任何额外的依赖项...]
// }),
],
})
export class AppModule {}

自定义选项工厂类

由于 registerAsync 方法(或 forRootAsync 或其他名称,取决于配置) 允许消费者传递一个解析为模块配置的提供程序定义,因此库的消费者可能会提供一个用于构造配置对象的类。

// app.module.ts
@Module({
imports: [
ConfigModule.registerAsync({
useClass: ConfigModuleOptionsFactory,
}),
],
})
export class AppModule {}

默认情况下,该类必须提供 create() 方法,该方法返回一个模块配置对象。 然而,如果您的库遵循不同的命名约定,您可以更改该行为并指示 ConfigurableModuleBuilder 期望一个不同的方法, 例如 createConfigOptions,使用 ConfigurableModuleBuilder#setFactoryMethodName 方法:

// config.module-definition.ts
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
new ConfigurableModuleBuilder<ConfigModuleOptions>().setFactoryMethodName('createConfigOptions').build();

现在,ConfigModuleOptionsFactory 类必须公开 createConfigOptions 方法(而不是 create):

// config.module-options-factory.ts
export class ConfigModuleOptionsFactory {
createConfigOptions(): ConfigModuleOptions {
// 返回模块配置对象
return {
folder: './config',
};
}
}

然后在应用模块中使用:

// app.module.ts
@Module({
imports: [
ConfigModule.registerAsync({
useClass: ConfigModuleOptionsFactory, // <-- 此类必须提供 "createConfigOptions" 方法
}),
],
})
export class AppModule {}

额外选项

在某些情况下,您的模块可能需要获取额外的选项,以确定其应该如何行为(一个很好的例子是 isGlobal 标志 - 或者只是 global), 同时这些选项不应包含在 MODULE_OPTIONS_TOKEN 提供程序中(因为它们与在该模块中注册的服务/提供程序无关, 例如,ConfigService 不需要知道其宿主模块是否注册为全局模块)。

在这种情况下,可以使用 ConfigurableModuleBuilder#setExtras 方法。 请参阅以下示例:

export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = new ConfigurableModuleBuilder<ConfigModuleOptions>()
.setExtras(
{
isGlobal: true,
},
(definition, extras) => ({
...definition,
global: extras.isGlobal,
}),
)
.build();

上面示例中,传递给 setExtras 方法的 第一个参数是一个包含“额外”属性默认值的对象。 第二个参数是一个函数,它接受一个自动生成的模块定义(具有提供程序、导出等)和一个额外的对象, 该对象表示额外属性(由消费者指定或默认值)。该函数的返回值是修改后的模块定义。 在此特定示例中,我们将 extras.isGlobal 属性取出并将其赋值给模块定义的 global 属性 (从而确定模块是否是全局模块,阅读更多信息请点击此处)。

现在,在使用该模块时,可以传入额外的 isGlobal 标志,如下所示:

@Module({
imports: [
ConfigModule.register({
isGlobal: true,
folder: './config',
}),
],
})
export class AppModule {}

但是,由于 isGlobal 被声明为“额外”属性,它不会出现在 MODULE_OPTIONS_TOKEN 提供程序中:

@Injectable()
export class ConfigService {
constructor(@Inject(MODULE_OPTIONS_TOKEN) private options: ConfigModuleOptions) {
// "options" 对象将不包含 "isGlobal" 属性
// ...
}
}

扩展自动生成的方法

如果需要,可以扩展自动生成的静态方法(例如,registerregisterAsync 等),如下所示:

import { Module } from '@nestjs/common';
import { ConfigService } from './config.service';
import { ConfigurableModuleClass, ASYNC_OPTIONS_TYPE, OPTIONS_TYPE } from './config.module-definition';

@Module({
providers: [ConfigService],
exports: [ConfigService],
})
export class ConfigModule extends ConfigurableModuleClass {
static register(options: typeof OPTIONS_TYPE): DynamicModule {
return {
// 添加您的自定义逻辑
...super.register(options),
};
}

static registerAsync(options: typeof ASYNC_OPTIONS_TYPE): DynamicModule {
return {
// 添加您的自定义逻辑
...super.registerAsync(options),
};
}
}

请注意,OPTIONS_TYPEASYNC_OPTIONS_TYPE 类型的使用,这些类型必须从模块定义文件中导出:

export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE } = new ConfigurableModuleBuilder<ConfigModuleOptions>().build();