模块章节 涵盖了 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 { UsersModule } from '../users/users.module'
import { AuthService } from './auth.service'
@Module({
imports: [UsersModule],
providers: [AuthService],
exports: [AuthService],
})
export class AuthModule {}
这些构造允许我们注入 UsersService
,例如,托管在 AuthModule
中的 AuthService
:
import { Injectable } from '@nestjs/common'
import { UsersService } from '../users/users.service'
@Injectable()
export class AuthService {
constructor(private 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
接受 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 {}
让我们考虑一下动态模块导入(我们传入配置对象)可能是什么样子。比较这两个示例之间导入
数组的差异:
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
。
静态``方法呢?我们现在可以看到它的工作是返回一个具有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
对象来定制其行为。让我们暂时假设我们知道如何以某种方式将 register()
方法中的 options
放入 ConfigService
中。有了这个假设,我们可以对服务进行一些更改,以根据 options
对象的属性定制其行为。(注意:暂时,因为我们还没有真正确定如何传递它,所以我们只会硬编码 options
。我们马上就会解决这个问题)。
import * as fs from 'node:fs'
import * as path from 'node:path'
import { Injectable } from '@nestjs/common'
import * as dotenv from 'dotenv'
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
文件。
我们剩下的任务是以某种方式将 register()
步骤中的 options
对象注入到我们的 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 fs from 'node:fs'
import * as path from 'node:path'
import * as dotenv from 'dotenv'
import { Inject, Injectable } 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
定义。
import { ConfigurableModuleBuilder } from '@nestjs/common'
import { ConfigModuleOptions } from './interfaces/config-module-options.interface'
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN }
= new ConfigurableModuleBuilder<ConfigModuleOptions>().build()
import { ConfigurableModuleBuilder } from '@nestjs/common'
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN }
= new ConfigurableModuleBuilder().build()
Now let's open up the config.module.ts
file and modify its implementation to leverage the auto-generated 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' }),
// or alternatively:
// ConfigModule.registerAsync({
// useFactory: () => {
// return {
// folder: './config',
// }
// },
// inject: [...any extra dependencies...]
// }),
],
})
export class AppModule {}
最后,让我们更新ConfigService
类以注入生成的模块选项的提供程序,而不是我们迄今为止使用的CONFIG_OPTIONS
。
@Injectable()
export class ConfigService {
constructor(@Inject(MODULE_OPTIONS_TOKEN) private options: ConfigModuleOptions) { ... }
}
自定义方法键
ConfigurableModuleClass
默认提供 register
及其对应的 registerAsync
方法。要使用不同的方法名称,请使用 ConfigurableModuleBuilder#setClassMethodName
方法,如下所示:
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN }
= new ConfigurableModuleBuilder<ConfigModuleOptions>().setClassMethodName('forRoot').build()
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN }
= new ConfigurableModuleBuilder().setClassMethodName('forRoot').build()
此构造将指示 ConfigurableModuleBuilder
生成一个公开 forRoot
和 forRootAsync
的类。例如:
@Module({
imports: [
ConfigModule.forRoot({ folder: './config' }), // <-- note the use of "forRoot" instead of "register"
// or alternatively:
// 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
期望不同的方法,例如,使用 ConfigurableModuleBuilder#setFactoryMethodName
方法的 createConfigOptions
:
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN }
= new ConfigurableModuleBuilder<ConfigModuleOptions>().setFactoryMethodName('createConfigOptions').build()
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN }
= new ConfigurableModuleBuilder().setFactoryMethodName('createConfigOptions').build()
现在,ConfigModuleOptionsFactory
类必须公开 createConfigOptions
方法(而不是 create
):
@Module({
imports: [
ConfigModule.registerAsync({
useClass: ConfigModuleOptionsFactory, // <-- this class must provide the "createConfigOptions" method
}),
],
})
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
方法的第一个参数是一个包含extra
属性默认值的对象。第二个参数是一个函数,它接受自动生成的模块定义(使用 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" object will not have the "isGlobal" property
// ...
}
}
扩展自动生成的方法
可以根据需要扩展自动生成的静态方法(register
、registerAsync
等),如下所示:
import { Module } from '@nestjs/common'
import { ConfigService } from './config.service'
import { ASYNC_OPTIONS_TYPE, ConfigurableModuleClass, OPTIONS_TYPE } from './config.module-definition'
@Module({
providers: [ConfigService],
exports: [ConfigService],
})
export class ConfigModule extends ConfigurableModuleClass {
static register(options: typeof OPTIONS_TYPE): DynamicModule {
return {
// your custom logic here
...super.register(options),
}
}
static registerAsync(options: typeof ASYNC_OPTIONS_TYPE): DynamicModule {
return {
// your custom logic here
...super.registerAsync(options),
}
}
}
请注意必须从模块定义文件中导出的 OPTIONS_TYPE
和 ASYNC_OPTIONS_TYPE
类型的使用:
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN, OPTIONS_TYPE, ASYNC_OPTIONS_TYPE } = new ConfigurableModuleBuilder<ConfigModuleOptions>().build()