NestJS08-Pipes

407 阅读10分钟

一个管道Pipes类需要用@Injectable()装饰器来装饰,它实现了PipeTransForm接口

未命名文件.png

管道有2种比较典型的使用场景

  • 转换:把进入的数据转换成希望的形式(比如:String转Integer)
  • 验证:验证数据如果有效就让它通过,否则抛出一个异常。

这2种场景,都是对Controller的路由参数进行操作的,管道拦截是在一个方法被执行之前,管道取得的参数是这个方法所要用到的参数。任何转换或者验证都是在这个时候进行的。之后使用任何(可能)转换的参数调用路由处理程序。

Nest有许多的内置管道可以是使用,也可以创建自己的自定义管道。这章主要的介绍内置管道的。后面也会简单举例一个自定义管道

提示:
管道在esceptions区域内运行。这意味着当管道抛出异常时,它由异常层(全局异常过滤器和应用于当前上下文的任何异常过滤器)处理。考虑到上述情况,应该清楚的是,当在管道中抛出异常时,随后不会执行任何控制器方法。这为您提供了一种最佳实践技术,用于验证从系统边界的外部源进入应用程序的数据。

内置管道

Nest有9种内置管道,他们都是从@nestjs/common包导出的。

  • ValidationPipe
  • ParseIntPipe
  • ParseFloatPipe
  • ParseBoolPipe
  • ParseArrayPipe
  • ParseUUIDPipe
  • ParseEnumPipe
  • DefaultValuePipe
  • ParseFilePipe

许多管道看名字就能很好的理解,比如ParseIntPipe管道,它可以把请求的String类型的数据转换成integer类型。稍后会做一些介绍。

绑定管道

ParseIntPipe管道绑定的例子,通过下面写法,可以将管道绑定懂路由方法上。

  @Get(':id')
  async findOne(@Param('id', ParseIntPipe) id: number) {
    return this.catsService.findOne(id);
  }

这会产生2种结果:

  • 我们在findOne()方法中收到的参数是一个数字(正如我们对This.catsService.findOne()的调用中所预期的那样)
  • 或者在调用路由处理程序之前引发异常。因为没传数字,所以会发上Bad Reqeust

例如,正常的情况

GET localhost:3000/abc

和异常的情况

{
  "statusCode": 400,
  "message": "Validation failed (numeric string is expected)",
  "error": "Bad Request"
}

这个异常会阻止findOne方法执行。

在上面的例子中,传递的是一个类,而非实例,nest会自动的执行依赖注入。我们也可通过创建实例的方法来自定义管道的行为。

其他的转换型的管道的使用方法类似。大家可以自己尝试下各种转换管道。

自定义管道

可以自定义管道,先从一个简单的继承自PipeTransform的类开始,

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

@Injectable()
export class ValidationPipe implements PipeTransform {
  transform(value: any, metadata: ArgumentMetadata) {
    return value;
  }
}

每个管道都必须实现transform这个方法,这个方法有2个参数:

  • 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提供参数的元类型,例如1string。注意:如果在路由处理程序方法签名中省略类型声明,或者使用普通JavaScript,则该值为undefined
data传递给装饰器的字符串,例如@Body('string')。如果将修饰符括号留空,则为undefined
注意:TypeScript接口在转换过程中消失。因此,如果方法参数的类型声明为接口而不是类,则元类型值将为Object

基于结构的验证

大部份情况下,我们希望传入到路由方法里面的内容是经过验证符合规范的数据。在CatsController加入下面的方法

@Controller('cats')
export class CatsController {
  constructor(private catsService: CatsService) {}
  @Post()
  async create(@Body() createCatDto) {
    this.catsService.create(createCatDto);
  }
}

然后把注意力集中到CreateCatDto

export class CreateCatDto {
  name: string;
  age: number;
  breed: string;
}

我们想要验证Dto里面的每个成员是否正确。我们可以在CatsController里面把每个dto的成员拿出来做判断,但是这不是一个很好的方法,这违反了单一职责

还有一种比较接近的方法,就是创建一个验证类,把所有的验证委托给它。但是他的缺点是必须在每个方法调用的时候先调用验证类。

那可不可以创建一个中间件来解决?但是不幸的是不可能创建一个通用的中间件来处理所有经过程序的方法。这是因为中间件不知道执行上下文,包括将被调用的处理程序及其任何参数。

当然,这正是管道设计的用例。因此,让我们继续完善我们的验证管道

对象结构验证

有几种对象结构验证的方法。Joi可以做这种验证。首先我们先安装依赖。

$ npm install --save joi

在下面的例子中通过constructor构造函数来获得验证类。然后调用schema.validate()来验证进来的参数

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;
  }
}

在下面的绑定验证管道里面,使用@UsePipes()来验证。

绑定验证管道

以前我们看了类型转换管道例如:ParseIntPipe等等

绑定验证管道也比较简单

下面的例子是方法级别的验证,我们需要做以下几点来绑定JoiValidationPipe.

  1. 创建JoiValidationPipe的实例
  2. 在管道的类构造函数中传递上下文特定的Joi模式
  3. 在方法上绑定这个管道

joi的例子

import Joi from 'joi';

export const createCatSchema = Joi.object({
  name: Joi.string().required(),
  age: Joi.number().required(),
  breed: Joi.string().required(),
});

export class CreateCatDto {
  name: string;
  age: number;
  breed: string;
}

@UsePipes()的使用方法如下

  @Post()
  @UsePipes(new JoiValidationPipe(createCatSchema))
  async create(@Body() createCatDto: CreateCatDto) {
    this.catsService.create(createCatDto);
  }

类验证器

警告:
本节中的技术需要TypeScript,如果应用程序使用普通JavaScript编写,则不可用。

让我们来看一下另一种实现技术

Nest和class-validator配合的很。这个强大的工具能够让我们进行基于装饰器的验证。基于装饰器的验证非常强大,特别是和Nest的管道一起使用的时候。使用之前必须先安装依赖:

$ npm i --save class-validator class-transformer

一旦安装完成,我们可以在CreateDto类里面加上装饰器。这里能看出它的一个优势:CreateDto只需要这一个文件就能完成验证(相比新建一个验证类要好一些)

import {IsString, IsInt} from 'class-validator'

export class CreateCatDto {
  @IsString()
  name: string;

  @IsInt()
  age: number;

  @IsString()
  breed: string;
}

class-validator

现在我们创建使用这些注解的ValidationPipe

import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';
import { validate } from 'class-validator';
import { plainToInstance } 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 = plainToInstance(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);
  }
}
class-transformer和class-validator是同一个作者写的。所以他们能很好的在一起工作

让我们来看一下这段代码,注意到transform()方法使用了async。Nest支持同步和非同步的管道。我们使用async是因为某些class-validator的验证可以是非同步的。

接着请注意,我们使用析构函数将元类型字段(仅从ArgumentMetadata中提取此成员)提取metatype。这只是获取完整的ArgumentMetadata,然后使用附加语句分配元类型变量的简写。

注意帮助方法toValidate().当当前处理的参数是本机JavaScript类型时,它负责绕过验证步骤 (它们不能附加验证修饰符,因此没有理由在验证步骤中运行它们)

接下来,我们使用class-transformer的plainToInstance()方法来转换JavaScript参数对象到有类型的对象从而能够进行验证。我们必须这样做的原因是,传入的post的body对象在从网络请求反序列化时没有任何类型信息(这是基础平台(如Express)的工作方式)。类验证器需要使用我们之前为DTO定义的验证修饰符,因此我们需要执行此转换,将传入主体视为一个经过适当修饰的对象,而不仅仅是一个普通对象。

最后,就像之前说的那样管道验证要么返回原来的值要么抛出异常。

最最后的一步是绑定ValidationPipe。管道的范围有参数,方法,控制器或者全局。之前,我们用基于Joi的验证器做了一个方法层面的验证。在下面的例子里,我们在@Body()装饰器里面添加管道实例,然后我们就能验证post的body数据了。

  @Post()
  async create(@Body(new ValidationPipe()) createCatDto: CreateCatDto) {
    this.catsService.create(createCatDto);
  }

当我们的想验证某个特定的参数时,参数范围的管道是很有用的

全局范围管道

自从ValidationPipe被创建后,我们可以设置一个全局范围的管道从而使每个请求的路由都能进入验证管道

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  // 全局验证管道
  app.useGlobalPipes(new ValidationPipe());
  await app.listen(3000);
}
bootstrap();
对于混合应用程序,useGlobalPipes()方法不为网关和微服务设置管道。对于“标准”(非混合)微服务应用程序,使用GlobalPipes()可以全局安装管道。

请注意,在依赖项注入方面,从任何模块外部注册的全局管道(使用上面示例中的useGlobalPipes())都不能注入依赖项,因为绑定是在任何模块的上下文之外完成的。为了解决此问题,可以使用以下构造直接从任何模块设置全局管道:

import { ValidationPipe } from './validation/validation.pipe';
import { Module } from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';
import { CatsModule } from './cats/cats.module';

@Module({
  imports: [CatsModule],
  controllers: [],
  providers: [
    {
      provide: APP_PIPE,
      useClass: ValidationPipe,
    },
  ],
})
export class AppModule {}
提示:
当使用这种方法为管道执行依赖注入时,请注意,不管使用这种构造的模块是什么,实际上管道是全局的。这应该在哪里进行?选择定义管道(上面示例中的ValidationPipe)的模块。此外,useClass不是处理自定义提供程序注册的唯一方法。请参照:https://docs.nestjs.com/fundamentals/custom-providers。

内置验证管道

需要提醒的是,由于ValidationPipe是由Nest开箱即用提供的,因此您不必自行构建通用验证管道。内置ValidationPipe提供了比我们在本章中构建的示例更多的选项。您可以参照:docs.nestjs.com/techniques/…

转换使用例子

对于自定义管道来说,验证并不是唯一的使用场景。在本开始的时候提到,管道可以做数据转换。这是因为transform的返回值会覆盖之前传入的参数。

这个东西什么时候有用?设想一下,如果我们想把请求过来的参数类型字符串转成数字行,在调用方法之前就能进行,甚至,当某个参数缺失了,我们可以设定一个默认值给这个参数。转换管道可以通过在客户端请求和请求处理程序之间插入处理函数来执行这些功能。

这里有一个简单的ParseIntPipe,它负责将字符串解析为整数值。(如上所述,Nest有一个更复杂的内置ParseIntPipe;我们将其作为自定义转换管道的一个简单示例)。

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')
@Bind(Param('id', UserByIdPipe))
findOne(userEntity) {
  return userEntity;
}

我们将此管道的实现留给读者,但请注意,与所有其他转换管道一样,它接收输入值(id)并返回输出值(UserEntity对象)。通过将样板代码从处理程序中抽象出来并放入一个公共管道中,这可以使代码更具声明性和DRY性。

提供默认值

Parse*管道期望一个参数是被定义的。当收到nullundefined的值时他们会抛出异常。为了能处理这些值缺失的数据,我们必须提供一个默认值在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 });
}

本章代码

代码