动态模块
模块章节涵盖了 Nest 模块的基础知识,并包括对动态模块的简要介绍。本章扩展了动态模块的主题。完成后,您应该很好地掌握它们是什么以及如何以及何时使用它们。
介绍
文档概述部分中的大多数应用程序代码示例都使用常规或静态模块。模块定义组件组,如提供者和控制器,它们作为整个应用程序的模块化部分组合在一起。它们为这些组件提供执行上下文或作用域。例如,模块中定义的提供者对模块的其他成员可见,而无需导出它们。当提供者需要在模块外部可见时,它首先从其宿主模块导出,然后导入到其消费模块中。
让我们通过一个熟悉的例子来演示。
首先,我们将定义一个 UsersModule 来提供和导出 UsersService。UsersModule 是 UsersService 的宿主模块。
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 中的 AuthService 中注入 UsersService:
import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';
@Injectable()
export class AuthService {
constructor(private usersService: UsersService) {}
/*
使用 this.usersService 的实现
*/
}
我们将此称为静态模块绑定。Nest 连接模块所需的所有信息已经在宿主和消费模块中声明。让我们解开这个过程中发生的事情。Nest 通过以下方式使 UsersService 在 AuthModule 内可用:
- 实例化
UsersModule,包括传递性地导入UsersModule本身消费的其他模块,并传递性地解析任何依赖项(参见自定义提供者)。 - 实例化
AuthModule,并使UsersModule的导出提供者对AuthModule中的组件可用(就像它们在AuthModule中声明一样)。 - 在
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()方法由我们定义,所以我们可以接受任何我们喜欢的输入参数。在这种情况下,我们将接受一个具有合适属性的简单options对象,这是典型情况。- 我们可以推断
register()方法必须返回类似module的东西,因为它的返回值出现在熟悉的imports列表中,我们迄今为止看到的包括模块列表。
实际上,我们的 register() 方法将返回一个 DynamicModule。动态模块只不过是在运行时创建的模块,具有与静态模块完全相同的属性,加上一个名为 module 的附加属性。让我们快速回顾一个示例静态模块声明,密切关注传递给装饰器的模块选项:
@Module({
imports: [DogsModule],
controllers: [CatsController],
providers: [CatsService],
exports: [CatsService]
})
动态模块必须返回具有完全相同接口的对象,加上一个名为 module 的附加属性。module 属性用作模块的名称,应该与模块的类名相同,如下面的示例所示。
提示 对于动态模块,模块选项对象的所有属性都是可选的,除了 module。
静态 register() 方法呢?我们现在可以看到它的工作是返回具有 DynamicModule 接口的对象。当我们调用它时,我们实际上是向 imports 列表提供一个模块,类似于我们在静态情况下通过列出模块类名的方式。换句话说,动态模块 API 只是返回一个模块,但不是在 @Module 装饰器中固定属性,我们以编程方式指定它们。
还有几个细节需要涵盖以帮助完成图片:
- 我们现在可以声明
@Module()装饰器的imports属性不仅可以接受模块类名(例如,imports: [UsersModule]),还可以接受返回动态模块的函数(例如,imports: [ConfigModule.register(...)])。 - 动态模块本身可以导入其他模块。我们在这个例子中不会这样做,但如果动态模块依赖于其他模块的提供者,您将使用可选的
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(...) 返回一个 DynamicModule 对象,其属性本质上与我们迄今为止通过 @Module() 装饰器提供的元数据相同。
提示 从 @nestjs/common 导入 DynamicModule。
然而,我们的动态模块还不是很有趣,因为我们还没有引入任何配置它的能力,正如我们说我们想要做的那样。让我们接下来解决这个问题。
模块配置
自定义 ConfigModule 行为的明显解决方案是在静态 register() 方法中传递 options 对象,正如我们上面猜测的那样。让我们再次查看我们消费模块的 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 对象来自定义其行为。让我们暂时假设我们知道如何以某种方式将 options 从 register() 方法传递到 ConfigService 中。有了这个假设,我们可以对服务进行一些更改,以根据 options 对象的属性自定义其行为。(注意:目前,由于我们_还没有_实际确定如何传递它,我们只是硬编码 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 知道如何在我们在 options 中指定的文件夹中找到 .env 文件。
我们剩下的任务是以某种方式将 options 对象从 register() 步骤注入到我们的 ConfigService 中。当然,我们将使用_依赖注入_来做到这一点。这是一个关键点,所以请确保您理解它。我们的 ConfigModule 正在提供 ConfigService。ConfigService 反过来依赖于仅在运行时提供的 options 对象。因此,在运行时,我们需要首先将 options 对象绑定到 Nest IoC 容器,然后让 Nest 将其注入到我们的 ConfigService 中。记住从自定义提供者章节中,提供者可以包括任何值,不仅仅是服务,所以我们可以使用依赖注入来处理简单的 options 对象。
让我们首先解决将选项对象绑定到 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'),但最佳实践是将其定义为单独文件中的常量(或 Symbol),并导入该文件。例如:
export const CONFIG_OPTIONS = 'CONFIG_OPTIONS';
示例
本章中代码的完整示例可以在这里找到。
社区指南
您可能已经在一些 @nestjs/ 包周围看到了 forRoot、register 和 forFeature 等方法的使用,并且可能想知道所有这些方法的区别是什么。对此没有硬性规定,但 @nestjs/ 包试图遵循这些指南:
创建模块时:
register,您期望使用特定配置配置动态模块,仅供调用模块使用。例如,使用 Nest 的@nestjs/axios:HttpModule.register({ baseUrl: 'someUrl' })。如果在另一个模块中您使用HttpModule.register({ baseUrl: 'somewhere else' }),它将具有不同的配置。您可以为任意数量的模块执行此操作。forRoot,您期望配置动态模块一次并在多个地方重用该配置(尽管可能在不知不觉中,因为它被抽象掉了)。这就是为什么您有一个GraphQLModule.forRoot()、一个TypeOrmModule.forRoot()等。forFeature,您期望使用动态模块的forRoot配置,但需要修改一些特定于调用模块需求的配置(即此模块应该访问哪个存储库,或记录器应该使用的上下文)。
所有这些通常也有它们的 async 对应物,registerAsync、forRootAsync 和 forFeatureAsync,它们意味着同样的事情,但也使用 Nest 的依赖注入进行配置。
可配置模块构建器
由于手动创建高度可配置的动态模块,暴露 async 方法(registerAsync、forRootAsync 等)相当复杂,特别是对于新手,Nest 暴露了 ConfigurableModuleBuilder 类,它促进了这个过程,让您只需几行代码就能构建模块"蓝图"。
例如,让我们采用我们上面使用的示例(ConfigModule)并使用 ConfigurableModuleBuilder 转换它。在我们开始之前,让我们确保创建一个专用接口,表示我们的 ConfigModule 接受的选项。
export interface ConfigModuleOptions {
folder: string;
}
有了这个,创建一个新的专用文件(与现有的 config.module.ts 文件一起)并将其命名为 config.module-definition.ts。在这个文件中,让我们利用 ConfigurableModuleBuilder 来构建 ConfigModule 定义。
@@filename(config.module-definition)
import { ConfigurableModuleBuilder } from '@nestjs/common';
import { ConfigModuleOptions } from './interfaces/config-module-options.interface';
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
new ConfigurableModuleBuilder<ConfigModuleOptions>().build();
@@switch
import { ConfigurableModuleBuilder } from '@nestjs/common';
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
new ConfigurableModuleBuilder().build();
现在让我们打开 config.module.ts 文件并修改其实现以利用自动生成的 ConfigurableModuleClass:
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 方法,允许消费者异步配置该模块,例如,通过提供异步工厂:
@Module({
imports: [
ConfigModule.register({ folder: './config' }),
// 或者:
// ConfigModule.registerAsync({
// useFactory: () => {
// return {
// folder: './config',
// }
// },
// inject: [...any extra dependencies...]
// }),
],
})
export class AppModule {}
registerAsync 方法接受以下对象作为参数:
{
/**
* 解析为将被实例化为提供者的类的注入令牌。
* 该类必须实现相应的接口。
*/
useClass?: Type<
ConfigurableModuleOptionsFactory<ModuleOptions, FactoryClassMethodKey>
>;
/**
* 返回选项(或解析为选项的 Promise)以配置模块的函数。
*/
useFactory?: (...args: any[]) => Promise<ModuleOptions> | ModuleOptions;
/**
* 工厂可能注入的依赖项。
*/
inject?: FactoryProvider['inject'];
/**
* 解析为现有提供者的注入令牌。提供者必须实现相应的接口。
*/
useExisting?: Type<
ConfigurableModuleOptionsFactory<ModuleOptions, FactoryClassMethodKey>
>;
}
让我们逐一了解上述属性:
useFactory- 返回配置对象的函数。它可以是同步的或异步的。要将依赖项注入到工厂函数中,请使用inject属性。我们在上面的示例中使用了这个变体。inject- 将注入到工厂函数中的依赖项数组。依赖项的顺序必须与工厂函数中参数的顺序匹配。useClass- 将被实例化为提供者的类。该类必须实现相应的接口。通常,这是一个提供create()方法返回配置对象的类。在下面的自定义方法键部分中阅读更多相关信息。useExisting-useClass的变体,允许您使用现有提供者而不是指示 Nest 创建类的新实例。当您想要使用已在模块中注册的提供者时,这很有用。请记住,该类必须实现与useClass中使用的相同接口(因此它必须提供create()方法,除非您覆盖默认方法名称,请参见下面的自定义方法键部分)。
始终选择上述选项之一(useFactory、useClass 或 useExisting),因为它们是互斥的。
最后,让我们更新 ConfigService 类以注入生成的模块选项提供者,而不是我们迄今为止使用的 'CONFIG_OPTIONS'。
@Injectable()
export class ConfigService {
constructor(@Inject(MODULE_OPTIONS_TOKEN) private options: ConfigModuleOptions) { ... }
}
自定义方法键
ConfigurableModuleClass 默认提供 register 及其对应的 registerAsync 方法。要使用不同的方法名称,请使用 ConfigurableModuleBuilder#setClassMethodName 方法,如下所示:
@@filename(config.module-definition)
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
new ConfigurableModuleBuilder<ConfigModuleOptions>().setClassMethodName('forRoot').build();
@@switch
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
new ConfigurableModuleBuilder().setClassMethodName('forRoot').build();
这个构造将指示 ConfigurableModuleBuilder 生成一个暴露 forRoot 和 forRootAsync 而不是的类。示例:
@Module({
imports: [
ConfigModule.forRoot({ folder: './config' }), // <-- 注意使用 "forRoot" 而不是 "register"
// 或者:
// ConfigModule.forRootAsync({
// useFactory: () => {
// return {
// folder: './config',
// }
// },
// inject: [...any extra dependencies...]
// }),
],
})
export class AppModule {}
自定义选项工厂类
由于 registerAsync 方法(或 forRootAsync 或任何其他名称,取决于配置)让消费者传递解析为模块配置的提供者定义,库消费者可能会提供一个用于构造配置对象的类。
@Module({
imports: [
ConfigModule.registerAsync({
useClass: ConfigModuleOptionsFactory,
}),
],
})
export class AppModule {}
默认情况下,此类必须提供返回模块配置对象的 create() 方法。但是,如果您的库遵循不同的命名约定,您可以更改该行为并指示 ConfigurableModuleBuilder 期望不同的方法,例如 createConfigOptions,使用 ConfigurableModuleBuilder#setFactoryMethodName 方法:
@@filename(config.module-definition)
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
new ConfigurableModuleBuilder<ConfigModuleOptions>().setFactoryMethodName('createConfigOptions').build();
@@switch
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
new ConfigurableModuleBuilder().setFactoryMethodName('createConfigOptions').build();
现在,ConfigModuleOptionsFactory 类必须暴露 createConfigOptions 方法(而不是 create):
@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 方法的第一个参数是包含"额外"属性默认值的对象。第二个参数是一个函数,它接受自动生成的模块定义(带有 provider、exports 等)和 extras 对象,该对象表示额外属性(由消费者指定或默认值)。此函数的返回值是修改后的模块定义。在这个特定示例中,我们取 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" 属性
// ...
}
}
扩展自动生成的方法
如果需要,可以扩展自动生成的静态方法(register、registerAsync 等),如下所示:
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_TYPE 和 ASYNC_OPTIONS_TYPE 类型的使用:
export const {
ConfigurableModuleClass,
MODULE_OPTIONS_TOKEN,
OPTIONS_TYPE,
ASYNC_OPTIONS_TYPE,
} = new ConfigurableModuleBuilder<ConfigModuleOptions>().build();