自定义provider
DI
依赖注入是一种控制反转(IoC) 技术,其中将依赖的实例化委托给 IoC 容器(在例子中是 NestJS 运行时系统),而不是在自己的代码中创建实例,将实例化的操作交给容器执行
定义一个 provider。 @Injectable() 装饰器将 CatsService 类标记为 provider
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 将 provider 注入到 controller
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 容器注册 provider
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 {}
整个过程分为三个关键步骤:
cats.service.ts中, @Injectable() 装饰器将 CatsService 类声明为可以被 Nest IoC 容器管理的类- 在
cats.controller.ts中,CatsController 通过构造函数注入声明了对 CatsService 标记的依赖:constructor(private catsService: CatsService) - 在
app.module.ts中,将标记 CatsService 与cats.service.ts文件中的类 CatsService 相关联
当 Nest IoC 容器实例化 CatsController 时,它首先查找是否有依赖。当它找到 CatsService 依赖时,它会根据注册步骤的第三步对 CatsService 标记执行查找,返回 CatsService 类。假设当前是设置为 SINGLETON (单例) 作用域(默认),Nest 将创建 CatsService 的实例,缓存它并返回它,或者如果已经缓存了一个实例,则返回现有实例
*上述的步骤有点简化了,上述步骤忽略的一个重要过程就是分析代码依赖的过程非常复杂,并且发生在应用引导期间。一个关键特性是依赖分析(或 "创建依赖图")是可传递的。在上面的示例中,如果 CatsService 本身有依赖,那么这些依赖也会被解析。依赖图确保依赖以正确的顺序解决 - 本质上是 自底而上。这种机制使开发人员不必管理如此复杂的依赖图
标准的providers
看看 @Module() 装饰器。在 app.module 中声明:
@Module({
controllers: [CatsController],
providers: [CatsService],
})
providers 属性采用 providers 数组。到目前为止,已经通过类名列表提供了这些 provider。事实上,语法providers: [CatsService] 是语法的缩写,完整的写法应该是这样的:
providers: [
{
provide: CatsService,
useClass: CatsService,
},
];
通过完整的写法看到了这个数组中每一个对象的显式的构造,就可以理解注册过程了。在这里,可以看到清楚地将标记(useClass) CatsService 与 CatsService 类关联起来。简写表示法只是为了简化最常见的用例,标记用于指向具有相同名称的类的实例
自定义provider
要求:
- 想要创建一个自定义实例而不是让 Nest 实例化(或返回一个类的缓存实例)
- 想在第二个依赖中重用现有的类
- 想用模拟版本覆盖一个类以进行测试
Nest 允许定义自定义 provider 来处理这些情况。它提供了几种定义自定义 provider 的方法
提示:如果遇到依赖解析问题,可以设置 NEST_DEBUG 环境变量并在启动期间获取额外的依赖解析日志
提供值:useValue
useValue 语法适用于注入常量值、将外部库放入 Nest 容器或用模拟对象替换实际的 provider(实际生成的 provider)。假设现在想强制 Nest 使用模拟 CatsService 进行测试:
import { CatsService } from './cats.service';
const mockCatsService = {
/* mock implementation
...
*/
};
@Module({
imports: [CatsModule],
providers: [
{
provide: CatsService,
// 将对应的 CatsService 替换为了这里生成的 mockCatsService
// 外部调用 CatsService 实际上调用的是 mockCatsService
useValue: mockCatsService,
},
],
})
export class AppModule {}
在此示例中,CatsService 标记将解析为 mockCatsService 模拟对象。useValue 需要一个值 - 在本例中,是一个与其要替换的 CatsService 类具有相同接口的字面量对象。由于 TypeScript 的 结构类型,可以使用任何具有兼容接口的对象,包括字面量对象或使用 new 实例化的类实例
假设想要注入通用值:
@Module({
controllers: [CatsController],
providers: [
CatsService,
{
provide: 'catName', // 想要往这个 module 中所有的模块注入一个值
useValue: 'Tom', // 值的实际内容
}
],
})
export class CatsModule implements NestModule {}
@Controller('cats')
export class CatsController {
constructor(
private readonly catsService: CatsService,
@Inject('catName') private catName: string, // 注入在 module 中定义的值
) {}
@Post()
create(@Body() createCatDto: CreateCatDto) {
return this.catsService.create(createCatDto);
}
@Get()
findAll() {
console.log(this.catName); // 这里可以获取到注入的值
return this.catsService.findAll();
}
}
非类的 provider 标记(别名)
除了用类作为标记以外,还可以灵活地使用字符串或符号作为 DI(依赖注入)的标记。例如:
import { connection } from './connection';
@Module({
providers: [
{
provide: 'CONNECTION',
useValue: connection,
},
],
})
export class AppModule {}
此示例中,将字符串值标记 ('CONNECTION')与从外部文件导入的预先存在的 connection 对象相关联
注意:除了使用字符串作为标记值之外,还可以使用 JavaScript symbols 或 TypeScript enums
注入这种类型的 provider:
@Injectable()
export class CatsRepository {
constructor(@Inject('CONNECTION') connection: Connection) {}
}
提供类: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 将注入所提供类(DevelopmentConfigService 或 ProductionConfigService)的实例,以覆盖可能已在其他地方声明的任何默认实现(例如,使用 @Injectable() 装饰器声明的 ConfigService)
- 当注入了 ConfigService 时,会根据当前的环境变量来确定使用哪个实例,如果是 development,则会使用DevelopmentConfigService 的实例,否则使用 ProductionConfigService 的实例
提供工厂:useFactory
useFactory 语法允许动态创建 providers 。实际上 provider 将由工厂方法返回的值提供。工厂功能可以根据需要简单或复杂。一个简单的工厂可能不依赖于任何其他 provider;更复杂的工厂本身可以注入计算结果给其他的 provider。对于后面更复杂的工厂,工厂 provider 语法有一对相关的机制:
- 工厂方法可以接受(可选)参数
- (可选的)inject 属性接受一组 provider,Nest 将在实例化过程中解析这些 provider 并将其作为参数传递给工厂方法。此外,这些 provider 可以标记为可选。这两个列表应该是相关的: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 }],
// _____________/ __________________/
// 这个 provider 是强制的 这个 token 的provider 可以解析为'undefined'
};
@Module({
providers: [
connectionProvider,
OptionsProvider,
// { provide: 'SomeOptionalProvider', useValue: 'anything' },
],
})
export class AppModule {}
示例:
@Module({
imports: [CatsModule, DogsModule],
controllers: [AppController],
providers: [
AppService,
CatsService, // 引入 CatsService 标记,在工厂方法中需要用到
DogsService, // 引入 DogsService 标记,在工厂方法中需要用到
{
provide: 'testFactory',
useFactory(...args) {
const cats: CatsService = args[0]; // 获取工厂方法中的参数
const dogs: DogsService = args[1]; // 获取工厂方法中的参数
console.log(cats, dogs);
return new DogsService(new CatsService()); // 返回一个工厂加工后的实例
},
inject: [CatsService, DogsService], // 往工厂方法中传入参数
},
],
})
export class AppModule {}
由于初始化 DogsService 时候需要用到 CatsService,所以需要在 Dogs 的 module 中引入 CatsService 标记
@Module({
controllers: [DogsController],
providers: [DogsService, CatsService], // 引入 CatsService 标记
exports: [DogsService]
})
export class DogsModule {}
@Injectable()
export class DogsService {
constructor(private cats: CatsService) {}
helloDog(): string {
console.log(this.cats.helloCat()); // 调用测试
return 'Hello Dog!';
}
}
定义 CatsService 组件
@Injectable()
export class CatsService {
helloCat(): string {
return 'I am cat';
}
}
在 app.controller 中测试
@Controller('testapp')
export class AppController {
constructor(
private readonly appService: AppService,
@Inject('testFactory') private readonly dogs: DogsService,
) {}
@Get()
getHello(): string {
console.log(this.dogs.helloDog());
return this.appService.getHello();
}
}
结果:
提供别名:useExisting
useExisting 语法允许为现有 provider 创建别名。这创建了两种访问同一个 provider 的方法。在下面的示例中,(基于字符串的)标记 AliasedLoggerService 是(基于类的)标记 LoggerService 的别名。假设有两个不同的依赖,一个用于 AliasedLoggerService,一个用于 LoggerService。如果两个依赖都指定了 SINGLETON(单例) 作用域,它们将解析为同一个实例:
@Injectable()
class LoggerService {
/* implementation details */
}
const loggerAliasProvider = {
provide: 'AliasedLoggerService',
useExisting: LoggerService,
};
@Module({
providers: [LoggerService, loggerAliasProvider],
})
export class AppModule {}
提供值的provider
虽然 provider 经常提供服务,但他们并不仅限于这种用途。 provider 可以提供任何值。例如,provider 可以根据当前环境提供一组配置对象,如下所示:
const configFactory = {
provide: 'CONFIG',
useFactory: () => {
return process.env.NODE_ENV === 'development' ? devConfig : prodConfig;
},
};
@Module({
providers: [configFactory],
})
export class AppModule {}
导出自定义provider
与任何 provider 一样,自定义 provider 的作用域仅限于其声明 module 。要使其对其他 module 可见,必须将其导出。要导出自定义provider,可以使用其标记或完整的 provider 对象
以下示例显示使用标记导出:
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 {}
或者,使用完整的 provider 对象导出:
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 {}
异步provider(asyncProvider)
有时,应用启动应延迟,直到完成一项或多项异步任务。例如,可能不想在与数据库建立连接之前开始接受请求,就可以使用异步provider实现此目的
语法是将 async/await 与 useFactory 语法一起使用。工厂返回一个 Promise,工厂方法可以 await(等待) 异步任务。在实例化任何依赖(注入)此类provider的类之前,Nest 将等待 promise 的解决
{
provide: 'ASYNC_CONNECTION',
useFactory: async () => {
const connection = await createConnection(options);
return connection;
},
}
注入: 与任何其他提供程序一样,异步 provider 程序通过其标记注入其他组件
动态module(DynamicModule)
静态module
前面的概述部分中的大多数应用代码示例都使用常规或静态module,module 定义了像 providers 和 controllers 这样的组件组,它们组合在一起作为整个应用的模块化部分。它们为这些组件提供执行上下文或作用域。例如,module 中定义的 provider 对 module 的其他成员可见,而无需导出它们。当 provider 需要在 module 外部可见时,它首先从其宿主 module 中导出,然后导入到其其他 module 中
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
@Module({
providers: [UsersService],
exports: [UsersService],
})
export class UsersModule {}
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 注入到 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
*/
}
将其称为静态module绑定
动态module
通过静态绑定的 module,没有办法影响主模块中 provider 的配置方式。这很重要,考虑到会有一个通用 module 需要在不同用例中表现不同的情况。这类似于许多系统中的plugin(配置) 概念,其中通用设施需要一些配置才能被其他组件使用
Nest 的一个很好的例子是配置模块(Config module) 。使用 Config module 来外部化配置细节很有用。这使得动态修改不同部署中的应用设置这个操作变得容易:例如,开发者的开发数据库,登台/测试环境的登台数据库等。通过将配置参数的管理委托给配置模块,应用代码保持独立于配置参数(代码运行与配置参数分离,例如要接入其他数据库时只需要更改配置文件的参数,而无需修改代码)
问题在于 Config module 本身,因为它是通用的(类似于plugin),需要由使用它的module来定制。这就是动态 module 发挥作用的地方。使用动态 module 功能,可以使配置模块动态化,以便 module 可以使用 API 来控制配置 module 在导入时的自定义方式
换句话说,动态 module 提供了一个 API,用于将一个 module 导入另一个 module,并在导入时自定义该 module 的属性和行为,而不是使用目前看到的静态绑定
配置模块示例
需求是让 ConfigModule 接受一个 options 对象来自定义。基本示例将 .env 文件的位置硬编码到项目根文件夹中。假设想让它可配置,这样就可以在选择的任何文件夹中管理 .env 文件。例如,假设想将各种 .env 文件存储在项目根目录下名为 config 的文件夹中(即 src 的同级文件夹),然后希望在不同的项目中使用 ConfigModule 时能够选择不同的文件夹
动态module 能够将参数传递到被导入的 module 中,这样就可以改变它的行为
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()的静态方法 register()方法由开发者定义,因此可以接受任何由开发者定义的输入参数。在这种情况下,建议将接受一个具有合适属性的简单 options 对象- 可以推断
register()方法必须返回类似 module 的内容,因为它的返回值出现在 imports 列表中,到目前为止已经看到该列表包含一个模块列表
@Module({
imports: [DogsModule],
controllers: [CatsController],
providers: [CatsService],
exports: [CatsService]
})
动态 module 必须返回一个具有完全相同接口的对象,外加一个名为 module 的附加属性。module 属性作为 module 的名称,应与 module 的类名相同
提示:对于动态模块, module 选项对象的所有属性都是可选的,除了 module
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],
};
}
}
提示:从 @nestjs/common 导入 DynamicModule
module 配置
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 中使用 options 对象?ConfigModule 基本上是一个提供和导出可注入服务的主体:ConfigService - 供其他 provider 使用。实际上是 ConfigService 需要读取 options 对象来自定义它的行为。先暂时假设知道如何以某种方式将 options 从 register() 方法获取到 ConfigService。有了这个假设,可以对 service 进行一些更改,以根据 options 对象的属性自定义其行为。(注意:此时,由于还没有真正确定如何传递它,因此这里先将硬编码 options,在后面修复此问题)
ConfigService 如何在传入的 options 中指定的文件夹中找到 .env 文件:
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)); // dotenv:读取.env 文件的内容
}
get(key: string): string {
return this.envConfig[key];
}
}
剩下的任务是以某种方式将 register() 步骤中的 options 对象注入到 ConfigService。当然,这里将使用依赖注入来做到这一点。这是一个关键点, ConfigModule 正在提供 ConfigService。ConfigService 又依赖于仅在运行时提供的 options 对象。因此,在运行时,需要先将 options 对象绑定到 Nest IoC 容器,然后让 Nest 将其注入到 ConfigService。请记住,在 自定义 provider 中,provider 还可以提供任何值,因此可以使用依赖注入来处理简单的 options 对象
先解决将选项对象绑定到 IoC 容器的问题。在静态 register() 方法中执行此操作。请记住,此时正在动态构建一个 module, module 的属性之一是它的 providers 列表。所以需要做的是将选项对象定义为 provider。这将使它可以注入到 ConfigService:
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' provider 注入 ConfigService 来完成该过程。回顾一下,当使用非类标记定义 provider 时,需要使用 @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';
完整示例来自官方:github.com/nestjs/nest…
另一个示例:
创建内容
ENV=dev
env='开发环境'
ENV=test
env='测试环境'
@Module({
// 传入了两个参数:存放变量文件的具体位置,和当前的环境变量
// 这个对象变量也可以抽成一个配置文件
imports: [ConfigModule.register({ folder: './envConfig', curDev: 'dev' })],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
然后去定义 ConfigModule
@Module({
providers: [ConfigService],
})
export class ConfigModule {
static register(options: Record<string, any>): DynamicModule {
console.log("动态模块接收的参数",options);
return {
module: ConfigModule,
providers: [
{
provide: 'config_options',
useValue: options
}, // 将从动态模块接收的参数注入成一个 provider 供 ConfigService使用
ConfigService
],
exports: [ConfigService]
}
}
}
定义 ConfigService
@Injectable()
export class ConfigService {
private readonly envConfig: any;
constructor(@Inject('config_options') private options: Record<string, any>) {
console.log('从module中获取的参数:', options);
console.log(`当前处于:${options.curDev === 'dev' ? '开发' : '测试'}环境`);
// 拼接环境变量配置文件的地址
const filePath = `.${options.curDev}.env`;
console.log('文件地址', filePath); // 应该读取哪个文件
console.log(__dirname); // 打包之后当前文件所处的绝对路径(dist里)
// 将地址拼接成绝对路径
const envFile = path.resolve(__dirname, '../../', options.folder, filePath);
console.log('环境文件', envFile);
this.envConfig = dotenv.parse(fs.readFileSync(envFile));
console.log('最终读取的配置内容',this.envConfig);
console.log(this.envConfig.env);
}
// 提供暴露配置文件内容方法
get(key: string){
return this.envConfig[key];
}
// 读取到的所有的配置文件内容方法
getAll(){
return this.envConfig
}
}
然后将 ConfigService 注入到 app.controller 中测试是否能拿得到值
@Controller()
export class AppController {
constructor(
private readonly appService: AppService,
private readonly configService: ConfigService,
) {}
@Get()
getHello(): string {
console.log(this.configService.get('env'));
return this.appService.getHello();
}
}
测试结果:
启动项目时设置环境变量
然后可以通过process.env.NODE_ENV读取出来输入的参数
社区准则
可能会看到有项目在 @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 的依赖注入进行配置
可配置的 module builder
由于手动创建公开 async 方法(registerAsync、forRootAsync 等)的高度可配置的动态模块非常复杂,Nest 提供了加快此过程的 ConfigurableModuleBuilder 类,只需几行代码即可构建模块 blueprint(规划) 代码
例如,将上面使用的示例(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();
打开 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' }),
// or alternatively:
// ConfigModule.registerAsync({
// useFactory: () => {
// return {
// folder: './config',
// }
// },
// inject: [...any extra dependencies...]
// }),
],
})
export class AppModule {}
最后,更新 ConfigService 类以注入生成 module options 的 provider
@Injectable()
export class ConfigService {
constructor(@Inject(MODULE_OPTIONS_TOKEN) private options: ConfigModuleOptions) { ... }
}
另一个示例(在上面《module 配置》中的示例继续修改):
创建接口,这里的接口是用来定义传入动态 module 中的参数的
export interface ConfigModuleOptions {
folder: string;
curDev: string;
}
利用 ConfigurableModuleBuilder 来构造 ConfigModule 定义
import { ConfigurableModuleBuilder } from '@nestjs/common';
import { ConfigModuleOptions } from './config-module-options';
// 这里创建 Buider,并且这个 buider 对外暴露 ConfigurableModuleClass(这个就是动态module)
// 和 MODULE_OPTIONS_TOKEN(往动态module中注入的参数)
const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
new ConfigurableModuleBuilder<ConfigModuleOptions>().build();
console.log('ConfigurableModuleClass', ConfigurableModuleClass);
console.log('MODULE_OPTIONS_TOKEN', MODULE_OPTIONS_TOKEN);
export { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN };
这里生成的实例是通过 ConfigurableModuleBuilder 这个类的实例调用 build 方法生成的
看看 ConfigurableModuleBuilder 内部的内容
import { DynamicModule } from '../interfaces';
import { Logger } from '../services/logger.service';
import { DEFAULT_FACTORY_CLASS_METHOD_KEY, DEFAULT_METHOD_KEY } from './constants';
import { ConfigurableModuleHost } from './interfaces';
export interface ConfigurableModuleBuilderOptions {
optionsInjectionToken?: string | symbol;
moduleName?: string;
alwaysTransient?: boolean;
}
export declare class ConfigurableModuleBuilder<ModuleOptions, StaticMethodKey extends string = typeof DEFAULT_METHOD_KEY, FactoryClassMethodKey extends string = typeof DEFAULT_FACTORY_CLASS_METHOD_KEY, ExtraModuleDefinitionOptions = {}> {
protected readonly options: ConfigurableModuleBuilderOptions;
protected staticMethodKey: StaticMethodKey;
protected factoryClassMethodKey: FactoryClassMethodKey;
protected extras: ExtraModuleDefinitionOptions;
protected transformModuleDefinition: (definition: DynamicModule, extraOptions: ExtraModuleDefinitionOptions) => DynamicModule;
protected readonly logger: Logger;
constructor(options?: ConfigurableModuleBuilderOptions, parentBuilder?: ConfigurableModuleBuilder<ModuleOptions>);
setExtras<ExtraModuleDefinitionOptions>(extras: ExtraModuleDefinitionOptions, transformDefinition?: (definition: DynamicModule, extras: ExtraModuleDefinitionOptions) => DynamicModule): ConfigurableModuleBuilder<ModuleOptions, StaticMethodKey, FactoryClassMethodKey, ExtraModuleDefinitionOptions>;
setClassMethodName<StaticMethodKey extends string>(key: StaticMethodKey): ConfigurableModuleBuilder<ModuleOptions, StaticMethodKey, FactoryClassMethodKey, ExtraModuleDefinitionOptions>;
setFactoryMethodName<FactoryClassMethodKey extends string>(key: FactoryClassMethodKey): ConfigurableModuleBuilder<ModuleOptions, StaticMethodKey, FactoryClassMethodKey, ExtraModuleDefinitionOptions>;
build(): ConfigurableModuleHost<ModuleOptions, StaticMethodKey, FactoryClassMethodKey, ExtraModuleDefinitionOptions>;
private constructInjectionTokenString;
private createConfigurableModuleCls;
private createTypeProxy;
}
这里看到 build方法返回了一个 ConfigurableModuleBuilderOptions,看看 ConfigurableModuleBuilderOptions 的内部
import { ConfigurableModuleAsyncOptions } from './configurable-module-async-options.interface';
import { ConfigurableModuleCls } from './configurable-module-cls.interface';
export interface ConfigurableModuleHost<ModuleOptions = Record<string, unknown>, MethodKey extends string = string, FactoryClassMethodKey extends string = string, ExtraModuleDefinitionOptions = {}> {
ConfigurableModuleClass: ConfigurableModuleCls<ModuleOptions, MethodKey, FactoryClassMethodKey, ExtraModuleDefinitionOptions>;
MODULE_OPTIONS_TOKEN: string | symbol;
ASYNC_OPTIONS_TYPE: ConfigurableModuleAsyncOptions<ModuleOptions, FactoryClassMethodKey> & Partial<ExtraModuleDefinitionOptions>;
OPTIONS_TYPE: ModuleOptions & Partial<ExtraModuleDefinitionOptions>;
}
看到这里,已经可以看到前面通过 new ConfigurableModuleBuilder<ConfigModuleOptions>().build();生成的对象的四个属性了,现在看看 ConfigurableModuleClass 内部
import { DynamicModule } from '../../interfaces';
import { DEFAULT_FACTORY_CLASS_METHOD_KEY, DEFAULT_METHOD_KEY } from '../constants';
import { ConfigurableModuleAsyncOptions } from './configurable-module-async-options.interface';
export type ConfigurableModuleCls<ModuleOptions, MethodKey extends string = typeof DEFAULT_METHOD_KEY, FactoryClassMethodKey extends string = typeof DEFAULT_FACTORY_CLASS_METHOD_KEY, ExtraModuleDefinitionOptions = {}> = {
new (): any;
} & Record<`${MethodKey}`, (options: ModuleOptions & Partial<ExtraModuleDefinitionOptions>) => DynamicModule> & Record<`${MethodKey}Async`, (options: ConfigurableModuleAsyncOptions<ModuleOptions, FactoryClassMethodKey> & Partial<ExtraModuleDefinitionOptions>) => DynamicModule>;
这里可以看到 ConfigurableModuleCls 这个类型定义了一个方法,这个方法返回了一个 DynamicModule,所以可以知道new ConfigurableModuleBuilder<ConfigModuleOptions>().build();生成的对象里面的其中一个属性 ConfigurableModuleClass 本质上是指向了一个动态module(DynamicModule),MODULE_OPTIONS_TOKEN 这个其实就是往这个动态 module 中传入的值
改造 config.module.ts
@Module({
providers: [ConfigService],
exports: [ConfigService]
})
export class ConfigModule extends ConfigurableModuleClass{}
// 继承刚刚从config.module-definition中暴露的 ConfigurableModuleClass
改造 config.service.ts
@Injectable()
export class ConfigService {
private readonly envConfig: any;
constructor(
// 注意:这里的 MODULE_OPTIONS_TOKEN 不是字符串,不用引号
@Inject(MODULE_OPTIONS_TOKEN) private options: ConfigModuleOptions,
) {
console.log('从module中获取的参数:', options);
console.log(`当前处于:${options.curDev === 'dev' ? '开发' : '测试'}环境`);
// 拼接环境变量配置文件的地址
const filePath = `.${options.curDev}.env`;
console.log('文件地址', filePath); // 应该读取哪个文件
console.log(__dirname); // 打包之后当前文件所处的绝对路径(dist里)
// 将地址拼接成绝对路径
const envFile = path.resolve(__dirname, '../../', options.folder, filePath);
console.log('环境文件', envFile);
this.envConfig = dotenv.parse(fs.readFileSync(envFile));
console.log('最终读取的配置内容', this.envConfig);
console.log(this.envConfig.env);
}
// 提供暴露配置文件内容方法
get(key: string) {
return this.envConfig[key];
}
// 读取到的所有的配置文件内容方法
getAll() {
return this.envConfig;
}
}
自定义方法名
ConfigurableModuleClass 默认提供 register 及其对应的 registerAsync 方法。要使用不同的方法名称,请使用 ConfigurableModuleBuilder#setClassMethodName 方法,如下所示:
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
new ConfigurableModuleBuilder<ConfigModuleOptions>().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 或任何其他名称,具体取决于配置)让使用者传递解析为模块配置的 provider 定义,因此库使用者可能会提供一个用于构造配置对象的类
@Module({
imports: [
ConfigModule.registerAsync({
useClass: ConfigModuleOptionsFactory,
}),
],
})
export class AppModule {}
默认情况下,此类必须提供返回 module 配置对象的 create() 方法。但是,如果你的库遵循不同的命名约定,你可以更改该行为并指示 ConfigurableModuleBuilder 期待不同的方法,例如,createConfigOptions,使用 ConfigurableModuleBuilder#setFactoryMethodName 方法:
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
new ConfigurableModuleBuilder<ConfigModuleOptions>().setFactoryMethodName('createConfigOptions').build();
现在,ConfigModuleOptionsFactory 类必须提供 createConfigOptions 方法(而不是 create):
@Module({
imports: [
ConfigModule.registerAsync({
useClass: ConfigModuleOptionsFactory, // <-- 这个类必须提供 createConfigOptions 方法
}),
],
})
export class AppModule {}
额外选项
在某些边缘情况下,module 可能需要采用额外的选项来确定它的行为方式,但同时不应包含这些选项在 MODULE_OPTIONS_TOKEN provider(传入的参数)中(因为它们与该 module 内注册的 service/ provider 无关,例如,ConfigService 不需要知道其 host module 是否注册为全局 module)
在这种情况下,可以使用 ConfigurableModuleBuilder#setExtras 方法:
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } = new ConfigurableModuleBuilder<ConfigModuleOptions>()
.setExtras(
{
isGlobal: true,
},
(definition, extras) => ({
...definition,
global: extras.isGlobal,
}),
)
.build();
在上面的示例中,传递给 setExtras 方法的第一个参数是一个包含额外属性默认值的对象。第二个参数是一个方法,它使用了自动生成的 module 定义(使用 provider、exports 等)和代表额外属性(由使用者指定或默认值)的 extras 对象。该函数的返回值是修改后的 module 定义。在这个具体示例中采用 extras.isGlobal 属性并将其分配给 module 定义的 global 属性(这反过来决定 module 是否是全局的)
现在使用这个 global 时,可以传入额外的 isGlobal 标志,如下:
@Module({
imports: [
ConfigModule.register({
isGlobal: true,
folder: './config',
}),
],
})
export class AppModule {}
但是,由于 isGlobal 被声明为 extra 属性,因此它在 MODULE_OPTIONS_TOKEN provider 中不可用:
@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 {
// your custom logic here
...super.register(options),
};
}
static registerAsync(options: typeof ASYNC_OPTIONS_TYPE): DynamicModule {
return {
// your custom logic here
...super.registerAsync(options),
};
}
}
注意必须从 module 定义文件中导出的 OPTIONS_TYPE 和 ASYNC_OPTIONS_TYPE 类型的使用:
export const { ConfigurableModuleClass,
MODULE_OPTIONS_TOKEN,
OPTIONS_TYPE,
ASYNC_OPTIONS_TYPE } =
new ConfigurableModuleBuilder<ConfigModuleOptions>()
.build();
注入作用域
Node.js 不遵循请求/响应多线程无状态模型,在该模型中每个请求都由单独的线程处理。因此,使用单例实例对于 nestjs 应用来说是完全安全的
Provider作用域
provider 可以具有以下任何作用域:
| DEFAULT(默认) | provider 的单个实例在整个应用中共享。实例生命周期与应用生命周期直接相关。应用启动后,所有单例提供程序都已实例化。默认情况下使用单例作用域 | 单例,从应用启动开始就只有这一个实例 | 默认(单例)作用域 |
|---|---|---|---|
| REQUEST | 专门为每个传入的请求创建一个新的 provider 实例。请求完成处理后,该实例将被垃圾回收 | 每次请求的时候创建一个新的实例 | 请求作用域 |
| TRANSIENT | 临时 provider 不在使用者之间共享。每个注入临时 provider 的使用者都将收到一个新的专用实例 | 在每次注入到其他 module 中都是一个新的实例 | 临时作用域 |
提示:对于大多数用例,建议使用单例范围。跨使用者和跨请求共享 provider 意味着一个实例可以被缓存并且它的初始化只发生一次,在应用启动期间
用法
通过将 scope 属性传递给 @Injectable() 装饰器选项对象来指定注入作用域:
import { Injectable, Scope } from '@nestjs/common';
@Injectable({ scope: Scope.REQUEST })
export class CatsService {}
对于自定义 provider:
{
provide: 'CACHE_MANAGER',
useClass: CacheManager,
scope: Scope.TRANSIENT,
}
提示:从 @nestjs/common 导入 Scope 枚举
Controller 作用域
controller 也可以有作用域,它适用于在该 controller 中声明的所有请求方法 provider。与provider 作用域一样,controller 的作用域声明了它的生命周期。对于请求作用域的 controller ,为每个入站请求创建一个新实例,并在请求完成处理后进行垃圾收集
使用 ControllerOptions 对象的 scope 属性声明 controller 作用域:
@Controller({
path: 'cats',
scope: Scope.REQUEST,
})
export class CatsController {}
作用域层次结构
请求作用域 在注入链中向上冒泡。依赖于请求作用域 provider 的 controller 本身也是请求作用域的
想象一下以下依赖图:CatsController <- CatsService <- CatsRepository。如果 CatsService 是请求作用域的一部分(其他都是默认的单例),CatsController 也将是请求作用域的一部分,因为它依赖于注入的服务。不依赖(没有其他注入)的 CatsRepository 将保持单例作用域
临时作用域 的依赖不遵循该模式。如果单例作用域的 DogsService 注入临时 LoggerService provider,它将收到它的一个新实例。但是,DogsService 仍然保持单例作用域,因此将它注入任何地方都不会解析为 DogsService 的新实例。如果 DogsService 也需要临时,则 DogsService 也必须明确标记作用域为 TRANSIENT
Request provider(请求provider)
在基于 HTTP 服务器的应用中(例如,使用 @nestjs/platform-express 或 @nestjs/platform-fastify),可能希望在使用请求作用域 provider 时访问对原始请求对象的引用。可以通过注入 REQUEST 对象来完成此操作
REQUEST provider 是 request-scoped(请求范围)的,因此在这种情况下不需要显式使用 REQUEST 范围(可以忽略显示使用 scope 声明 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) {}
}
然后,将 context 值(在 GraphQLModule 中)配置为包含 request 作为其属性
Inquirer provider(查询器provider)
如果想获取构建 provider 的类,例如在日志记录或指标 provider 中,可以注入 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 将被记录到控制台
另一个示例:
@Injectable()
export class CatsService {
constructor(@Inject(REQUEST) private request: Request,@Inject(INQUIRER) private parentClass: object) {
}
findAll(msg: string = '默认') {
console.log("request", this.request)
// 如果是通过调用 DogsService 来触发这个方法的话,这里可以看到 name 为 DogsService
// 并且 msg 是 DogsService 传过来的参数
console.log("调用我的是:",this.parentClass?.constructor?.name,msg)
return `This action returns all cats`;
}
}
@Injectable()
export class DogsService {
constructor(private readonly catsService: CatsService) {} // 注入catsService
findAll() {
// 在这里调用 catsService 的 findAll 方法,并传入参数
this.catsService.findAll('我传了这句话给你');
return `This action returns all dogs`;
}
}
性能
使用请求作用域的 provider 将对应用性能产生影响。虽然 Nest 尝试缓存尽可能多的元数据,但它仍然必须在每个请求上创建新的实例。因此,它会减慢平均响应时间和整体基准测试结果。除非 provider 必须是请求作用域的,否则强烈建议使用默认的单例作用域
提示:尽管这一切听起来相当令人生畏,但利用请求范围 provider 的正确设计的应用不应在延迟方面减慢超过约 5%
Durable providers(持久性provider)
请求范围的 provider 可能会导致延迟增加,因为至少有 1 个请求作用域的 provider(注入到controller 实例中,或更深层 - 注入到其 provider 之一)使 controller 也处于请求作用域。这意味着必须根据每个单独的请求重新创建(实例化)它(然后进行垃圾收集)。现在,这也意味着,假设有 30k 个并行请求,将有 30k 个 controller (及其请求作用域的 provider)的临时实例
拥有大多数 provider 所依赖的公共 provider (想想数据库连接或日志器服务),也会自动将所有这些 provider 转换为请求作用域的 provider 。这可能会给多租户应用带来挑战,特别是对于那些具有中央请求范围的 "数据源" provider 的应用,该 provider 从请求对象中获取标头/标记,并根据其值查找获取相应的数据库连接/架构(特定于该租户) )
例如,假设有一个应用由 10 个不同的客户轮流使用。每个客户都有自己专用的数据源,并且希望确保客户 A 永远无法访问客户 B 的数据库。实现此目的的一种方法是声明一个请求范围的 "数据源" provider - 基于请求对象 - 确定 "当前客户" 是什么并查找获取其相应的数据库。使用这种方法,可以在几分钟内将应用转变为多租户应用。但是,这种方法的一个主要缺点是,由于应用的大部分组件很可能依赖于 "数据源" provider,因此它们将隐式变为 request-scoped(请求作用域) ,因此无疑会看到对应用性能的影响
是否有更好的解决方案?由于只有 10 个客户,能否为每个客户提供 10 个单独的 DI 子树(而不是根据请求重新创建每棵树)?如果 provider 不依赖于每个连续请求(例如,请求 UUID)真正唯一的任何属性,而是有一些特定的属性聚合(分类)它们,则没有理由在每个请求传入时重新创建 DI 子树
这正是 Durable providers 派上用场的时候
在开始将 provider 标记为持久之前,必须首先注册一个策略,指示 Nest 这些 公共请求属性 是什么,提供对请求进行分组的逻辑 - 将它们与相应的 DI 子树关联起来
import {
HostComponentInfo,
ContextId,
ContextIdFactory,
ContextIdStrategy,
} from '@nestjs/core';
import { Request } from 'express';
// 用来存多个持久的 provider,key 就是根据请求头中的 x-tenant-id 判断是否存在
const tenants = new Map<string, ContextId>();
export class AggregateByTenantContextIdStrategy implements ContextIdStrategy {
attach(contextId: ContextId, request: Request) {
// 从请求头中获取 id,这个 id 应该是根据用户来区分生成的不同 id(暂称用户id)
const tenantId = request.headers['x-tenant-id'] as string;
let tenantSubTreeId: ContextId;
// 判断 map 中是否存在用户id
if (tenants.has(tenantId)) {
// 存在,直接从 map 中获取之前已经生成好的持久 provider
tenantSubTreeId = tenants.get(tenantId);
} else {
// 不存在,根据上下文生成一个新的持久 provider 并放入到 map 缓存中
tenantSubTreeId = ContextIdFactory.create();
tenants.set(tenantId, tenantSubTreeId);
}
// 如果 tree 不是持久的,返回原始的" contexttid "对象
// 这个判断 provider 是否开启了持久化,开启了就取持久化的,没开启就取请求域中新的
return (info: HostComponentInfo) =>
info.isTreeDurable ? tenantSubTreeId : contextId;
}
}
提示:与请求范围类似,持久性会在注入链中冒泡。这意味着如果 A 依赖于标记为 durable 的 B,则 A 也隐含地变得持久(除非 durable 明确设置为 A 提供器的 false)
警告:请注意,此策略对于运行大量租户的应用来说并不理想
attach 方法返回的值指示 Nest 应该为给定主机使用什么上下文标识符。当主机组件(例如,请求作用域的 controller )被标记为持久时,在这种情况下,应该指定使用 tenantSubTreeId 而不是原始的自动生成的 contextId 对象。此外,在上面的示例中,不会注册任何有效负载(其中有效负载 = REQUEST/CONTEXT 提供程序,代表 root - 子树的父级)
如果要为持久树注册有效负载,请改用以下结构:
// The return of `AggregateByTenantContextIdStrategy#attach` method:
return {
resolve: (info: HostComponentInfo) =>
info.isTreeDurable ? tenantSubTreeId : contextId,
payload: { tenantId },
}
现在,每当使用 @Inject(REQUEST)/@Inject(CONTEXT) 注入 REQUEST provider(或 CONTEXT 对于 GraphQL 应用)时,都会注入 payload 对象(由单个属性组成) - 在本例中为 tenantId)
有了这个策略,可以在代码中的某个地方注册它(因为它无论如何都适用于全局),例如,可以将它放在 main.ts 文件中:
ContextIdFactory.apply(new AggregateByTenantContextIdStrategy());
提示:ContextIdFactory 类是从 @nestjs/core 包中导入的
只要在任何请求到达应用之前进行注册,一切都会按预期进行
最后,要将常规 provider 转变为 Durable provider,只需将 durable 标志设置为 true 并将其作用域更改为 Scope.REQUEST(如果 REQUEST 作用域已经在注入链中则不需要):
import { Injectable, Scope } from '@nestjs/common';
@Injectable({ scope: Scope.REQUEST, durable: true })
export class CatsService {}
对于自定义 provider:
{
provide: 'foobar',
useFactory: () => { ... },
scope: Scope.REQUEST,
durable: true,
}
解决循环依赖
当两个类相互依赖时,就会发生循环依赖。比如 A 类需要 B 类,B 类也需要 A 类。Nest 中模块之间和提供器之间可能会出现循环依赖
虽然应尽可能避免循环依赖,但不能总是这样做。在这种情况下,Nest 可以通过两种方式解决提供器之间的循环依赖:
- 使用 前向引用(Forward reference)
- 使用 ModuleRef 类从 DI 容器中查找获取 provider 实例
前向引用(Forward reference)
在程序设计中,前向引用是指提前声明,但还没有给出完整的定义的标识符(表示编程的实体,如数据类型、变量、函数)
前向引用允许 Nest 引用尚未使用 forwardRef() 实用函数定义的类。例如,如果 CatsService 和 CommonService 相互依赖,则关系的双方都可以使用 @Inject() 和 forwardRef() 实用程序来解决循环依赖,否则 Nest 不会实例化它们,因为所有必要的元数据都将不可用。例:
@Injectable()
export class CatsService {
constructor(
@Inject(forwardRef(() => CommonService))
private commonService: CommonService,
) {}
}
提示:forwardRef()函数是从 @nestjs/common 包中导入的
@Injectable()
export class CommonService {
constructor(
@Inject(forwardRef(() => CatsService))
private catsService: CatsService,
) {}
}
警告:实例化的顺序是不确定的。确保代码不依赖于首先调用哪个构造函数。循环依赖依赖于具有 Scope.REQUEST 的提供器可能会导致未定义的依赖
模块的前向引用与 provider 前向引用有点类似,在 module 关联的两边使用相同的 forwardRef() 实用函数。例如:
@Module({
imports: [forwardRef(() => CatsModule)],
})
export class CommonModule {}
@Module({
imports: [forwardRef(() => CommonModule)],
})
export class CatsModule {}
ModuleRef 类替代
使用 forwardRef() 的替代方法是重构代码并使用 ModuleRef 类并在循环关系的一侧查找获取 provider(具体内容见模块引用(Module reference) 这一部分)
模块引用(Module reference)
Nest 提供 ModuleRef 类来操作内部 provider 列表,并使用其注入 token 作为键来查找到任何provider 的引用。ModuleRef 类还提供了一种动态实例化静态或作用域 provider 的方法。ModuleRef 可以通过正常方式注入到一个类中:
@Injectable()
export class CatsService {
constructor(private moduleRef: ModuleRef) {}
}
提示:ModuleRef 类是从 @nestjs/core 包中导入的
查找获取实例
ModuleRef 实例(以下将其称为模块引用)有一个 get() 方法。此方法使用其注入token/类名称查找获取当前模块中存在(已实例化)的 provider、controller 或可注入(例如,防护、拦截器等)
@Injectable()
export class CatsService implements OnModuleInit {
private service: Service;
constructor(private moduleRef: ModuleRef) {}
onModuleInit() {
this.service = this.moduleRef.get(Service);
}
}
警告:无法使用 get() 方法查找获取作用域 provider (临时或请求 provider )
要从全局上下文中查找获取 provider(例如,如果 provider 已被注入到不同的 module 中),请将 { strict: false } 选项作为第二个参数传递给 get()
this.moduleRef.get(Service, { strict: false });
解析provider的作用域
要动态解析 provider 的作用域 (临时或请求 provider ),使用 resolve() 方法,将 provider 的注入标记作为参数传递
@Injectable()
export class CatsService implements OnModuleInit {
private transientService: TransientService;
constructor(private moduleRef: ModuleRef) {}
async onModuleInit() {
this.transientService = await this.moduleRef.resolve(TransientService);
}
}
resolve() 方法从其自己的 DI 容器子树返回 provider 的唯一实例。每个子树都有一个唯一的上下文标识符。因此,如果多次调用此方法并比较实例引用,会发现它们不相等
@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
}
}
提示:ContextIdFactory 类是从 @nestjs/core 包中导入的
注册 REQUEST provider
手动生成的上下文标识符(带有 ContextIdFactory.create())代表 DI 子树,其中 REQUEST provider是 undefined,因为它们不是由 Nest 依赖注入系统实例化和管理的
要为手动创建的 DI 子树注册自定义 REQUEST 对象,请使用 ModuleRef#registerRequestByContextId() 方法,如下所示:
const contextId = ContextIdFactory.create();
this.moduleRef.registerRequestByContextId(/* YOUR_REQUEST_OBJECT */, contextId);
获取当前子树
有时,可能希望在请求上下文中解析请求范围内的 provider 的实例。假设 CatsService 是请求作用域的,想要解析也标记为请求作用域 provider 的 CatsRepository 实例。为了共享同一个 DI 容器子树,必须获取当前的上下文标识符而不是生成一个新的上下文标识符(例如,使用 ContextIdFactory.create() 函数,如上所示)。要获取当前上下文标识符,首先使用 @Inject() 装饰器注入请求对象
@Injectable()
export class CatsService {
constructor(
@Inject(REQUEST) private request: Record<string, unknown>,
) {}
}
现在,使用 ContextIdFactory 类的 getByRequest() 方法根据请求对象创建上下文 ID,并将其传递给 resolve() 调用:
const contextId = ContextIdFactory.getByRequest(this.request);
const catsRepository = await this.moduleRef.resolve(CatsRepository, contextId);
动态实例化自定义类
要动态实例化先前未注册为 provider 的类,请使用模块引用的 create() 方法
@Injectable()
export class CatsService implements OnModuleInit {
private catsFactory: CatsFactory;
constructor(private moduleRef: ModuleRef) {}
async onModuleInit() {
this.catsFactory = await this.moduleRef.create(CatsFactory);
}
}
此技术能够有条件地在框架容器之外实例化不同的类
延迟加载module
默认情况下,module 是预先加载的,这意味着一旦应用加载,所有 module 也会加载,无论它们是否立即需要。虽然这对于大多数应用来说没问题,但它可能会成为在无服务器环境中运行的应用/工作进程的瓶颈,其中延迟启动(冷启动)至关重要
延迟加载可以通过仅加载特定无服务器方法调用所需的 module 来帮助减少引导时间。此外,还可以在无服务器功能为 warm 时异步加载其他 module ,以进一步加快后续调用的引导时间(延迟 module 注册)
警告:请注意,延迟加载的 module 和 service 中不会调用生命周期钩子方法
入门
为了按需加载 module,Nest 提供了 LazyModuleLoader 类,可以以正常方式注入到类中:
@Injectable()
export class CatsService {
constructor(private lazyModuleLoader: LazyModuleLoader) {}
}
提示:LazyModuleLoader 类是从 @nestjs/core 包中导入的
或者,可以从main.ts中获取对 LazyModuleLoader provider 的引用,如下所示:
// "app" represents a Nest application instance
const lazyModuleLoader = app.get(LazyModuleLoader);
有了这个,现在可以使用以下结构加载任何 module:
const { LazyModule } = await import('./lazy.module');
const moduleRef = await this.lazyModuleLoader.load(() => LazyModule);
提示:延迟加载 module 在第一次 LazyModuleLoader#load 方法调用时被缓存。这意味着,每次连续尝试加载 LazyModule 将非常快,并且将返回缓存的实例,而不是再次加载 module
Load "LazyModule" attempt: 1
time: 2.379ms
Load "LazyModule" attempt: 2
time: 0.294ms
Load "LazyModule" attempt: 3
time: 0.303ms
此外,延迟加载 module与那些在main.ts中预加载的 module 以及稍后在应用中注册的任何其他惰性 module 共享相同的模块图
其中 lazy.module.ts 是导出常规 Nest 模块的 TypeScript 文件(无需额外更改)
LazyModuleLoader#load 方法返回模块引用(属于 LazyModule),它允许操作内部 provider 列表并使用其注入 token 作为键来查找到任何 provider 的引用
假设有一个具有以下定义的 LazyModule:
@Module({
providers: [LazyService],
exports: [LazyService],
})
export class LazyModule {}
提示:延迟加载的 module 不能注册为全局 module,因为它根本没有意义(因为当所有静态注册的 module 都已实例化时,它们是按需延迟注册的)。同样,注册的全局增强(守卫/拦截器/等)也将无法正常工作
有了这个,就可以获得对 LazyService provider的引用,如下所示:
const { LazyModule } = await import('./lazy.module');
const moduleRef = await this.lazyModuleLoader.load(() => LazyModule);
const { LazyService } = await import('./lazy.service');
const lazyService = moduleRef.get(LazyService);
警告:如果使用 Webpack,请确保更新 tsconfig.json 文件 - 将 compilerOptions.module 设置为 esnext 并添加 compilerOptions.moduleResolution 属性并使用 node 作为值:
{
"compilerOptions": {
"module": "esnext",
"moduleResolution": "node",
...
}
}
设置这些选项后,就能够使用 代码拆分 功能
延迟加载controllers、网关和解析器
由于 Nest 中的 controller(或 GraphQL 应用中的解析器)表示路由/路径/主题(或查询/突变)集,因此无法使用 LazyModuleLoader 类延迟加载它们
警告:在延迟加载模块内注册的 controller、resolvers 和 gateways 将不会按预期运行。同样,不能按需注册中间件方法(通过实现 MiddlewareConsumer 接口)
假设正在使用底层的 Fastify 驱动程序(使用 @nestjs/platform-fastify 包)构建一个 REST API(HTTP 应用)。Fastify 不允许在应用初始化完成/成功监听消息后注册路由。这意味着即使分析了在模块控制器中注册的路由映射,所有延迟加载的路由也将无法访问,因为无法在运行时注册它们
同样,作为 @nestjs/microservices 包的一部分提供的一些传输策略(包括 Kafka、gRPC 或 RabbitMQ)需要在建立连接之前订阅/收听特定主题/通道。一旦应用开始收听消息,框架将无法订阅/收听新主题
最后,启用了代码优先方法的 @nestjs/graphql 包会根据元数据自动即时生成 GraphQL 模式。这意味着,它需要预先加载所有类。否则,将无法创建适当的、有效的模式
常见用例
最常见的情况是,当 worker/cron job/lambda 和无服务器函数/webhook 必须根据输入参数(路由路径/日期/查询参数等)触发不同的服务(不同的逻辑)时,就会看到延迟加载的 module。另一方面,延迟加载 module 对于整体应用可能没有太大意义,因为启动时间与整体应用无关
执行上下文
Nest 提供了几个实用程序类,有助于轻松编写跨多个应用上下文(例如,基于 Nest HTTP 服务器、微服务和 WebSockets 应用上下文)运行的应用。这些实用程序提供有关当前执行上下文的信息,这些信息可用于构建通用的 guards、filters 和 interceptors,它们可以跨广泛的控制器、方法和执行上下文集工作
ArgumentsHost 类
ArgumentsHost 类提供了用于查找获取传递给处理程序的参数的方法。它允许选择适当的上下文(例如 HTTP、RPC(微服务)或 WebSockets)以从中查找获取参数。该框架在可能想要访问它的地方提供了一个 ArgumentsHost 的实例,通常作为 host 参数引用。例如,使用 ArgumentsHost 实例调用 异常过滤器 的 catch() 方法
ArgumentsHost 只是作为处理程序参数的抽象。例如,对于 HTTP 服务器应用(当使用 @nestjs/platform-express 时),host 对象封装了 Express 的 [request, response, next] 数组,其中 request 是请求对象,response 是响应对象,next 是控制应用请求-响应周期的函数。另一方面,对于 GraphQL 应用,host 对象包含[root, args, context, info] 数组
当前应用上下文
在构建旨在跨多个应用上下文运行的通用守卫 guards、过滤器 filters 和 拦截器interceptors 时,需要一种方法来确定当前方法运行的类型。使用 ArgumentsHost 的 getType() 方法执行此操作:
if (host.getType() === 'http') {
// 做一些只有在常规HTTP请求(REST)上下文中才重要的事情
} else if (host.getType() === 'rpc') {
// 做一些只在微服务请求上下文中重要的事情
} else if (host.getType<GqlContextType>() === 'graphql') {
// 做一些只有在GraphQL请求的上下文中才重要的事情
}
提示:GqlContextType 是从 @nestjs/graphql 包导入的
Host handler 参数
要查找获取传递给处理程序的参数数组,一种方法是使用宿主对象的 getArgs() 方法
const [req, res, next] = host.getArgs();
可以使用 getArgByIndex() 方法按索引提取特定参数:
const request = host.getArgByIndex(0);
const response = host.getArgByIndex(1);
在这些示例中,通过索引查找获取请求和响应对象,这通常不被推荐,因为它将应用耦合到特定的执行上下文。相反,可以通过使用 host 对象的实用方法之一切换到适合应用的应用上下文,从而使代码更加健壮和可重用。上下文切换实用程序方法如下所示
/**
* Switch context to RPC.
*/
switchToRpc(): RpcArgumentsHost;
/**
* Switch context to HTTP.
*/
switchToHttp(): HttpArgumentsHost;
/**
* Switch context to 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>();
同样,WsArgumentsHost 和 RpcArgumentsHost 具有在微服务和 WebSockets 上下文中返回适当对象的方法。以下是 WsArgumentsHost 的方法:
export interface WsArgumentsHost {
/**
* Returns the data object.
*/
getData<T>(): T;
/**
* Returns the client object.
*/
getClient<T>(): T;
}
以下是 RpcArgumentsHost 的方法:
export interface RpcArgumentsHost {
/**
* Returns the data object.
*/
getData<T>(): T;
/**
* Returns the context object.
*/
getContext<T>(): T;
}
执行上下文类
ExecutionContext 扩展 ArgumentsHost,提供有关当前执行过程的更多详细信息。与 ArgumentsHost 一样,Nest 在可能需要的地方提供了 ExecutionContext 的实例,例如守卫 guard 的 canActivate() 方法和拦截器 interceptor 的 intercept() 方法。它提供了以下方法:
export interface ExecutionContext extends ArgumentsHost {
/**
* Returns the type of the controller class which the current handler belongs to.
*/
getClass<T>(): Type<T>;
/**
* Returns a reference to the handler (method) that will be invoked next in the
* request pipeline.
*/
getHandler(): Function;
}
getHandler() 方法返回对即将被调用的处理程序的引用。getClass() 方法返回此特定处理程序所属的 Controller 类的类型。例如,在 HTTP 上下文中,如果当前处理的请求是 POST 请求,绑定到 CatsController 上的 create() 方法,则 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) {}
}
提示:Reflector 类是从 @nestjs/core 包中导入的
现在,要读取处理程序元数据,使用 get() 方法:
const roles = this.reflector.get(Roles, context.getHandler());
Reflector#get方法允许通过传入两个参数轻松访问元数据:装饰器引用和从中查找获取元数据的上下文(装饰器目标)。在这个例子中,指定的装饰器是 Roles(参考上面的 roles.decorator.ts 文件)。上下文由对 context.getHandler() 的调用提供,这会导致为当前处理的路由处理程序提取元数据。请记住,getHandler() 提供了路由处理函数的引用
或者,可以通过在控制器级别应用元数据来组织 controller,应用于 controller类 中的所有路由
@Roles(['admin'])
@Controller('cats')
export class CatsController {}
在这种情况下,为了提取 controller 元数据,将 context.getClass() 作为第二个参数传递(以提供 controller 作为元数据提取的上下文)而不是 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);
}
提示: @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) {}
}
提示:Reflector 类是从 @nestjs/core 包中导入的
现在,要读取处理程序元数据,请使用 get() 方法
const roles = this.reflector.get<string[]>('roles', context.getHandler());
这里没有传递装饰器引用,而是传递元数据键作为第一个参数(在例子中是 'roles')。其他一切与 Reflector#createDecorator 示例中的相同
生命周期事件
Nest 应用以及每个应用元素都有一个由 Nest 管理的生命周期。Nest 提供了生命周期钩子,可以了解一些关键的生命周期事件,并能够在事件发生时采取行动(在 module、provider 或 controller 上运行注册代码)
生命周期顺序
下图描述了关键应用生命周期事件的顺序,从应用启动到节点进程退出。可以将整个生命周期分为三个阶段:初始化、运行和终止。使用此生命周期,可以规划模块和服务的适当初始化、管理活动连接并在收到终止信号时优雅地关闭应用
生命周期事件
生命周期事件发生在应用启动和关闭期间。Nest 在以下每个生命周期事件中调用 module、provider 和 controller 上已注册的生命周期钩子方法(需要首先启用关闭钩子)。如上图所示,Nest 还会调用相应的底层方法来开始监听连接和停止监听连接
在下表中,仅当显式调用 app.close() 或进程收到特殊系统信号(例如 SIGTERM)并且在应用引导时正确调用了 enableShutdownHooks 时,才会触发 onModuleDestroy、beforeApplicationShutdown 和 onApplicationShutdown
| 生命周期钩子方法 | 生命周期事件触发钩子方法调用 |
|---|---|
| onModuleInit() | 一旦解决了主机模块的依赖,就会调用 |
| onApplicationBootstrap() | 一旦所有模块都已初始化,但在监听连接之前调用 |
| onModuleDestroy()* | 在收到终止信号(例如 SIGTERM)后调用 |
| beforeApplicationShutdown()* | 在所有 onModuleDestroy() 处理程序完成后调用(Promise 已解决或拒绝) 一旦完成(Promise 已解决或拒绝),所有现有连接将被关闭(app.close() 被调用) |
| onApplicationShutdown()* | 在连接关闭后调用(app.close() 解析) |
- 对于这些事件,如果没有显式调用
app.close(),则必须选择让它们与 SIGTERM 等系统信号一起工作
警告:对于请求范围的类,不会触发上面列出的生命周期钩子。请求作用域的类与应用生命周期无关,它们的生命周期是不可预测的。它们专门为每个请求创建,并在发送响应后自动进行垃圾回收
提示:onModuleInit() 和 onApplicationBootstrap() 的执行顺序直接取决于 module 导入的顺序,等待上一个钩子
用法
每个生命周期钩子都由一个接口表示。接口在技术上是可选的,因为它们在 TypeScript 编译后不存在。尽管如此,使用它们以从强类型和编辑器工具中获益是一种很好的做法。要注册生命周期钩子,请实现适当的接口。例如,要在特定类(例如 Controller、Provider 或 Module)的模块初始化期间注册要调用的方法,请通过提供 onModuleInit() 方法来实现 OnModuleInit 接口,如下所示:
import { Injectable, OnModuleInit } from '@nestjs/common';
@Injectable()
export class UsersService implements OnModuleInit {
onModuleInit() {
console.log(`The module has been initialized.`);
}
}
异步初始化
OnModuleInit 和 OnApplicationBootstrap 钩子都允许推迟应用初始化过程(返回 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);
// Starts listening for shutdown hooks
app.enableShutdownHooks();
await app.listen(3000);
}
bootstrap();
警告:由于固有的平台限制,NestJS 对 Windows 上的应用关闭钩子的支持有限。你可以期望 SIGINT 能够工作,SIGBREAK 也能工作,并且在某种程度上 SIGHUP 也能工作。 - 阅读更多。但是 SIGTERM 永远不会在 Windows 上运行,因为在任务管理器中终止进程是无条件的,即,应用无法检测或阻止它。这里有一些来自 libuv 的 uv_signal_t — Signal handle - libuv documentation,以了解更多关于如何在 Windows 上处理 SIGINT、SIGBREAK 和其他内容的信息。另请参阅 处理信号事件 的 Node.js 文档
信息:enableShutdownHooks 通过启动监听器来消耗内存。如果在单个 Node 进程中运行多个 Nest 应用(例如,当使用 Jest 运行并行测试时),Node 可能会开启过多的监听器进程。因此,默认情况下不启用 enableShutdownHooks。当在单个节点进程中运行多个实例时,请注意这种情况
当应用接收到终止信号时,它将调用任何已注册的 onModuleDestroy()、beforeApplicationShutdown(),然后是 onApplicationShutdown() 方法(按上述顺序),并将相应的信号作为第一个参数。如果注册的函数等待异步调用(返回 promise),Nest 将不会继续序列,直到 promise 被解决或拒绝
@Injectable()
class UsersService implements OnApplicationShutdown {
onApplicationShutdown(signal: string) {
console.log(signal); // e.g. "SIGINT"
}
}
信息:调用 app.close() 并不会终止 Node 进程,只会触发 onModuleDestroy() 和 onApplicationShutdown() 钩子,所以如果有一些间隔、长时间运行的后台任务等,进程不会自动终止
测试
Nest 致力于推广开发最佳实践,包括有效的测试,因此它包括以下功能,以帮助开发者和团队构建和自动化测试。Nest:
- 自动搭建组件的默认单元测试和应用的端到端测试
- 提供默认工具(例如构建隔离模块/应用加载器的测试运行器)
- 开箱即用地提供与 Jest 和 Supertest 的集成,同时保持对测试工具的无关性
- 使 Nest 依赖注入系统在测试环境中可用,以便轻松模拟组件
如前所述,可以使用喜欢的任何测试框架, Nest 不强制使用任何特定工具。只需替换所需的元素(例如测试运行器),仍然可以享受 Nest 现成测试工具的好处
安装
要开始,首先安装所需的包:
$ npm i --save-dev @nestjs/testing
单元测试
下面的例子中测试了两个类:CatsController 和 CatsService。如前所述,Jest 是作为默认测试框架提供的。它充当测试运行器,还提供断言函数和测试替身实用程序,以帮助模拟、监听等。在下面的基本测试中,开发者手动实例化这些类,并确保 controller 和 service 符合其 API 规范
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);
});
});
});
提示:将测试文件放置在它们测试的类附近。测试文件应具有 .spec 或 .test 后缀
测试程序
@nestjs/testing 包提供了一组实用程序,可以实现更强大的测试过程。使用内置的 Test 类重写前面的示例:
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() 方法。此方法引导模块及其依赖(类似于使用 NestFactory.create() 在常规 main.ts 文件中引导应用的方式),并返回准备好进行测试的模块
提示:compile() 方法是异步的,因此必须等待。编译模块后,可以使用get()方法查找获取它声明的任何静态实例(控制器和提供程序)
TestingModule 继承自模块引用类,因此它具有动态解析作用域 provider(临时或请求作用域)的能力。使用 resolve() 方法执行此操作(get() 方法只能查找获取静态实例)
const moduleRef = await Test.createTestingModule({
controllers: [CatsController],
providers: [CatsService],
}).compile();
catsService = await moduleRef.resolve(CatsService);
警告:resolve() 方法从其自己的 DI 容器子树返回提供器的唯一实例。每个子树都有一个唯一的上下文标识符。因此,如果多次调用此方法并比较实例引用,会发现它们不相等
可以使用自定义 provider 覆盖它以进行测试,而不是使用任何 provider 的生产版本。例如,可以模拟数据库服务而不是连接到实时数据库
自动模拟
Nest 还允许定义一个模拟工厂以应用于所有缺失的依赖。这对于类中有大量依赖并且模拟所有依赖将花费很长时间和大量设置的情况很有用。要使用此功能,需要将 createTestingModule() 与 useMocker() 方法链接起来,为依赖模拟传递一个工厂。这个工厂可以接受一个可选的 token,它是一个实例 token,任何对 Nest 提供器有效的 token,并返回一个模拟实现。下面是使用 jest-mock 创建通用模拟器和使用 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);
});
});
还可以像往常一样自定义 provider moduleRef.get(CatsService),从测试容器中查找获取这些模拟
提示:一般的 mock 工厂,比如 createMock from @golevelup/ts-jest 也可以直接传过去
提示:REQUEST 和 INQUIRER provider 无法自动模拟,因为它们已在上下文中预定义。但是,可以使用自定义 provider 语法或使用 .overrideProvider 方法覆盖它们
端到端测试
与侧重于单个模块和类的单元测试不同,端到端 (e2e) 测试涵盖类和模块在更聚合级别上的交互 - 更接近终端用户与产品之间的交互类型系统。随着应用的增长,手动测试每个 API 端点的端到端行为变得越来越困难。自动化的端到端测试帮助我们确保系统的整体行为正确并满足项目要求。为了执行 e2e 测试,使用刚刚在单元测试中介绍的配置类似的配置。此外,Nest 还可以轻松使用 Supertest 库来模拟 HTTP 请求
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();
});
});
提示:如果使用 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 请求(如 get '/cats')相同
在此示例中,还提供了 CatsService 的替代(测试替身)实现,它只返回一个可以测试的硬编码值。使用 overrideProvider() 提供这样的替代实现。类似地,Nest 提供了覆盖模块、守卫、拦截器、过滤器和管道的方法,分别使用 overrideModule()、overrideGuard()、overrideInterceptor()、overrideFilter() 和 overridePipe()方法
每个覆盖方法(overrideModule() 除外)都返回一个对象,该对象具有 3 种不同的方法,这些方法反映了 自定义 provider 中描述的方法:
- useClass:提供一个将被实例化的类,以提供实例来覆盖对象(提供器、守卫等)
- useValue:提供一个将覆盖该对象的实例
- useFactory:提供一个方法,该方法返回一个将覆盖该对象的实例
另一方面,overrideModule() 返回一个带有 useModule() 方法的对象,可以使用它来提供一个将覆盖原始模块的模块,如下所示:
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
})
.overrideModule(CatsModule)
.useModule(AlternateCatsModule)
.compile();
每个覆盖方法类型依次返回 TestingModule 实例,因此可以与 fluent style 中的其他方法链接。你应该在这样一个链的末尾使用 compile() 来使 Nest 实例化和初始化模块
此外,有时当测试运行时(例如,在 CI 服务器上)可能希望使用自定义日志器。使用 setLogger() 方法并传递一个满足 LoggerService 接口的对象来指示 TestModuleBuilder 在测试期间如何记录(默认情况下,只有 error 日志会记录到控制台)
编译后的模块有几个有用的方法,如下表所述:
| createNestApplication() | 基于给定 module 创建并返回 Nest 应用(INestApplication 实例)。请注意,必须使用init()方法手动初始化应用 |
|---|---|
| createNestMicroservice() | 基于给定 module 创建并返回 Nest 微服务(INestMicroservice 实例) |
| get() | 查找获取应用上下文中可用的 controller 或 provider(包括守卫、过滤器等)的静态实例 |
| resolve() | 查找获取应用上下文中可用的 controller 或 provider(包括守卫、过滤器等)的动态创建的作用域实例(请求或临时) |
| select() | 浏览模块的依赖图;可用于从所选模块中查找获取特定实例(与get()方法中的严格模式 (strict: true) 一起使用) |
提示:将 e2e 测试文件保存在 test 目录中。测试文件应具有 .e2e-spec 后缀
覆盖全局注册的增强器
如果有一个全局注册的守卫(或管道、拦截器或过滤器),需要采取更多的步骤来覆盖那个增强器。回顾一下原始注册如下所示:
providers: [
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
这是通过 APP_* token 将守卫注册为 multi provider。为了能够替换此处的 JwtAuthGuard,注册需要使用此插槽中的现有 provider:
providers: [
{
provide: APP_GUARD,
useExisting: JwtAuthGuard,
// ^^^^^^^^ notice the use of 'useExisting' instead of 'useClass'
},
JwtAuthGuard,
],
提示:将 useClass 更改为 useExisting 以引用注册的 provider,而不是让 Nest 在标记后面实例化它
现在,JwtAuthGuard 对 Nest 可见,作为常规提供程序可以在创建 TestingModule 时被覆盖:
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(JwtAuthGuard)
.useClass(MockAuthGuard)
.compile();
现在,所有测试都将在每次请求时使用 MockAuthGuard
测试请求作用域的实例
Request-scoped providers 是为每个传入请求唯一创建的。请求完成处理后,该实例将被垃圾回收。这带来了一个问题,无法访问专门为测试请求生成的依赖注入子树
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);