Skip to main content

自定义提供者

在早期的章节中,我们涉及了**依赖注入(DI)**的各个方面,以及它在 Nest 中的使用方式。 其中一个例子是使用构造函数注入将实例(通常是服务提供者)注入到类中。 您可能不会感到惊讶地了解到,依赖注入在 Nest 核心中以一种基本的方式内置。 到目前为止,我们只探讨了一个主要的模式。随着应用程序变得更加复杂,您可能需要充分利用 DI 系统的全部功能, 因此让我们更详细地探讨它们。

依赖注入基础知识

依赖注入是一种**控制反转(IoC)**技术,其中您将依赖项的实例化委托给IoC容器 (在我们的情况下是 NestJS 运行时系统),而不是在您自己的代码中以命令式的方式执行。 让我们来看一下来自提供者章节的这个例子中正在发生什么。

首先,我们定义了一个提供者。@Injectable() 装饰器将 CatsService 类标记为提供者。

cats.service.ts
import { Injectable } from '@nestjs/common';
import { Cat } from './interfaces/cat.interface';

@Injectable()
export class CatsService {
private readonly cats: Cat[] = [];

findAll(): Cat[] {
return this.cats;
}
}

然后,我们请求Nest将提供者程序注入到我们的控制器类中:

cats.controller.ts
import { Controller, Get } from '@nestjs/common';
import { CatsService } from './cats.service';
import { Cat } from './interfaces/cat.interface';

@Controller('cats')
export class CatsController {
constructor(private catsService: CatsService) {}

@Get()
async findAll(): Promise<Cat[]> {
return this.catsService.findAll();
}
}

最后,我们向Nest IoC容器注册提供者程序:

app.module.ts
import { Module } from '@nestjs/common';
import { CatsController } from './cats/cats.controller';
import { CatsService } from './cats/cats.service';

@Module({
controllers: [CatsController],
providers: [CatsService],
})
export class AppModule {}

究竟是什么在暗中发生,使得这一切得以实现?这个过程有三个关键步骤:

  1. cats.service.ts中,我们使用@Injectable()装饰器将CatsService类声明为可由Nest IoC容器管理的类。
  2. cats.controller.ts中,CatsController通过构造器注入声明了对CatsService标记的依赖。
     constructor(private catsService: CatsService) {}
  3. app.module.ts中,我们将字符CatsServicecats.service.ts文件中的CatsService类关联起来。

我们将在下面详细了解这种关联(也称为注册)是如何发生的。

当Nest IoC容器实例化CatsController时,它首先查找任何依赖项当它找到CatsService依赖项时, 它会在CatsService标记上执行查找,该标记返回CatsService类, 根据注册步骤(上述的#3)。假设是SINGLETON范围(默认行为),Nest将创建一个CatsService实例, 将其缓存并返回,或者如果已经缓存,则返回现有实例。

这个解释有点简化以阐明观点。我们忽略的一个重要领域是分析代码以查找依赖项的过程非常复杂,并且发生在应用程序启动期间。 一个关键特征是依赖性分析(或“创建依赖图”)是传递的。 在上述示例中,如果CatsService本身有依赖关系,这些依赖关系也将被解决。 依赖图确保以正确的顺序解决依赖关系 - 本质上是“自底向上”。 这种机制使开发人员无需管理如此复杂的依赖关系图。

标准提供者

让我们仔细看看@Module()装饰器。在app.module中,我们声明:

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

providers属性采用一组提供者。 到目前为止,我们已经通过类名列表提供了这些提供者程序。 事实上,语法providers: [CatsService]是更完整语法的简写形式:

providers: [
{
provide: CatsService,
useClass: CatsService,
},
]

现在我们看到了这个显式结构,就能理解注册过程了。 在这里,我们明确地将CatsService标记(token)与CatsService类关联起来。 这种简称只是为了简化最常见的使用情况,即使用标记来请求同名类的实例

自定义提供程序

当你的需求超出标准提供程序提供的范围时会发生什么呢?以下是一些例子:

  • 你想要创建一个自定义实例,而不是让Nest实例化(或返回缓存的实例)一个类
  • 你想要在第二个依赖中重用一个现有类
  • 你想要用测试的模拟版本覆盖一个类 Nest允许你定义自定义提供程序来处理这些情况。它提供了几种定义自定义提供程序的方式。 让我们逐步了解它们。
tip

如果你在依赖项解析方面遇到问题,你可以设置NEST_DEBUG环境变量,在启动期间获得额外的依赖项解析日志。

Value提供程序: useValue

useValue语法对于注入常量值、将外部库放入Nest容器中或用模拟对象替换真实实现非常有用。 比如说,你想要强制Nest在测试目的中使用一个模拟的CatsService

import { CatsService } from './cats.service';

const mockCatsService = {
/* mock implementation */
};

@Module({
imports: [CatsModule],
providers: [
{
provide: CatsService,
useValue: mockCatsService,
},
],
})
export class AppModule {}

在这个例子中,CatsService标记将解析为mockCatsService模拟对象。 useValue需要一个值 - 在这种情况下,它是一个字面对象,它具有与它替代的CatsService类相同的接口。 由于TypeScript的结构类型,你可以使用任何具有兼容接口的对象,包括字面对象或使用new实例化的类实例。

非基于类的提供程序标记

到目前为止,我们一直使用类名作为我们的提供程序标记(在providers数组中列出的提供程序的provide属性的值)。 这与基于构造函数注入中使用的标准模式相匹配, 其中标记也是类名(如果这个概念不太清晰,请参考DI基础知识进行复习)。 有时,我们可能希望灵活地使用字符串或符号作为DI标记。例如:

import { connection } from './connection';

@Module({
providers: [
{
provide: 'CONNECTION',
useValue: connection,
},
],
})
export class AppModule {}

在这个例子中,我们将一个字符串值的标记('CONNECTION')与我们从外部文件导入的预先存在的连接对象关联起来。

tip

除了使用字符串作为标记值外,你还可以使用JavaScript符号或TypeScript枚举。

我们之前已经看到了如何使用标准的基于构造函数注入模式注入提供程序。 这种模式要求依赖关系使用类名声明。'CONNECTION'自定义提供程序使用字符串值的标记。 让我们看看如何注入这样的提供程序。为此,我们使用@Inject()装饰器。这个装饰器接受一个参数 - 标记。

@Injectable()
export class CatsRepository {
constructor(@Inject('CONNECTION') connection: Connection) {}
}
tip

@Inject()装饰器从@nestjs/common包中导入。

虽然在上面的例子中我们直接使用字符串'CONNECTION'用于说明目的,但为了代码组织的清晰性, 最好的实践是在一个单独的文件中定义标记,比如constants.ts。 将它们视为在自己的文件中定义并在需要的地方导入的符号或枚举。

类提供程序: useClass

useClass语法允许你动态确定一个标记应该解析为哪个类。 例如,假设我们有一个抽象(或默认)的ConfigService类。 根据当前的环境,我们希望Nest提供不同的配置服务实现。 以下代码实现了这样的策略。

const configServiceProvider = {
provide: ConfigService,
useClass:
process.env.NODE_ENV === 'development'
? DevelopmentConfigService
: ProductionConfigService,
};

@Module({
providers: [configServiceProvider],
})
export class AppModule {}

让我们在这个代码示例中看一些细节。 你会注意到我们首先用一个字面对象定义了configServiceProvider, 然后将它传递给模块装饰器的providers属性。 这只是一点代码组织,但在功能上等同于本章迄今为止使用的示例。

此外,我们已经将ConfigService类名用作我们的标记。 对于任何依赖于ConfigService的类,Nest将注入由提供的类的实例 (DevelopmentConfigServiceProductionConfigService), 覆盖可能在其他地方声明的任何默认实现(例如,使用@Injectable()装饰器声明的ConfigService)。

工厂提供程序: useFactory

useFactory 语法允许动态创建提供程序。 实际的提供程序将由从工厂函数返回的值提供。 工厂函数可以根据需要简单或复杂。 简单的工厂可能不依赖于任何其他提供程序。 更复杂的工厂可以自身注入其需要计算结果的其他提供程序。 对于后者,工厂提供程序语法具有一对相关的机制:

  1. 工厂函数可以接受(可选的)参数。
  2. (可选的)inject 属性接受一个由Nest解析并在实例化过程中作为参数传递给工厂函数的提供程序数组。 此外,这些提供程序可以被标记为可选的。 这两个列表应该是相关的:Nest将以相同的顺序将来自 inject 列表的实例作为参数传递给工厂函数。 下面的例子演示了这一点。
     const connectionProvider = {
    provide: 'CONNECTION',
    useFactory: (optionsProvider: OptionsProvider, optionalProvider?: string) => {
    const options = optionsProvider.get();
    return new DatabaseConnection(options);
    },
    inject: [OptionsProvider, { token: 'SomeOptionalProvider', optional: true }],
    // \_____________/ \__________________/
    // This provider The provider with this
    // is mandatory. token can resolve to `undefined`.
    };

    @Module({
    providers: [
    connectionProvider,
    OptionsProvider,
    // { provide: 'SomeOptionalProvider', useValue: 'anything' },
    ],
    })
    export class AppModule {}

别名提供程序: useExisting

useExisting语法允许你为现有的提供程序创建别名。 这样可以创建两种访问相同提供程序的方式。 在下面的例子中,(基于字符串的)标记'AliasedLoggerService'是(基于类的)标记LoggerService 的别名。 假设我们有两个不同的依赖项,一个用于'AliasedLoggerService',另一个用于LoggerService。 如果两个依赖项都使用SINGLETON范围指定,它们将都解析为相同的实例。

@Injectable()
class LoggerService { /* ... */ }

const loggerAliasProvider = {
provide: 'AliasedLoggerService',
useExisting: LoggerService,
};

@Module({
providers: [LoggerService, loggerAliasProvider],
})
export class AppModule {}

非基于服务的提供程序

虽然提供者程序通常提供服务,但它们并不局限于此用法。 提供者程序可以提供任何值。例如,一个提供程序可以根据当前环境提供一个配置对象数组,如下所示:

const configFactory = {
provide: 'CONFIG',
useFactory: () => {
return process.env.NODE_ENV === 'development' ? devConfig : prodConfig;
}
}

@Module({
providers: [configFactory],
})

export class AppModule {}

导出自定义提供者程序

与任何提供者程序一样,自定义提供者程序的范围仅限于其声明模块。 为了使其对其他模块可见,必须将其导出。 要导出自定义提供者程序,我们可以使用其标记(token)或完整的提供者程序对象。

以下示例显示使用标记(token)导出:

const connectionFactory = {
provide: 'CONNECTION',
useFactory: (optionsProvider: OptionsProvider) => {
const options = optionsProvider.get();
return new DatabaseConnection(options);
},
inject: [OptionsProvider],
}

@Module({
providers: [connectionFactory],
exports: ['CONNECTION'],
})
export class AppModule {}

或者,使用完整的提供者程序对象导出:

const connectionFactory = {
provide: 'CONNECTION',
useFactory: (optionsProvider: OptionsProvider) => {
const options = optionsProvider.get();
return new DatabaseConnection(options);
},
inject: [OptionsProvider],
}

@Module({
providers: [connectionFactory],
exports: [connectionFactory],
})

export class AppModule {}

异步提供程序

有时,在一个或多个异步任务完成之前,应用程序启动应该被延迟。 例如,你可能不希望在与数据库的连接建立之前开始接受请求。 你可以使用异步提供程序来实现这一点。

这种情况下的语法是在useFactory 语法中使用async/await。 工厂返回一个Promise,工厂函数可以等待(await)异步任务。 Nest将在实例化依赖(注入)这样的提供程序的任何类之前等待Promise的解析。

{
provide: 'ASYNC_CONNECTION',
useFactory: async () => {
const connection = await createConnection(options);
return connection;
},
}
tip

在这里学习更多自定义提供者语法

注入

与任何其他提供者程序一样,异步提供者程序通过其标记(token)注入到其他组件。 在上面的示例中,您将使用构造函数@Inject('ASYNC_CONNECTION')

动态模块

在《模块》章节中,介绍了 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();

注入作用域

对于来自不同编程语言背景的人来说,可能会感到意外的是,在Nest中,几乎所有内容都是跨传入请求共享的。 我们有一个与数据库的连接池、具有全局状态的单例服务等等。 请记住,Node.js不遵循请求/响应的多线程无状态模型,其中每个请求都由一个单独的线程处理。 因此,在我们的应用程序中使用单例实例是完全安全的

然而,在一些特殊情况下,可能需要基于请求的生命周期, 例如在GraphQL应用程序中进行每个请求的缓存、请求跟踪和多租户支持。 注入作用域提供了一种机制来获得所需的提供程序生命周期行为。

提供程序作用域

提供程序可以具有以下任何一种作用域:

  • DEFAULT: 提供程序的单个实例在整个应用程序中共享。实例的生命周期直接与应用程序生命周期相关联。 一旦应用程序启动,所有单例提供程序都已实例化。默认情况下使用单例作用域。

  • REQUEST: 为每个传入请求专门创建提供程序的新实例。在请求完成处理后,该实例将被垃圾回收。

  • TRANSIENT: 瞬时提供程序不在各个消费者之间共享。 每个注入瞬时提供程序的消费者都将获得一个新的、专用的实例。

tip

对于大多数用例,建议使用单例作用域。 在消费者和请求之间共享提供程序意味着可以缓存实例,并且其初始化仅在应用程序启动期间发生一次。

使用方式

通过将scope属性传递给@Injectable()装饰器选项对象来指定注入作用域:

import { Injectable, Scope } from '@nestjs/common';

@Injectable({ scope: Scope.REQUEST })
export class CatsService {}

同样,对于自定义提供程序,可以在提供程序注册的长格式中设置scope属性:

{
provide: 'CACHE_MANAGER',
useClass: CacheManager,
scope: Scope.TRANSIENT,
}
tip

@nestjs/common 中导入 Scope 枚举。

默认情况下使用单例作用域,并且无需声明。如果确实要将提供程序声明为单例作用域, 请对scope属性使用Scope.DEFAULT值。

tip

WebSocket Gateways 不应使用请求作用域的提供程序,因为它们必须充当单例。 每个网关封装了一个真实的套接字,不能多次实例化。 此限制还适用于其他一些提供程序,例如 Passport 策略或 Cron 控制器。

控制器作用域

控制器也可以具有作用域,该作用域适用于在该控制器中声明的所有请求方法处理程序。 与提供程序作用域一样,控制器的作用域声明其生命周期。 对于请求作用域的控制器,将为每个传入请求创建一个新实例,并在请求处理完成后进行垃圾回收。

使用ControllerOptions对象的scope属性声明控制器作用域:

@Controller({
path: 'cats',
scope: Scope.REQUEST,
})
export class CatsController {}

作用域层次结构

REQUEST作用域在注入链上传播。依赖于请求作用域提供程序的控制器本身将成为请求作用域。

想象一下以下依赖图:CatsController <- CatsService <- CatsRepository。 如果CatsService是请求作用域(其他是默认的单例), 则CatsController将成为请求作用域,因为它依赖于注入的服务。 而不依赖的CatsRepository仍将保持单例作用域。

瞬时作用域的依赖关系不遵循该模式。 如果单例作用域的DogsService注入一个瞬时作用域的LoggerService提供程序,它将收到一个新实例。 然而,DogsService将保持单例作用域,因此在任何地方注入它都不会解析为DogsService的新实例。 如果希望达到这种行为,必须将DogsService明确标记为TRANSIENT

请求提供程序

在基于 HTTP 服务器的应用程序中(例如使用 @nestjs/platform-express@nestjs/platform-fastify),当使用请求作用域的提供程序时, 您可能希望访问对原始请求对象的引用。您可以通过注入REQUEST对象来实现这一点。

import { Injectable, Scope, Inject } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';

@Injectable({ scope: Scope.REQUEST })
export class CatsService {
constructor(@Inject(REQUEST) private request: Request) {}
}

由于底层平台/协议的差异,您在微服务或GraphQL应用程序中略有不同地访问传入请求。 在GraphQL应用程序中,您注入CONTEXT而不是REQUEST

import { Injectable, Scope, Inject } from '@nestjs/common';
import { CONTEXT } from '@nestjs/graphql';

@Injectable({ scope: Scope.REQUEST })
export class CatsService {
constructor(@Inject(CONTEXT) private context) {}
}

然后,在GraphQLModule中配置上下文值,使其包含request作为其属性。

询问者提供程序

如果您想要获取构造提供程序的类,例如在日志记录或度量提供程序中,您可以注入INQUIRER令牌。

import { Inject, Injectable, Scope } from '@nestjs/common';
import { INQUIRER } from '@nestjs/core';

@Injectable({ scope: Scope.TRANSIENT })
export class HelloService {
constructor(@Inject(INQUIRER) private parentClass: object) {}

sayHello(message: string) {
console.log(`${this.parentClass?.constructor?.name}: ${message}`);
}
}

然后像下面这样使用它:

import { Injectable } from '@nestjs/common';
import { HelloService } from './hello.service';

@Injectable()
export class AppService {
constructor(private helloService: HelloService) {}

getRoot(): string {
this.helloService.sayHello('My name is getRoot');

return 'Hello world!';
}
}

在上面的示例中,当调用 AppService#getRoot 时,将向控制台记录 "AppService: My name is getRoot"

性能

使用请求作用域的提供程序将影响应用程序性能。 尽管 Nest 尝试尽可能缓存元数据,但仍然需要在每个请求上创建您的类的实例。 因此,它将减缓您的平均响应时间和整体基准测试结果。 除非提供程序必须是请求作用域的,否则强烈建议使用默认的单例作用域。

tip

尽管这听起来令人生畏,但一个设计良好的应用程序,充分利用请求作用域的提供程序, 从延迟的角度来说,不应该减速超过约 5%。

持久化提供者

如上一节所述,请求范围的提供者可能会导致增加的延迟,因为至少有一个请求范围的提供者 (注入到控制器实例中,或更深入到其中的一个提供者中)会使控制器也成为请求范围的。 这意味着它必须在每个单独的请求中重新创建(实例化),并在请求完成处理后进行垃圾回收。 现在,这也意味着,例如,对于 30k 个并行请求,将会有 30k 个控制器实例(及其请求范围的提供者)。

如果有一个通用的提供者,大多数提供者依赖于它(考虑一个数据库连接或日志记录服务), 则自动将所有这些提供者也转换为请求范围的提供者。 在多租户应用程序中,尤其是对于具有抓取请求对象中的标头/令牌并基于其值检索相应 数据库连接/模式(特定于该租户)的中央请求范围的“数据源”提供者,这可能构成挑战。

例如,假设您有一个由 10 个不同客户交替使用的应用程序。 每个客户都有自己专用的数据源,您希望确保客户 A 永远无法访问客户 B 的数据库。 实现这一目标的一种方法是声明一个请求范围的“数据源”提供者, 该提供者 - 基于请求对象 - 确定“当前客户”是什么,并检索其对应的数据库。 通过这种方法,您可以在短短几分钟内将应用程序转变为多租户应用程序。 但是,这种方法的一个主要缺点是,由于您的应用程序的大部分组件很可能依赖于“数据源”提供者, 它们将会自动变为“请求范围”,因此您无疑会在应用程序的性能上看到影响。

但是,如果我们有更好的解决方案呢?由于我们只有 10 个客户, 难道我们不能为每个客户拥有 10 个单独的 DI 子树(而不是在每个请求中重新创建每个树)吗? 如果您的提供者不依赖于每个连续请求都是真正独特的属性(例如,请求 UUID), 而是存在一些特定属性,让我们能够对它们进行聚合(分类), 那么没有理由在每个传入请求中重新创建 DI 子树。

这正是持久化提供者派上用场的地方。

在将提供者标记为持久化提供者之前,我们必须首先注册一种策略, 指示 Nest 什么是那些“通用请求属性”,提供将请求分组的逻辑 - 将其与相应的 DI 子树关联。

import {
HostComponentInfo,
ContextId,
ContextIdFactory,
ContextIdStrategy,
} from '@nestjs/core';
import { Request } from 'express';

const tenants = new Map<string, ContextId>();

export class AggregateByTenantContextIdStrategy implements ContextIdStrategy {
attach(contextId: ContextId, request: Request) {
const tenantId = request.headers['x-tenant-id'] as string;
let tenantSubTreeId: ContextId;

if (tenants.has(tenantId)) {
tenantSubTreeId = tenants.get(tenantId);
} else {
tenantSubTreeId = ContextIdFactory.create();
tenants.set(tenantId, tenantSubTreeId);
}

// 如果树不是持久的,返回原始的 "contextId" 对象
return (info: HostComponentInfo) =>
info.isTreeDurable ? tenantSubTreeId : contextId;
}
}
tip

类似于请求范围,持久性会冒泡到注入链中。这意味着如果 A 依赖于标记为持久的 B,那么 A 也会变成持久的(除非对 A 的提供者显式设置为 false)。

tip

请注意,该策略对于操作大量租户的应用程序不是理想的。

attach方法返回的值告诉 Nest 应该为给定的主机使用什么上下文标识符。 在这种情况下,我们指定了在主机组件(例如,请求范围的控制器)标记为持久时应使用 tenantSubTreeId而不是原始的、自动生成的contextId对象。 此外,在上面的示例中,不会注册有效载荷(其中有效载荷 = REQUEST/CONTEXT 提供者,表示子树的 "根" - 父级)。

如果要为持久化树注册有效载荷,请改用以下结构:

// `AggregateByTenantContextIdStrategy#attach` 方法的返回值:
return {
resolve: (info: HostComponentInfo) =>
info.isTreeDurable ? tenantSubTreeId : contextId,
payload: { tenantId },
}

现在,每当使用 @Inject(REQUEST)/@Inject(CONTEXT) 注入REQUEST提供者时, 将注入有效payload对象(在本例中由tenantId组成的单个属性)。

好的,有了这个策略,您可以在代码的某个地方(因为它全局适用)注册它, 因此例如,您可以将它放在main.ts文件中:

ContextIdFactory.apply(new AggregateByTenantContextIdStrategy());
tip

ContextIdFactory类从@nestjs/core包中导入。

只要注册在任何请求到达应用程序之前,一切都将按预期工作。

最后,要将常规提供者转变为持久化提供者,只需将durable标志设置为true, 并将其范围更改为Scope.REQUEST(如果注入链中已经有REQUEST范围,则不需要):

import { Injectable, Scope } from '@nestjs/common';

@Injectable({ scope: Scope.REQUEST, durable: true })
export class CatsService {}

同样,对于自定义提供者,以长格式注册时,设置durable属性:

{
provide: 'foobar',
useFactory: () => { ... },
scope: Scope.REQUEST,
durable: true,
}

循环依赖

循环依赖发生在两个类相互依赖时。例如,类 A 需要类 B,而类 B 也需要类 A。 在 Nest 中,模块之间和提供者之间都可能出现循环依赖。

虽然应尽量避免循环依赖,但有时是不可避免的。 在这种情况下,Nest 提供两种解决提供者之间循环依赖的方法。 在本章中,我们将描述使用前向引用作为一种技术, 以及使用ModuleRef类从 DI 容器中检索提供者实例的另一种方法。

我们还将描述解决模块之间循环依赖的方法。

warning

循环依赖可能也是使用 "barrel 文件" 或index.ts文件对导入进行分组时的原因。在涉及模块/提供者类时,不应使用 barrel 文件。例如,在 barrel 文件所在目录中导入文件时,即 cats/cats.controller 不应导入 cats/cats.service 文件时,不应使用 barrel 文件。有关详细信息,请参见此 GitHub 问题。

前向引用

前向引用允许 Nest 使用forwardRef()实用程序函数引用尚未定义的类。 例如,如果CatsServiceCommonService互相依赖, 关系的两端都可以使用@Inject()forwardRef()实用程序来解决循环依赖。 否则,Nest 不会实例化它们,因为所有必要的元数据将不可用。以下是一个示例:

cats.service.ts
@Injectable()
export class CatsService {
constructor(
@Inject(forwardRef(() => CommonService))
private commonService: CommonService,
) {}
}
tip

forwardRef() 函数从 @nestjs/common 包导入。

这涵盖了关系的一侧。现在让我们对 CommonService 采取相同的方法:

@Injectable()
export class CommonService {
constructor(
@Inject(forwardRef(() => CatsService))
private catsService: CatsService,
) {}
}
warning

实例化的顺序是不确定的。确保您的代码不依赖于首先调用哪个构造函数。 具有依赖于 Scope.REQUEST 的提供者的循环依赖可能导致未定义的依赖关系。 更多信息请参阅此处

ModuleRef 类替代方案

除了使用forwardRef()外,另一种方法是重构代码并使用ModuleRef类在关系的一侧检索提供者。 有关ModuleRef实用程序类的更多信息,请查看这里

模块前向引用

为了解决模块之间的循环依赖,可以在模块关联的两侧使用相同的forwardRef()实用程序函数。 例如:

common.module.ts
@Module({
imports: [forwardRef(() => CatsModule)],
})
export class CommonModule {}

这涵盖了关系的一方面。现在让我们对CatsModule做同样的事情:

cats.module.ts
@Module({
imports: [forwardRef(() => CommonModule)],
})
export class CatsModule {}

模块引用

Nest提供了ModuleRef类,用于浏览提供程序的内部列表并使用其注入令牌作为查找键获取对任何提供程序的引用。 ModuleRef类还提供了一种动态实例化静态和作用域提供程序的方法。 可以像通常一样将ModuleRef注入到类中:

@Injectable()
export class CatsService {
constructor(private moduleRef: ModuleRef) {}
}
tip

ModuleRef类是从@nestjs/core包中导入的。

检索实例

ModuleRef实例(以下简称为模块引用)具有一个get()方法。 此方法使用其注入令牌/类名在当前模块中检索已存在(已实例化)的提供程序、控制器或可注入对象 (例如,守卫、拦截器等)。

@Injectable()
export class CatsService implements OnModuleInit {
private service: Service;
constructor(private moduleRef: ModuleRef) {}

onModuleInit() {
this.service = this.moduleRef.get(Service);
}
}
warning

不能使用get()方法检索作用域提供程序(瞬态或请求作用域)。 而是,请使用下面描述的技术。了解如何控制作用域,请参阅此处

要从全局上下文中检索提供程序(例如,如果提供程序已在不同的模块中注入), 请将{ strict: false }选项作为get()的第二个参数传递。

this.moduleRef.get(Service, { strict: false });

解析作用域提供程序

要动态解析作用域提供程序(瞬态或请求作用域),请使用resolve()方法, 并将提供程序的注入令牌作为参数传递。

@Injectable()
export class CatsService implements OnModuleInit {
private transientService: TransientService;
constructor(private moduleRef: ModuleRef) {}

async onModuleInit() {
this.transientService = await this.moduleRef.resolve(TransientService);
}
}

resolve()方法返回提供程序的唯一实例,来自其自己的DI容器子树。 每个子树都有一个唯一的上下文标识符。因此,如果多次调用此方法并比较实例引用,您会发现它们不相等。

@Injectable()
export class CatsService implements OnModuleInit {
constructor(private moduleRef: ModuleRef) {}

async onModuleInit() {
const transientServices = await Promise.all([
this.moduleRef.resolve(TransientService),
this.moduleRef.resolve(TransientService),
]);
console.log(transientServices[0] === transientServices[1]); // false
}
}

要在多个resolve()调用之间生成单个实例,并确保它们共享相同的生成DI容器子树, 可以将上下文标识符传递给resolve()方法。使用ContextIdFactory类生成上下文标识符。 该类提供一个create()方法,返回一个适当的唯一标识符。

@Injectable()
export class CatsService implements OnModuleInit {
constructor(private moduleRef: ModuleRef) {}

async onModuleInit() {
const contextId = ContextIdFactory.create();
const transientServices = await Promise.all([
this.moduleRef.resolve(TransientService, contextId),
this.moduleRef.resolve(TransientService, contextId),
]);
console.log(transientServices[0] === transientServices[1]); // true
}
}
tip

ContextIdFactory类是从@nestjs/core包中导入的

注册REQUEST提供程序

手动生成的上下文标识符(使用ContextIdFactory.create())表示在其中REQUEST提供程序未定义的 DI 子树, 因为它们不是由 Nest 依赖注入系统实例化和管理的。

要为手动创建的 DI 子树注册自定义的REQUEST对象,请使用ModuleRef#registerRequestByContextId()方法, 如下所示:

const contextId = ContextIdFactory.create();
this.moduleRef.registerRequestByContextId(/* YOUR_REQUEST_OBJECT */, contextId);

获取当前子树

有时,您可能希望在请求上下文中解析请求范围提供程序的实例。 假设CatsService是请求范围的,并且您想要解析也标记为请求范围提供程序的CatsRepository实例。 为了共享相同的 DI 容器子树,您必须获取当前上下文标识符,而不是生成一个新的 (例如,使用上面显示的ContextIdFactory.create() 函数)。 为了获取当前上下文标识符,请首先使用@Inject()装饰器注入请求对象。

@Injectable()
export class CatsService {
constructor(
@Inject(REQUEST) private request: Record<string, unknown>,
) {}
}

现在,使用ContextIdFactory类的getByRequest()方法根据请求对象创建上下文标识符, 并将其传递给resolve()调用:

const contextId = ContextIdFactory.getByRequest(this.request);
const catsRepository = await this.moduleRef.resolve(CatsRepository, contextId);

动态实例化自定义类

要动态实例化之前未注册为提供程序的类,请使用模块引用的create()方法。

@Injectable()
export class CatsService implements OnModuleInit {
private catsFactory: CatsFactory;
constructor(private moduleRef: ModuleRef) {}

async onModuleInit() {
this.catsFactory = await this.moduleRef.create(CatsFactory);
}
}

这种技术允许您在框架容器之外有条件地实例化不同的类。

懒加载模块

默认情况下,模块是急切加载的,这意味着一旦应用程序加载,所有模块都会加载,无论它们是否立即需要。 虽然这对大多数应用程序来说没问题,但对于在无服务器环境中运行的应用程序/工作程序, 启动延迟(“冷启动”)至关重要时,这可能成为一个瓶颈。

懒加载可以通过仅加载特定无服务器函数调用所需的模块来帮助减少引导时间。 此外,您还可以在服务器函数“热”后异步加载其他模块,以进一步加快后续调用的引导时间(延迟模块注册)。

tip

如果您熟悉Angular框架,您可能之前见过“懒加载模块”的术语。 请注意,这个技术在Nest中的功能与之前不同,因此请将其视为一种完全不同的功能,尽管它共享相似的命名约定。

warning

请注意,懒加载模块和服务不会调用生命周期钩子方法。

入门

为了按需加载模块,Nest提供了可以以正常方式注入到类中的LazyModuleLoader类:

cats.service.ts
@Injectable()
export class CatsService {
constructor(private lazyModuleLoader: LazyModuleLoader) {}
}
tip

LazyModuleLoader类是从@nestjs/core包中导入的。

或者,您还可以从应用程序引导文件(main.ts)中获取对LazyModuleLoader提供程序的引用, 如下所示:

// "app"表示Nest应用程序实例
const lazyModuleLoader = app.get(LazyModuleLoader);

有了这个设置,您现在可以使用以下结构加载任何模块:

const { LazyModule } = await import('./lazy.module');
const moduleRef = await this.lazyModuleLoader.load(() => LazyModule);
tip

“懒加载”模块在第一次调用LazyModuleLoader#load方法时被缓存。 这意味着每次尝试加载LazyModule时都会非常快,会返回一个缓存的实例,而不是再次加载模块。

Load "LazyModule" attempt: 1
time: 2.379ms
Load "LazyModule" attempt: 2
time: 0.294ms
Load "LazyModule" attempt: 3
time: 0.303ms

此外,“懒加载”模块与在应用程序引导时急切加载的模块以及稍后在应用程序中注册的任何其他懒加载模块共享相同的模块图。

其中lazy.module.ts是一个导出普通Nest模块的TypeScript文件(不需要额外更改)。

LazyModuleLoader#load方法返回模块引用LazyModule的引用), 让您浏览内部提供程序列表,并使用其注入令牌作为查找键获取对任何提供程序的引用。

例如,假设我们有一个LazyModule,其定义如下:

@Module({
providers: [LazyService],
exports: [LazyService],
})
export class LazyModule {}
tip

懒加载模块不能注册为全局模块,因为这根本没有意义(因为它们是在需要时懒惰注册的,当所有静态注册的模块已经实例化时)。 同样,注册的全局增强器(守卫/拦截器等)也将无法正常工作。

这样,我们就可以获得LazyService提供者的引用,如下所示:

const { LazyModule } = await import('./lazy.module');
const moduleRef = await this.lazyModuleLoader.load(() => LazyModule);

const { LazyService } = await import('./lazy.service');
const lazyService = moduleRef.get(LazyService);
warning

如果您使用Webpack,请确保更新您的tsconfig.json文件, 将compilerOptions.module设置为esnext并添加以node为值的 compilerOptions.moduleResolution属性。

{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "node",
...
}
}

设置这些选项后,您将能够利用代码分割功能。

懒加载控制器、网关和解析器

由于在Nest中,控制器(或在GraphQL应用程序中的解析器)表示一组路由/路径/主题(或查询/变更), 您不能使用LazyModuleLoader类对它们进行懒加载。

warning

在懒加载模块中注册的控制器、解析器和网关将不会按预期行为。 同样,您不能按需注册中间件函数(通过实现MiddlewareConsumer接口)。

例如,假设您正在使用Fastify驱动程序(使用@nestjs/platform-fastify包)在底层构建REST API(HTTP应用程序)。 Fastify不允许在应用程序准备就绪/成功监听消息后注册路由。 这意味着即使我们分析了模块控制器中注册的路由映射,由于没有办法在运行时注册它们,所有懒加载的路由都将无法访问。

同样,我们作为@nestjs/microservices包的一部分提供的一些传输策略 (包括Kafka、gRPC或RabbitMQ)要求在建立连接之前订阅/监听特定的主题/通道。 一旦您的应用程序开始监听消息,框架将无法订阅/监听新主题。

最后,@nestjs/graphql包启用代码优先方法,会根据元数据动态生成GraphQL模式。 这意味着它需要预先加载所有类。否则,将无法创建适当的有效模式。

常见用例

在大多数情况下,您会在以下情况下看到懒加载模块: 当您的工作程序/定时作业/lambda和无服务器函数/webhook必须基于输入参数(路由路径/日期/查询参数等)触发不同服务(不同逻辑)时。 另一方面,在单体应用程序中,启动时间不太重要时,懒加载模块可能没有太多意义。

执行上下文

Nest提供了几个实用类,帮助轻松编写可以跨多个应用程序上下文 (例如,基于Nest HTTP服务器、微服务和WebSockets应用程序上下文)运行的应用程序。 这些实用程序提供有关当前执行上下文的信息,可用于构建通用的守卫过滤器拦截器, 可以在广泛的控制器、方法和执行上下文中工作。

在本章中,我们涵盖了两个这样的类:ArgumentsHostExecutionContext

ArgumentsHost类

ArgumentsHost类提供了检索传递给处理程序的参数的方法。 它允许选择适当的上下文(例如,HTTP、RPC(微服务)或WebSockets)来从中检索参数。 框架在需要访问ArgumentsHost实例的地方通常提供ArgumentsHost实例,通常被引用为host参数。 例如,异常过滤器catch()方法就是使用ArgumentsHost实例调用的。

ArgumentsHost简单地充当处理程序参数的抽象。 例如,对于HTTP服务器应用程序(当使用@nestjs/platform-express时), host对象封装了Express的[request、response、next]数组,其中request是请求对象, response是响应对象,而next是一个控制应用程序请求-响应循环的函数。 另一方面,对于GraphQL应用程序,host对象包含[root、args、context、info]数组。

当前应用程序上下文

在构建通用的守卫、过滤器和拦截器时,这些组件旨在在多个应用程序上下文中运行, 我们需要一种方法来确定我们的方法当前正在运行的应用程序类型。 使用ArgumentsHostgetType()方法来实现:

if (host.getType() === 'http') {
// 在常规HTTP请求(REST)上下文中执行的特定操作
} else if (host.getType() === 'rpc') {
// 在微服务请求上下文中执行的特定操作
} else if (host.getType<GqlContextType>() === 'graphql') {
// 在GraphQL请求上下文中执行的特定操作
}
tip

GqlContextType是从@nestjs/graphql包中导入的。

有了应用程序类型,我们可以编写更通用的组件,如下所示。

处理程序参数

为了检索传递给处理程序的参数数组,一种方法是使用host对象的getArgs()方法。

const [req, res, next] = host.getArgs();

您可以使用getArgByIndex()方法按索引提取特定参数。

const request = host.getArgByIndex(0);
const response = host.getArgByIndex(1);

在这些示例中,我们通过索引检索了请求和响应对象,这通常不建议, 因为它将应用程序与特定的执行上下文耦合在一起。 相反,您可以通过使用host对象的实用方法之一,在应用程序的适当应用程序上下文中切换, 使您的代码更健壮和可重用。以下是上下文切换实用方法。

切换上下文到RPC
switchToRpc(): RpcArgumentsHost;

切换上下文到HTTP
switchToHttp(): HttpArgumentsHost;

切换上下文到WebSockets。
switchToWs(): WsArgumentsHost;

让我们使用switchToHttp()方法重新编写前面的示例。 host.switchToHttp()帮助方法调用返回一个适用于HTTP应用程序上下文的HttpArgumentsHost对象。 HttpArgumentsHost对象有两个有用的方法,我们可以使用这两个方法提取所需的对象。 在这种情况下,我们还使用了Express类型断言来返回本机Express类型化的对象。

const ctx = host.switchToHttp();
const request = ctx.getRequest<Request>();
const response = ctx.getResponse<Response>();

同样,WsArgumentsHostRpcArgumentsHost有方法以在微服务和WebSockets上下文中返回适当的对象。 以下是WsArgumentsHost的方法:

export interface WsArgumentsHost {
/**
* 返回数据对象。
*/
getData<T>(): T;
/**
* 返回客户端对象。
*/
getClient<T>(): T;
}

以下是RpcArgumentsHost的方法:

export interface RpcArgumentsHost {
/**
* 返回数据对象。
*/
getData<T>(): T;

/**
* 返回上下文对象。
*/
getContext<T>(): T;
}

ExecutionContext类

ExecutionContext扩展了ArgumentsHost,提供有关当前执行过程的额外详细信息。 与ArgumentsHost一样,Nest在可能需要的地方提供了ExecutionContext的实例, 例如在守卫的canActivate()方法和拦截器的intercept()方法中。它提供了以下方法:

export interface ExecutionContext extends ArgumentsHost {
/**
* 返回当前处理程序所属的控制器类的类型。
*/
getClass<T>(): Type<T>;
/**
* 返回对将在请求管道中接下来调用的处理程序(方法)的引用。
*/
getHandler(): Function;
}

getHandler()方法返回一个对即将被调用的处理程序的引用。 getClass()方法返回此特定处理程序所属的Controller类的类型。 例如,在HTTP上下文中,如果当前处理的请求是一个绑定到CatsControllercreate()方法的POST请求, getHandler()返回对create()方法的引用, 而getClass()返回CatsController类型(而不是实例)。

const methodKey = ctx.getHandler().name; // "create"
const className = ctx.getClass().name; // "CatsController"

能够访问当前类和处理程序方法的引用提供了很大的灵活性。 最重要的是,它使我们有机会通过Reflector#createDecorator创建的装饰器或 在守卫或拦截器中内置的@SetMetadata()装饰器设置的元数据。 我们在下面涵盖这个用例。

反射和元数据

Nest提供了通过Reflector#createDecorator方法和 内置的@SetMetadata()装饰器将自定义元数据附加到路由处理程序的能力。 在本节中,让我们比较这两种方法,并看看如何在守卫或拦截器中访问元数据。

要使用Reflector#createDecorator创建强类型的装饰器,我们需要指定类型参数。 例如,让我们创建一个Roles装饰器,它以字符串数组作为参数。

import { Reflector } from '@nestjs/core';

export const Roles = Reflector.createDecorator<string[]>();

这里的Roles装饰器是一个函数,它接受一个类型为string[]的单一参数。

现在,要使用这个装饰器,我们只需在处理程序上注释它:

@Post()
@Roles(['admin'])
async create(@Body() createCatDto: CreateCatDto) {
this.catsService.create(createCatDto);
}

在这里,我们将Roles装饰器元数据附加到create()方法, 表示只有具有admin角色的用户才能访问此路由。

要访问路由的角色(自定义元数据),我们将再次使用Reflector助手类。 可以正常方式将Reflector注入到类中:

@Injectable()
export class RolesGuard {
constructor(private reflector: Reflector) {}
}
tip

Reflector类是从@nestjs/core包中导入的。

现在,要读取处理程序元数据,使用get()方法:

const roles = this.reflector.get(Roles, context.getHandler());

Reflector#get方法允许我们通过传递两个参数轻松访问元数据: 装饰器引用和上下文(装饰器目标),以从中检索元数据。 在这个例子中,指定的装饰器是Roles(参见上面的roles.decorator.ts文件)。 上下文由调用context.getHandler()提供,这导致提取当前处理的路由处理程序的元数据。 请记住,getHandler()给我们提供了对路由处理程序函数的引用。

或者,我们可以通过在控制器级别应用元数据来组织我们的控制器,应用于控制器类中的所有路由。

@Roles(['admin'])
@Controller('cats')
export class CatsController {}

在这种情况下,为了提取控制器元数据, 我们将context.getClass()作为第二个参数传递(以提供控制器类作为元数据提取的上下文), 而不是context.getHandler()

const roles = this.reflector.get(Roles, context.getClass());

由于可以在多个级别提供元数据,您可能需要从几个上下文中提取并合并元数据。 Reflector类提供了两个用于帮助处理的实用方法。 这些方法一次性提取控制器和方法元数据,并以不同的方式组合它们。

考虑以下场景,您在两个级别都提供了Roles元数据。

@Roles(['user'])
@Controller('cats')
export class CatsController {
@Post()
@Roles(['admin'])
async create(@Body() createCatDto: CreateCatDto) {
this.catsService.create(createCatDto);
}
}

如果您的目的是将'user'指定为默认角色,并有选择地在某些方法中覆盖它, 您可能会使用getAllAndOverride()方法。

const roles = this.reflector.getAllAndOverride(Roles, [context.getHandler(), context.getClass()]);

带有上述元数据的create()方法上下文中运行的带有此代码的守卫将导致roles包含['admin']

要获取两者的元数据并合并它(此方法合并数组和对象),请使用getAllAndMerge()方法:

const roles = this.reflector.getAllAndMerge(Roles, [context.getHandler(), context.getClass()]);

这将导致roles包含['user', 'admin']

对于这两种合并方法,您将元数据键作为第一个参数传递, 将元数据目标上下文数组(即,对getHandler()和/或getClass()方法的调用)作为第二个参数。

低级方法

如前所述,您可以使用内置的@SetMetadata()装饰器而不是使用Reflector#createDecorator来附加元数据到处理程序。

@Post()
@SetMetadata('roles', ['admin'])
async create(@Body() createCatDto: CreateCatDto) {
this.catsService.create(createCatDto);
}
tip

@SetMetadata()装饰器是从@nestjs/common包中导入的。

通过上述构造,我们将roles元数据(roles是元数据键,['admin']是关联值)附加到create()方法。 虽然这样可以工作,但直接在路由中使用@SetMetadata()不是一个好的做法。 相反,您可以创建自己的装饰器,如下所示:

import { SetMetadata } from '@nestjs/common';

export const Roles = (...roles: string[]) => SetMetadata('roles', roles);

这种方法更清晰、更可读,并且在某种程度上类似于Reflector#createDecorator方法。 区别在于,使用@SetMetadata,您可以更好地控制元数据键和值,并且还可以创建接受多个参数的装饰器。

现在我们有了一个自定义的@Roles()装饰器,我们可以用它来装饰create()方法。

@Post()
@Roles('admin')
async create(@Body() createCatDto: CreateCatDto) {
this.catsService.create(createCatDto);
}

要访问路由的角色(自定义元数据),我们将再次使用Reflector助手类:

@Injectable()
export class RolesGuard {
constructor(private reflector: Reflector) {}
}
tip

Reflector类是从@nestjs/core包中导入的。

现在,要读取处理程序元数据,使用get()方法。

const roles = this.reflector.get<string[]>('roles', context.getHandler());

在这里,我们传递的是元数据键作为第一个参数(在我们的例子中是'roles'), 而其他的都与Reflector#createDecorator示例中一样。

生命周期事件

Nest应用程序以及每个应用程序元素都由Nest管理的生命周期。 Nest提供了生命周期钩子,可以在关键的生命周期事件发生时提供可见性, 并在这些事件发生时执行注册的代码(在模块、提供程序或控制器上运行)。

生命周期序列

以下图表描述了关键应用程序生命周期事件的序列,从应用程序启动到节点进程退出的过程。 我们可以将整个生命周期分为三个阶段:初始化、运行和终止。 利用这个生命周期,您可以计划模块和服务的适当初始化,管理活动连接, 并在接收到终止信号时优雅地关闭您的应用程序。

生命周期事件#

生命周期事件发生在应用程序启动和关闭期间。 Nest在每个以下生命周期事件上调用模块、提供程序和控制器上注册的生命周期钩子方法(首先需要启用关闭钩子,如下所述)。 如上图所示,Nest还调用适当的底层方法来开始监听连接和停止监听连接。

在以下表格中,只有在显式调用app.close()或进程接收到特殊的系统信号 (例如SIGTERM)且您在应用程序启动时正确调用了enableShutdownHooks (见下文的应用程序关闭部分)时,才会触发onModuleDestroybeforeApplicationShutdownonApplicationShutdown

生命周期钩子方法触发钩子方法调用的生命周期事件
onModuleInit()在主模块的依赖项已解析后调用一次。
onApplicationBootstrap()在所有模块初始化完成后调用,但在开始监听连接之前。
onModuleDestroy()*在接收到终止信号(例如,SIGTERM)后调用。
beforeApplicationShutdown()*在所有onModuleDestroy()处理程序完成后调用(Promises已解析或拒绝);一旦完成(Promises已解析或拒绝),将关闭所有现有连接(调用app.close())。
onApplicationShutdown()*在连接关闭后调用(app.close()解析完成)。
  • 对于这些事件,如果您没有显式调用app.close(),则必须选择让它们与SIGTERM等系统信号一起使用。 请参阅下面的应用程序关闭
warning

上述列出的生命周期钩子对于请求范围的类不会触发。请求范围的类与应用程序生命周期无关,其生命周期是不可预测的。 它们专门为每个请求创建,并在响应发送后自动进行垃圾回收。

tip

onModuleInit()onApplicationBootstrap()的执行顺序直接取决于模块导入的顺序,等待前一个钩子。

使用

每个生命周期钩子都由一个接口表示。 接口在技术上是可选的,因为它们在TypeScript编译后不存在。 尽管如此,最好的实践是使用它们以便从强类型和编辑器工具中受益。 要注册生命周期钩子,请实现适当的接口。 例如,要在特定类(例如ControllerProviderModule)上注册在模块初始化期间调用的方法, 实现OnModuleInit接口,并提供一个onModuleInit()方法, 如下所示:

import { Injectable, OnModuleInit } from '@nestjs/common';

@Injectable()
export class UsersService implements OnModuleInit {
onModuleInit() {
console.log(`The module has been initialized.`);
}
}

异步初始化

OnModuleInitOnApplicationBootstrap钩子都允许您推迟应用程序初始化过程 (返回一个Promise或将方法标记为async并在方法体内await异步方法的完成)。

async onModuleInit(): Promise<void> {
await this.fetch();
}

应用程序关闭

onModuleDestroy()beforeApplicationShutdown()onApplicationShutdown()钩子 在终止阶段被调用(响应显式调用app.close()或者接收到系统信号如SIGTERM时,如果选择接收的话)。 这个特性经常与Kubernetes一起使用,用于管理容器的生命周期,或者在Heroku中用于dynos或类似的服务。

关闭钩子监听器会消耗系统资源,因此它们默认是禁用的。 要使用关闭钩子,必须通过调用enableShutdownHooks()来启用监听器:

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
const app = await NestFactory.create(AppModule);

// 启动监听关闭钩子
app.enableShutdownHooks();

await app.listen(3000);
}

bootstrap();
warning

由于固有的平台限制,NestJS在Windows上对应用程序关闭钩子的支持有限。 可以期望SIGINT工作,以及SIGBREAK并在某种程度上SIGHUP - 阅读更多。 然而,SIGTERM在Windows上永远不会工作,因为在任务管理器中终止进程是无条件的,“即,应用程序无法检测或阻止它”。 这里有一些来自libuv的相关文档,了解有关在Windows上如何处理SIGINTSIGBREAK等的更多信息。 此外,请查看Node.js关于进程信号事件的文档。

info

enableShutdownHooks通过启动监听器消耗内存。 在运行多个Nest应用程序的单个Node进程中(例如,在Jest中运行并行测试时),Node可能会抱怨有过多的监听器进程。 因此,默认情况下不启用enableShutdownHooks。在运行多个实例的单个Node进程中时,请注意此条件。

当应用程序接收到终止信号时,它将调用任何已注册的onModuleDestroy()beforeApplicationShutdown(), 然后调用onApplicationShutdown()方法(如上述顺序),并将相应的信号作为第一个参数。 如果已注册的函数等待异步调用(返回一个promise),Nest将在promise解析或拒绝之前不会继续顺序。

@Injectable()
class UsersService implements OnApplicationShutdown {
onApplicationShutdown(signal: string) {
console.log(signal); // 例如 "SIGINT"
}
}
info

调用app.close()不会终止Node进程,而只会触发onModuleDestroy()onApplicationShutdown()钩子, 因此如果有一些间隔、长时间运行的后台任务等,进程不会被自动终止。

平台无关性

Nest是一个平台无关的框架。这意味着您可以开发可在不同类型的应用程序中重复使用的逻辑部分。 例如,大多数组件可以在不同的底层HTTP服务器框架(例如Express和Fastify)之间无需更改地重用, 甚至可以在不同类型的应用程序(例如HTTP服务器框架、具有不同传输层的微服务和WebSockets)之间重用。

构建一次,到处使用

文档的概述部分主要展示了使用HTTP服务器框架的编码技巧(例如,提供REST API或提供MVC样式的服务器端渲染应用程序)。 然而,所有这些构建块都可以在不同的传输层(微服务或WebSockets)之上使用。

此外,Nest提供了一个专用的GraphQL模块。您可以将GraphQL用作API层,与提供REST API等效地使用。

此外,应用程序上下文功能有助于在Nest之上创建任何类型的Node.js应用程序,包括CRON作业和CLI应用程序。

Nest的目标是成为Node.js应用程序的全功能平台,为您的应用程序提供更高级别的模块化和可重用性。构建一次,到处使用!

测试

自动化测试被视为任何严肃软件开发工作的重要组成部分。自动化使在开发过程中快速轻松地重复单个测试或测试套件成为可能。 这有助于确保发布达到质量和性能目标。自动化有助于提高测试覆盖率,并为开发人员提供更快的反馈循环。 自动化既提高了个人开发人员的生产力,又确保在关键的开发生命周期节点运行测试,例如源代码控制提交、功能集成和版本发布。

这些测试通常涵盖各种类型,包括单元测试、端到端(e2e)测试、集成测试等。 虽然利益是不可否认的,但设置它们可能会很繁琐。Nest致力于推广开发最佳实践, 包括有效的测试,因此它提供了以下功能,以帮助开发人员和团队构建和自动化测试。Nest:

  • 自动为组件生成默认的单元测试和应用程序的端到端测试
  • 提供默认工具(如构建孤立模块/应用程序加载器的测试运行器)
  • 提供与Jest和Supertest的开箱即用的集成,同时对测试工具保持中立
  • 在测试环境中使用Nest依赖注入系统,轻松模拟组件

正如前面提到的,您可以使用任何您喜欢的测试框架,因为Nest不强制使用特定的工具。 只需替换所需的元素(如测试运行器),您仍然可以享受Nest提供的现成测试设施的好处。

安装

要开始,请首先安装所需的包:

npm i --save-dev @nestjs/testing

单元测试

在以下示例中,我们测试两个类:CatsControllerCatsService。 如前所述,Jest 被提供作为默认的测试框架。 它充当测试运行器,并提供断言函数和测试替身工具,有助于进行模拟、间谍等操作。 在以下基本测试中,我们手动实例化这些类,并确保控制器和服务满足其 API 契约。

cats.controller.spec.ts
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

describe('CatsController', () => {
let catsController: CatsController;
let catsService: CatsService;

beforeEach(() => {
catsService = new CatsService();
catsController = new CatsController(catsService);
});

describe('findAll', () => {
it('should return an array of cats', async () => {
const result = ['test'];
jest.spyOn(catsService, 'findAll').mockImplementation(() => result);

expect(await catsController.findAll()).toBe(result);
});
});
});
tip

保持测试文件靠近它们测试的类。测试文件应该具有.spec.test后缀。

由于上面的示例很简单,我们实际上并没有测试任何与 Nest 相关的内容。 事实上,我们甚至没有使用依赖注入(请注意,我们将CatsService的实例传递给了我们的catsController)。 这种测试形式 - 在其中手动实例化要测试的类 - 通常称为孤立(isolated)测试,因为它独立于框架。 让我们引入一些更先进的功能,帮助您测试更广泛使用 Nest 特性的应用程序。

测试工具

@nestjs/testing 包提供了一组实用程序,支持更强大的测试过程。让我们使用内置的Test类重新编写前面的示例:

cats.controller.spec.ts
import { Test } from '@nestjs/testing';
import { CatsController } from './cats.controller';
import { CatsService } from './cats.service';

describe('CatsController', () => {
let catsController: CatsController;
let catsService: CatsService;

beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [CatsController],
providers: [CatsService],
}).compile();

catsService = moduleRef.get<CatsService>(CatsService);
catsController = moduleRef.get<CatsController>(CatsController);
});

describe('findAll', () => {
it('should return an array of cats', async () => {
const result = ['test'];
jest.spyOn(catsService, 'findAll').mockImplementation(() => result);

expect(await catsController.findAll()).toBe(result);
});
});
});

Test类对于提供一个模拟完整 Nest 运行时的应用程序执行上下文非常有用,但同时提供了一些钩子,使得很容易管理类实例,包括模拟和覆盖。 Test类有一个createTestingModule()方法,它以模块元数据对象作为其参数(与您传递给@Module()装饰器的对象相同)。 此方法返回一个TestingModule实例,它反过来提供了一些方法。 对于单元测试,其中一个重要的方法是compile()方法。 此方法使用其依赖项启动模块(类似于在传统的main.ts文件中使用NestFactory.create()启动应用程序), 并返回一个准备好进行测试的模块。

tip

compile()方法是异步的,因此必须等待。 一旦编译了模块,您就可以使用get()方法检索它声明的任何静态实例(控制器和提供者)。

TestingModule继承自模块引用类,因此具有动态解析作用域提供者(瞬态或请求作用域)的能力。 使用resolve()方法实现这一点(get() 方法只能检索静态实例)。

const moduleRef = await Test.createTestingModule({
controllers: [CatsController],
providers: [CatsService],
}).compile();

catsService = await moduleRef.resolve(CatsService);
warning

resolve()方法返回提供者的唯一实例,来自其自身的 DI 容器子树。 每个子树都有一个唯一的上下文标识符。因此,如果调用此方法多次并比较实例引用,则会发现它们不相等。

tip

这里了解更多关于模块引用功能的信息。

您可以使用自定义提供者覆盖任何提供者的生产版本,以进行测试。 例如,您可以模拟数据库服务,而不是连接到实际的数据库。我们将在下一节中介绍覆盖功能,但它们也适用于单元测试。

自动模拟

Nest 还允许您定义一个模拟工厂,应用于所有缺少的依赖项。 这对于类中有大量依赖项的情况很有用,因为模拟所有这些依赖项将花费很长时间并需要大量设置。 要使用此功能,createTestingModule()将需要链接useMocker()方法,传递一个用于依赖项模拟的工厂。 该工厂可以接受一个可选的令牌,该令牌是实例令牌,任何对 Nest 提供程序有效的令牌,并返回一个模拟实现。 下面是使用jest-mock创建通用mocker和使用jest.fn()创建CatsService的特定模拟的示例。

// ...
import { ModuleMocker, MockFunctionMetadata } from 'jest-mock';

const moduleMocker = new ModuleMocker(global);

describe('CatsController', () => {
let controller: CatsController;

beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [CatsController],
})
.useMocker((token) => {
const results = ['test1', 'test2'];
if (token === CatsService) {
return { findAll: jest.fn().mockResolvedValue(results) };
}
if (typeof token === 'function') {
const mockMetadata = moduleMocker.getMetadata(token) as MockFunctionMetadata<any, any>;
const Mock = moduleMocker.generateFromMetadata(mockMetadata);
return new Mock();
}
})
.compile();

controller = moduleRef.get(CatsController);
});
});

您还可以像通常使用自定义提供者一样从测试容器中检索这些模拟,使moduleRef.get(CatsService)

tip

通用模拟工厂,比如@golevelup/ts-jestcreateMock,也可以直接传递。

tip

REQUESTINQUIRER提供程序无法自动模拟,因为它们已经在上下文中预定义。 但是,它们可以使用自定义提供者语法或通过使用.overrideProvider方法进行覆盖。

端到端测试

与侧重于单个模块和类的单元测试不同,端到端(e2e)测试涵盖了更粗粒度的类和模块的交互 - 更接近最终用户与生产系统交互的方式。 随着应用程序的增长,手动测试每个 API 端点的端到端行为变得困难。 自动化的端到端测试帮助我们确保系统的整体行为是正确的,并满足项目要求。 为了执行 e2e 测试,我们使用与我们刚刚在单元测试中介绍的相似的配置。 此外,Nest 使使用 Supertest 库模拟 HTTP 请求变得轻松。

cats.e2e-spec.ts
import * as request from 'supertest';
import { Test } from '@nestjs/testing';
import { CatsModule } from '../../src/cats/cats.module';
import { CatsService } from '../../src/cats/cats.service';
import { INestApplication } from '@nestjs/common';

describe('Cats', () => {
let app: INestApplication;
let catsService = { findAll: () => ['test'] };

beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [CatsModule],
})
.overrideProvider(CatsService)
.useValue(catsService)
.compile();

app = moduleRef.createNestApplication();
await app.init();
});

it(`/GET cats`, () => {
return request(app.getHttpServer())
.get('/cats')
.expect(200)
.expect({
data: catsService.findAll(),
});
});

afterAll(async () => {
await app.close();
});
});
tip

如果您将 Fastify 作为 HTTP 适配器,则需要稍微不同的配置,并且内置了测试功能:

let app: NestFastifyApplication;

beforeAll(async () => {
app = moduleRef.createNestApplication<NestFastifyApplication>(new FastifyAdapter());

await app.init();
await app.getHttpAdapter().getInstance().ready();
});

it(`/GET cats`, () => {
return app
.inject({
method:

'GET',
url: '/cats',
})
.then((result) => {
expect(result.statusCode).toEqual(200);
expect(result.payload).toEqual(/* expectedPayload */);
});
});

afterAll(async () => {
await app.close();
});

在这个例子中,我们构建在先前描述的一些概念之上。除了我们之前使用的compile()方法之外, 现在我们使用createNestApplication()方法来实例化完整的 Nest 运行时环境。 我们将正在运行的应用程序的引用保存在我们的app变量中,以便我们可以使用它来模拟 HTTP 请求。

我们使用 Supertest 的request()函数模拟 HTTP 测试。 我们希望这些 HTTP 请求路由到我们运行的 Nest 应用程序, 因此我们将request()函数传递给 Nest 底层的 HTTP 监听器的引用(反过来可能由 Express 平台提供)。 因此构建request(app.getHttpServer())。调用request()会将包装后的 HTTP 服务器返回给我们, 现在连接到 Nest 应用程序,它公开了模拟实际 HTTP 请求的方法。 例如,使用request(...).get('/cats')将启动到 Nest 应用程序的请求,该请求与通过网络进入的实际 HTTP 请求相同。

在这个例子中,我们还提供了CatsService的另一种(测试替身)实现,它只返回我们可以进行测试的硬编码值。 使用overrideProvider()提供这样的替代实现。 同样,Nest 提供了使用overrideModule()overrideGuard()overrideInterceptor()overrideFilter()overridePipe()方法分别覆盖模块、守卫、拦截器、过滤器和管道的方法。

每个override(除了overrideModule())方法类型都返回一个具有 3 种不同方法的对象, 这些方法与上述用于自定义提供者的方法相同:

  • useClass:您提供一个类,该类将被实例化以提供要覆盖的对象的实例(提供程序、守卫等)。
  • useValue:您提供一个实例,该实例将覆盖对象。
  • useFactory:您提供一个返回要覆盖对象的实例的函数。

另一方面,overrideModule()返回一个带有useModule()方法的对象,您可以使用它提供要覆盖原始模块的模块, 如下所示:

const moduleRef = await Test.createTestingModule({
imports: [AppModule],
})
.overrideModule(CatsModule)
.useModule(AlternateCatsModule)
.compile();

每个override方法类型反过来都返回TestingModule实例,因此可以与流畅样式中的其他方法链接在一起。 在这样的链的末尾应该使用compile(),以使 Nest 实例化和初始化模块。

此外,有时您可能想要在测试运行时(例如,在 CI 服务器上)提供自定义的记录器。 使用setLogger()方法并传递实现LoggerService接口的对象, 以指示TestModuleBuilder 在测试期间如何记录(默认情况下,仅将 "error" 日志记录到控制台)。

编译后的模块有一些有用的方法,如下表所述:

createNestApplication()根据给定的模块创建并返回 Nest 应用程序(INestApplication实例)。请注意,您必须手动使用init()方法初始化应用程序。
createNestMicroservice()根据给定的模块创建并返回 Nest 微服务(INestMicroservice实例)。
get()检索应用程序上下文中可用的控制器或提供者(包括守卫、过滤器等)的静态实例。从模块引用类继承。
resolve()检索应用程序上下文中可用的控制器或提供者(包括守卫、过滤器等)的动态创建的作用域实例(请求或瞬态)。从模块引用类继承。
select()导航模块的依赖关系图;可用于从所选模块中检索特定实例(与 get() 方法一起使用 strict 模式(strict: true))。
tip

将您的 e2e 测试文件放在test目录中。测试文件应具有.e2e-spec后缀。

覆盖全局注册的增强器

如果您有一个全局注册的守卫(或管道、拦截器或过滤器),则需要采取更多步骤来覆盖该增强器。 首先,回顾一下原始的注册如下:

providers: [
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],

这是通过APP_*令牌将守卫注册为“multi”提供程序。为了能够替换这里的JwtAuthGuard,注册需要使用此槽位中的现有提供程序:

providers: [
{
provide: APP_GUARD,
useExisting: JwtAuthGuard,
// ^^^^^^^^ 注意使用 'useExisting' 而不是 'useClass'
},
JwtAuthGuard,
],
tip

useClass更改为useExisting以引用已注册的提供程序,而不是让 Nest 在令牌后面实例化它。

现在,JwtAuthGuard在 Nest 中可见,可以在创建TestingModule时覆盖它:

const moduleRef = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(JwtAuthGuard)
.useClass(MockAuthGuard)
.compile();

现在,所有的测试都将在每个请求上使用MockAuthGuard

测试请求作用域的实例

为每个传入请求唯一创建的请求作用域提供程序。该实例在请求完成处理后被垃圾回收。 这带来了一个问题,因为我们无法访问为测试请求生成的依赖注入子树。

我们知道(基于上面的章节)resolve() 方法可用于检索动态实例化的类。 此外,正如在此处所述,我们知道我们可以传递一个唯一的上下文标识符来控制为测试请求创建 DI 容器子树的生命周期。 我们如何在测试上下文中利用这一点?

策略是预先生成一个上下文标识符,并强制 Nest 使用这个特定 ID 来为所有传入请求创建子树。 通过这种方式,我们将能够检索为测试请求创建的实例。

为此,请对ContextIdFactory使用jest.spyOn()

const contextId = ContextIdFactory.create();
jest.spyOn(ContextIdFactory, 'getByRequest').mockImplementation(() => contextId);

现在,我们可以使用contextId来访问任何后续请求的单个生成的 DI 容器子树。

catsService = await moduleRef.resolve(CatsService, contextId);