NestJS 动态模块
模块一章介绍了 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 {}
这些构造使我们能够注入 UsersService 例如 AuthService 托管在其中的 AuthModule:
import { Injectable } from '@nestjs/common';
import { UsersService } from '../users/users.service';
@Injectable()
export class AuthService {
constructor(private readonly usersService: UsersService) {}
/*
Implementation that makes use of this.usersService
*/
}
我们将其称为静态模块绑定。Nest在主模块和消费模块中已经声明了连接模块所需的所有信息。让我们来看看这个过程中发生了什么。Nest通过以下方式使 UsersService 在 AuthModule中可用:
- 实例化 UsersModule ,包括传递导入 UsersModule 本身使用的其他模块,以及传递的任何依赖项(参见自定义提供程序)。
- 实例化 AuthModule ,并将 UsersModule 导出的提供者提供给 AuthModule 中的组件(就像在 AuthModule 中声明它们一样)。
- 在 AuthService 中注入 UsersService 实例。
动态模块实例
使用静态模块绑定,消费模块不会影响来自主机模块的提供者的配置方式。为什么这很重要?考虑这样一种情况:我们有一个通用模块,它需要在不同的用例中有不同的行为。这类似于许多系统中的插件概念,在这些系统中,一般功能需要一些配置才能供使用者使用。
Nest 的一个很好的例子是配置模块。 许多应用程序发现使用配置模块来外部化配置详细信息很有用。 这使得在不同部署中动态更改应用程序设置变得容易:例如,开发人员的开发数据库,测试环境的数据库等。通过将配置参数的管理委派给配置模块,应用程序源代码保持独立于配置参数。
主要在于配置模块本身,因为它是通用的(类似于 '插件' ),需要由它的消费模块进行定制。这就是动态模块发挥作用的地方。使用动态模块特性,我们可以使配置模块成为动态的,这样消费模块就可以使用 API 来控制配置模块在导入时是如何定制的。
换句话说,动态模块提供了一个 API ,用于将一个模块导入到另一个模块中,并在导入模块时定制该模块的属性和行为,而不是使用我们目前看到的静态绑定。
配置模块示例
在本节中,我们将使用示例代码的基本版本。 截至本章末尾的完整版本在此处可用作工作示例。
我们的要求是使 ConfigModule 接受选项对象以对其进行自定义。 这是我们要支持的功能。 基本示例将 .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 {}
让我们考虑一下动态模块导入是什么样子的,我们在其中传递了一个配置对象。比较这两个例子之间的导入数组的差异:
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() 方法必须返回类似模块的内容,因为它的返回值出现在熟悉的导入列表中,到目前为止,我们已经看到该列表包含了一个模块列表。
实际上,我们的 register() 方法将返回的是 DynamicModule。 动态模块无非就是在运行时创建的模块,它具有与静态模块相同属性,外加一个称为模块的附加属性。 让我们快速查看一个示例静态模块声明,并密切注意传递给装饰器的模块选项:
@Module({
imports: [DogsService],
controllers: [CatsController],
providers: [CatsService],
exports: [CatsService]
})
动态模块必须返回具有完全相同接口的对象,外加一个称为module的附加属性。 module属性用作模块的名称,并且应与模块的类名相同,如下例所示。
对于动态模块,模块选项对象的所有属性都是可选的,模块除外。
静态 register() 方法呢? 现在我们可以看到它的工作是返回具有 DynamicModule 接口的对象。 当我们调用它时,我们实际上是在导入列表中提供一个模块,类似于在静态情况下通过列出模块类名的方式。 换句话说,动态模块 API 只是返回一个模块,而不是固定 @Modules 装饰器中的属性,而是通过编程方式指定它们。
仍然有一些细节需要详细了解:
- 现在我们可以声明 @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() 装饰器提供的元数据相同。
DynamicModule 需要从 @nestjs/common 包导入。
然而,我们的动态模块还不是很有趣,因为我们还没有引入任何我们想要配置它的功能。让我们接下来解决这个问题。
模块配置
定制 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 对象来定制它的行为。现在让我们假设我们知道如何将 register() 方法中的选项获取到 ConfigService 中。有了这个假设,我们可以对服务进行一些更改,以便基于 options 对象的属性自定义其行为。(注意:目前,由于我们还没有确定如何传递它,我们将只硬编码选项。我们将在一分钟内解决这个问题)。
import { Injectable } from '@nestjs/common';
import * as dotenv from 'dotenv';
import * as fs from 'fs';
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() 步骤中的 options 对象注入 ConfigService。当然,我们将使用依赖注入来做到这一点。这是一个关键点,所以一定要理解它。我们的 ConfigModule 提供 ConfigService。而 ConfigService 又依赖于只在运行时提供的 options 对象。因此,在运行时,我们需要首先将 options 对象绑定到 Nest IoC 容器,然后让 Nest 将其注入 ConfigService 。请记住,在自定义提供者一章中,提供者可以包含任何值,而不仅仅是服务,所以我们可以使用依赖项注入来处理简单的 options 对象。
让我们首先处理将 options 对象绑定到 IoC 容器的问题。我们在静态 register() 方法中执行此操作。请记住,我们正在动态地构造一个模块,而模块的一个属性就是它的提供者列表。因此,我们需要做的是将 options 对象定义为提供程序。这将使它可注入到 ConfigService 中,我们将在下一个步骤中利用它。在下面的代码中,注意 provider 数组:
import { DynamicModule, Module } from '@nestjs/common';
import { ConfigService } from './config.service';
@Module({})
export class ConfigModule {
static register(options): DynamicModule {
return {
module: ConfigModule,
providers: [
{
provide: 'CONFIG_OPTIONS',
useValue: options,
},
ConfigService,
],
exports: [ConfigService],
};
}
}
现在,我们可以通过将 'CONFIG_OPTIONS' 提供者注入 ConfigService 来完成这个过程。回想一下,当我们使用非类令牌定义提供者时,我们需要使用这里描述的 @Inject() 装饰器。
import { Injectable, Inject } from '@nestjs/common';
import * as dotenv from 'dotenv';
import * as fs from 'fs';
import { EnvConfig } from './interfaces';
@Injectable()
export class ConfigService {
private readonly envConfig: EnvConfig;
constructor(@Inject('CONFIG_OPTIONS') private options) {
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';
更多建议: