Nest.js 管道
管道是一个用 @Injectable()
装饰器注解的类,它实现了 PipeTransform
接口。
管道有两个典型的用例:
- 转换 :将输入数据转换为所需的形式(例如,从字符串到整数)。
- 验证 :评估输入数据,如果有效,则简单地通过不变; 否则,当数据不正确时抛出异常。
在这两种情况下,管道都对控制器路由处理程序正在处理的参数进行操作。 Nest 在调用方法之前插入了一个管道,管道接收到该方法的参数并对其进行操作。 任何转换或验证操作都在那个时候发生,之后使用任何(可能)转换的参数调用路由处理程序。
Nest 带有许多内置管道,我们可以开箱即用。 还可以构建自己的自定义管道。 在本章中,我们将介绍内置管道并展示如何将它们绑定到路由处理程序。 然后,我们将检查几个定制的管道,来展示如何从头开始构建一个。
提示
: 管道在异常区内运行。 这意味着当 Pipe 抛出异常时,它由异常层(全局异常过滤器和应用于当前上下文的任何异常过滤器)处理。 鉴于上述情况,应该清楚的是,当 Pipe 中抛出异常时,随后不会执行任何控制器方法。 这为我们提供了一种最佳实践技术,用于在系统边界验证从外部源进入应用程序的数据。
内置管道
Nest 提供了 8 个开箱即用的管道:
- ValidationPipe
- ParseIntPipe
- ParseFloatPipe
- ParseBoolPipe
- ParseArrayPipe
- ParseUUIDPipe
- ParseEnumPipe
- DefaultValuePipe
它们是从 @nestjs/common
包中导出的。
让我们快速了解一下 ParseIntPipe
的使用。 这是转换用例的示例,其中管道确保将方法处理程序参数转换为 JavaScript 整数(或在转换失败时引发异常)。 在本章后面,我们将展示 ParseIntPipe 的简单自定义实现。 下面的示例也适用于其他内置转换管道(ParseBoolPipe
、ParseFloatPipe
、ParseEnumPipe
、ParseArrayPipe
和 ParseUUIDPipe
,我们将在本章中将其称为 Parse*
管道)。
绑定管道
要使用管道,我们需要将管道类的实例绑定到适当的上下文。 在我们的 ParseIntPipe
示例中,我们希望将管道与特定的路由处理程序方法相关联,并确保它在调用该方法之前运行。 我们使用以下构造函数来实现这一点,我们将其称为在方法参数级别绑定管道:
@Get(':id')
async findOne(@Param('id', ParseIntPipe) id: number) {
return this.catsService.findOne(id);
}
这确保了以下两个条件之一为真:我们在 findOne()
方法中收到的参数是一个数字(正如我们对 this.catsService.findOne() 的调用中所预期的那样),或者在路由之前抛出异常 处理程序被调用。
例如,假设路由如下:
GET localhost:3000/abc
Nest 将要抛出一个异常
{
"statusCode": 400,
"message": "Validation failed (numeric string is expected)",
"error": "Bad Request"
}
该异常将阻止 findOne() 方法执行。
在上面的示例中,我们传递了一个类 (ParseIntPipe),而不是实例,将实例化的责任留给了框架并启用了依赖注入。 与管道和 Guard 一样,我们可以传递一个实例。 如果我们想通过传递选项来自定义内置管道的行为,传递实例很有用:
@Get(':id')
async findOne(
@Param('id', new ParseIntPipe({ errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE }))
id: number,
) {
return this.catsService.findOne(id);
}
绑定其他转换管道(所有 Parse*
管道)的工作方式类似。 这些管道都在验证路由参数、查询字符串参数和请求正文值的上下文中工作。
例如使用查询字符串参数:
@Get()
async findOne(@Query('id', ParseIntPipe) id: number) {
return this.catsService.findOne(id);
}
这是一个使用 ParseUUIDPipe
解析字符串参数并验证它是否为 UUID 的示例。
@Get(':uuid')
async findOne(@Param('uuid', new ParseUUIDPipe()) uuid: string) {
return this.catsService.findOne(uuid);
}
提示
: 使用 ParseUUIDPipe() 时,我们正在解析版本 3、4 或 5 中的 UUID,如果只需要特定版本的 UUID,那么我们可以在管道选项中传递一个版本作为参数。
上面我们已经看到了绑定各种 Parse*
系列内置管道的示例。 绑定验证管道有点不同; 我们将在下一节讨论这个问题。
自定义管道
如前所述,我们可以构建自己的自定义管道。 虽然 Nest 提供了强大的内置 ParseIntPipe
和 ValidationPipe
,但让我们从头开始构建每个的简单自定义版本,来了解如何构造自定义管道。
我们从一个简单的 ValidationPipe
开始。 最初,我们将让它简单地接受一个输入值并立即返回相同的值,这像一个幂等函数。
validation.pipe.ts
import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common'; @Injectable() export class ValidationPipe implements PipeTransform { transform(value: any, metadata: ArgumentMetadata) { return value; } }
提示
: PipeTransform<T, R> 是必须由任何管道实现的通用接口。 泛型接口使用 T 表示输入值的类型,使用 R 表示 transform() 方法的返回类型。
每个管道都必须实现 transform() 方法来实现 PipeTransform 接口契约。 该方法有两个参数:
- value
- metadata
value 参数是当前处理的方法参数(在被路由处理方法接收之前),metadata 是当前处理的方法参数的元数据。 元数据对象具有以下属性:
export interface ArgumentMetadata {
type: 'body' | 'query' | 'param' | 'custom';
metatype?: Type<unknown>;
data?: string;
}
这些属性描述了当前处理的参数。
参数 | 描述 |
---|---|
type | 告诉我们该属性是一个 body @Body(),query @Query(),param @Param() 还是自定义参数 在这里阅读更多。 |
metatype | 属性的元类型,例如 String。 如果在函数签名中省略类型声明,或者使用原生 JavaScript,则为 undefined。 |
data | 传递给装饰器的字符串,例如 @Body('string')。 如果您将括号留空,则为 undefined。 |
警告
:TypeScript 接口在转译期间消失。 因此,如果方法参数的类型被声明为接口而不是类,则元类型值将是 Object。
基于模式的验证
让我们的验证管道更有用一点。 仔细看看 CatsController
的 create()
方法,我们可能希望在尝试运行我们的服务方法之前确保 post body 对象是有效的。
@Post()
async create(@Body() createCatDto: CreateCatDto) {
this.catsService.create(createCatDto);
}
让我们看一下 createCatDto
主体参数。 它的类型是 CreateCatDto:
create-cat.dto.ts
export class CreateCatDto { name: string; age: number; breed: string; }
我们希望确保对 create 方法的任何传入请求都包含有效的正文。 所以我们必须验证 createCatDto 对象的三个成员。 我们可以在路由处理程序方法中执行此操作,但这样做并不理想,因为它会破坏单一责任规则 (SRP)。
另一种方法可能是创建一个验证器类并在那里委派任务。 这样做的缺点是我们必须记住在每个方法的开头调用这个验证器。
如何创建验证中间件? 这可以工作,但不幸的是,不可能创建可以在整个应用程序的所有上下文中使用的通用中间件。 这是因为中间件不知道执行上下文,包括将被调用的处理程序及其任何参数。
当然,这正是管道设计的用例。 因此,让我们继续完善我们的验证管道。
对象架构验证
有几种方法可用于以 DRY 方式进行对象验证。 一种常见的方法是使用基于模式的验证。 让我们继续尝试这种方法。
Joi 库允许我们使用可读的 API 以直接的方式创建模式。 让我们构建一个使用基于 Joi 的模式的验证管道。
首先安装所需的软件包:
$ npm install --save joi
$ npm install --save-dev @types/joi
在下面的代码示例中,我们创建了一个将模式作为构造函数参数的简单类。 然后我们应用 schema.validate()
方法,根据提供的模式验证我们的传入参数。
如前所述,验证管道要么返回未更改的值,要么引发异常。
在下一节中,我们将看到我们如何使用 @UsePipes()
装饰器为给定的控制器方法提供适当的模式。 这样做使我们的验证管道可以跨上下文重用,就像我们开始做的那样。
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
import { ObjectSchema } from 'joi';
@Injectable()
export class JoiValidationPipe implements PipeTransform {
constructor(private schema: ObjectSchema) {}
transform(value: any, metadata: ArgumentMetadata) {
const { error } = this.schema.validate(value);
if (error) {
throw new BadRequestException('Validation failed');
}
return value;
}
}
绑定验证管道
早些时候,我们看到了如何绑定转换管道(如 ParseIntPipe 和其余的 Parse*
管道)。
绑定验证管道也非常简单。
在这种情况下,我们希望在方法调用级别绑定管道。 在我们当前的示例中,我们需要执行以下操作才能使用 JoiValidationPipe
:
- 创建 JoiValidationPipe 的实例
- 在管道的类构造函数中传递特定于上下文的 Joi 模式
- 将管道绑定到方法
我们使用 @UsePipes() 装饰器来做到这一点,如下所示:
@Post()
@UsePipes(new JoiValidationPipe(createCatSchema))
async create(@Body() createCatDto: CreateCatDto) {
this.catsService.create(createCatDto);
}
提示
: @UsePipes() 装饰器是从 @nestjs/common 包中导入的。
类验证器
警告
:本节中的技术需要 TypeScript,如果应用程序是使用 vanilla JavaScript 编写的,则不可用。
让我们看一下验证技术的替代实现。
Nest 与类验证器库配合得很好。 这个强大的库允许我们使用基于装饰器的验证。 基于装饰器的验证非常强大,尤其是与 Nest 的 Pipe 功能结合使用时,因为我们可以访问已处理属性的元类型。 在开始之前,我们需要安装所需的软件包:
$ npm i --save class-validator class-transformer
安装这些后,我们可以向 CreateCatDto
类添加一些装饰器。 在这里,我们看到了这种技术的一个显着优势:CreateCatDto 类仍然是我们的 Post body 对象的唯一真实来源(而不是必须创建一个单独的验证类)。
create-cat.dto.ts
import { IsString, IsInt } from 'class-validator'; export class CreateCatDto { @IsString() name: string; @IsInt() age: number; @IsString() breed: string; }
现在我们可以创建一个使用这些注解的 ValidationPipe
类。
validation.pipe.ts
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common'; import { validate } from 'class-validator'; import { plainToClass } from 'class-transformer'; @Injectable() export class ValidationPipe implements PipeTransform<any> { async transform(value: any, { metatype }: ArgumentMetadata) { if (!metatype || !this.toValidate(metatype)) { return value; } const object = plainToClass(metatype, value); const errors = await validate(object); if (errors.length > 0) { throw new BadRequestException('Validation failed'); } return value; } private toValidate(metatype: Function): boolean { const types: Function[] = [String, Boolean, Number, Array, Object]; return !types.includes(metatype); } }
注意
: 上面,我们使用了类转换器库。 它与类验证器库是由同一作者开发的,因此它们可以很好地配合使用。
让我们来看看这段代码。首先,请注意 transform()
方法被标记为异步。这是可能的,因为 Nest 同时支持同步和异步管道。我们使这个方法异步,因为一些类验证器验证可以是异步的(利用 Promises)。
接下来请注意,我们使用解构将元类型字段(仅从 ArgumentMetadata 中提取此成员)提取到我们的元类型参数中。这只是获取完整 ArgumentMetadata
然后有一个附加语句来分配元类型变量的简写。
接下来,注意辅助函数 toValidate()
。当目前正在处理的参数是原生 JavaScript 类型时,它负责绕过验证步骤(这些不能附加验证装饰器,因此没有理由通过验证步骤运行它们)。
接下来,我们使用类转换器函数 plainToClass()
将我们的普通 JavaScript 参数对象转换为类型化对象,以便我们可以应用验证。我们必须这样做的原因是传入的 post body 对象在从网络请求反序列化时没有任何类型信息(这是底层平台(例如 Express)的工作方式)。 Class-validator
需要使用我们之前为 DTO 定义的验证装饰器,因此我们需要执行此转换以将传入的主体视为经过适当装饰的对象,而不仅仅是普通的 vanilla 对象。
最后,如前所述,由于这是一个验证管道,它要么返回未更改的值,要么抛出异常。
最后一步是绑定 ValidationPipe
。管道可以是参数范围、方法范围、控制器范围或全局范围。早些时候,在我们基于 Joi 的验证管道中,我们看到了在方法级别绑定管道的示例。在下面的示例中,我们将管道实例绑定到路由处理程序 @Body()
装饰器,以便调用我们的管道来验证帖子正文。
cats.controller.ts
@Post() async create( @Body(new ValidationPipe()) createCatDto: CreateCatDto, ) { this.catsService.create(createCatDto); }
当验证逻辑只涉及一个指定的参数时,参数范围的管道很有用。
全局范围的管道
由于 ValidationPipe
被创建为尽可能通用,我们可以通过将其设置为全局范围的管道来实现它的完整实用程序,以便将其应用于整个应用程序中的每个路由处理程序。
main.ts
async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalPipes(new ValidationPipe()); await app.listen(3000); } bootstrap();
注意
: 对于混合应用程序,useGlobalPipes() 方法不会为网关和微服务设置管道。 对于“标准”(非混合)微服务应用程序,useGlobalPipes() 会全局安装管道。
全局管道用于整个应用程序,用于每个控制器和每个路由处理程序。
请注意,在依赖注入方面,从任何模型外部注册的全局管道(使用上面示例中的 useGlobalPipes() )无法注入依赖项,因为绑定是在任何模型的上下文之外完成的。 为了解决这个问题,我们可以使用以下结构直接从任何模型设置全局管道:
app.module.ts
import { Module } from '@nestjs/common'; import { APP_PIPE } from '@nestjs/core'; @Module({ providers: [ { provide: APP_PIPE, useClass: ValidationPipe, }, ], }) export class AppModule {}
提示
:当使用这种方法对管道执行依赖注入时,请注意,无论使用此构造的模型如何,管道实际上都是全局的。 这应该在哪里完成? 选择定义管道(上例中的ValidationPipe)的模型。 此外,useClass 不是处理自定义提供程序注册的唯一方法。
内置的 ValidationPipe
提醒一下,我们不必自己构建通用验证管道,因为 ValidationPipe
由开箱即用的 Nest 提供。 内置的 ValidationPipe 提供了比我们在本章中构建的示例更多的选项,为了说明定制管道的机制,该示例保持基本状态。 我们可以在此处找到完整的详细信息以及大量示例。
转换用例
验证并不是自定义管道的唯一用例。 在本章开头,我们提到管道还可以将输入数据转换为所需的格式。 这是可能的,因为从转换函数返回的值完全覆盖了参数的先前值。
这什么时候有用? 考虑到有时从客户端传递的数据需要进行一些更改 - 例如将字符串转换为整数 - 才能由路由处理程序方法正确处理。 此外,可能缺少一些必需的数据字段,我们希望应用默认值。 转换管道可以通过在客户端请求和请求处理程序之间插入处理功能来执行这些功能。
这是一个简单的 ParseIntPipe
,它负责将字符串解析为整数值。 (如上所述,Nest 有一个更复杂的内置 ParseIntPipe;我们将其作为自定义转换管道的一个简单示例)。
parse-int.pipe.ts
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common'; @Injectable() export class ParseIntPipe implements PipeTransform<string, number> { transform(value: string, metadata: ArgumentMetadata): number { const val = parseInt(value, 10); if (isNaN(val)) { throw new BadRequestException('Validation failed'); } return val; } }
然后我们可以将此管道绑定到选定的参数,如下所示:
@Get(':id')
async findOne(@Param('id', new ParseIntPipe()) id) {
return this.catsService.findOne(id);
}
另一个有用的转换示例是使用请求中提供的 id 从数据库中选择现有用户实体:
@Get(':id')
findOne(@Param('id', UserByIdPipe) userEntity: UserEntity) {
return userEntity;
}
提供默认值
Parse*
管道期望定义一个参数的值。 它们在接收到 null 或 undefined 值时抛出异常。 为了允许端点处理丢失的查询字符串参数值,我们必须在 Parse*
管道对这些值进行操作之前提供要注入的默认值。 DefaultValuePipe
用于此目的。 只需在相关 Parse*
管道之前的 @Query() 装饰器中实例化 DefaultValuePipe,如下所示:
@Get()
async findAll(
@Query('activeOnly', new DefaultValuePipe(false), ParseBoolPipe) activeOnly: boolean,
@Query('page', new DefaultValuePipe(0), ParseIntPipe) page: number,
) {
return this.catsService.findAll({ activeOnly, page });
}