深入了解Nest的管道

2,047 阅读3分钟

在Nest的执行顺序中,管道的所处的位置是middleware之后在router(controller中的方法)之前。其作用总的来说只有两个:验证输入的数据是否合法以及转换参数(一般都是字符串类型)到合适的类型。

内建管道

Nest内建了6个管道类:

类名作用
ValidationPipe验证参数有效性
ParseIntPipe转换为整形
ParseBoolPipe转换为布尔形
ParseArrayPipe转换为数组
ParseUUIDPipe转换为UUID
DefaultValuePipe默认值管道

以上类型均来自于@nestjs/common包中。

用法

在控制器中创建一个使用管道的方法:

  @Get('get')
  get(@Query('id', ParseIntPipe) id: number): string {
	return `result: ${id ** 2}`;
  }
curl -X GET "http://localhost:3000/get?id=abc"
# {"statusCode":400,"message":"Validation failed (numeric string is expected)","error":"Bad Request"}
curl -X GET "http://localhost:3000/get?id=12"
# result: 144

对于不同的请求方式,例如@Param()或者@Body()用法是一致的。作为参数之一的管道类型,可以是一个类也可以将其实例化后传入:new ParseIntPipe()。例如使用UUID转换管道时,需要实例化管道指定UUID的版本(3以后)。

通过上面的例子可以看到,如果输入的参数类型非法,Nest会引发一个异常,然而在先前的文章中讲解异常过滤器时我们提到过从中间件开始到控制其中,都属于异常过滤器的控制范畴,自然,在管道中出现的异常,也会被异常过滤器捕获处理。默认情况下会引发一个BadRequestException(400)的异常。

源码分析

从最简单的ParseIntPipe入手,我们了解下它的源代码:

export interface ParseIntPipeOptions {
  errorHttpStatusCode?: ErrorHttpStatusCode;
  exceptionFactory?: (error: string) => any;
}

@Injectable()
export class ParseIntPipe implements PipeTransform<string> {
  protected exceptionFactory: (error: string) => any;

  constructor(@Optional() options?: ParseIntPipeOptions) {
    options = options || {};
    const {
      exceptionFactory,
      errorHttpStatusCode = HttpStatus.BAD_REQUEST,
    } = options;

    this.exceptionFactory =
      exceptionFactory ||
      (error => new HttpErrorByCode[errorHttpStatusCode](error));
  }

  async transform(value: string, metadata: ArgumentMetadata): Promise<number> {
    const isNumeric =
      ['string', 'number'].includes(typeof value) &&
      !isNaN(parseFloat(value)) &&
      isFinite(value as any);
    if (!isNumeric) {
      throw this.exceptionFactory(
        'Validation failed (numeric string is expected)',
      );
    }
    return parseInt(value, 10);
  }
}

从其构造函数可以看出,其构造函数支持输入指定的异常工厂和Http状态码。很明显,可以用于让特定的过滤器捕获后进行下一步的处理。transform函数是受到PipeTransform接口的限制。value是被传入调用控制器的参数,metadata虽然在这里没有被用到,但是从源码中可以得知其是处理当前处理请求的元数据。

export interface ArgumentMetadata {
  readonly type: 'body' | 'query' | 'param' | 'custom';
  readonly metatype?: Type<any> | undefined;
  readonly data?: string | undefined;
}
  • type:参数的装饰器,@Body()@Query()@Param()或者自定义装饰器;
  • metatype:参数的类型,例如@Query('id') id:number,那么它的值就是number(类型)
  • data:参数装饰其中志明的参数名称,上例:id

自定义类型转换

DTO

DTO (Data Transfer Object) 数据传输对象,是一种以类的形式实际为数据的对象,用于前后端或程序间传输。例如有一个消息对象:

export class Message {
  fromUserId: number;
  toUserId: number;
  title: string;
  sentDate: Date;
  body: string;
}

用于接受来自与前端提交来的消息发送请求,那么验证对象是否合法的过程可以放在控制器的具体方法中,但是这样并不优雅(违反单一任务原则)。可以通过管道来处理转换、验证过程。

JOI

JOI用于检测数据类型是否合法的组件。安装到项目中,相关使用文档参见这里

cnpm install --save joi
cnpm install --save-dev @types/joi

添加一个验证程序

// /src/message.schema.ts
const Joi = require('joi');
export const MessageSchema = Joi.object({
  fromUserId: Joi.number().integer().min(1).required(),
  toUserId: Joi.number().integer().min(1).required(),
  title: Joi.string().max(20).required(),
  sendDate: Joi.date().default(Date.now).greater('now'),
  body: Joi.string().required(),
});

创建一个JOI的管道验证参数:

// /src/joi.pipe.ts
import {
  ArgumentMetadata,
  BadRequestException,
  Injectable,
  PipeTransform,
} from '@nestjs/common';
import { ObjectSchema } from 'joi';

@Injectable()
export class JoiPipe implements PipeTransform {
  constructor(private schema: ObjectSchema) {}

  transform(value: any, metadata: ArgumentMetadata) {
    const { error } = this.schema.validate(value);
    if (error) {
      throw new BadRequestException(
        `${error.details.map((d) => d.message).join('\n')} \n 验证失败`,
      );
    }
    return value;
  }
}

app.controller添加一个post的方法,接受参数:

  @Post('submit')
  @UsePipes(new JoiPipe(MessageSchema))
  save(@Body() message: MessageDto): string {
    return 'received.';
  }

尝试调用:

curl -X POST http://localhost:3000/submit -d '{"fromUserId":"1","toUserId":"2","title":"hello world","sendDate":"2021-2-11","body":"more details"}' -H "Content-Type: application/json"
# received

将提交的内容删除某个字段,将会因此无法通过验证,引发异常以及提示相关的信息。在引入管道的控制器中,采用了@UserPipes()装饰器,其效果等同于在@Body()中使用。

class-validator

还有一种数据验证的方法,就是采用class-validator组件,相对JOI的链式声明数据类型,它采用装饰器的方式来声明数据类型:

cnpm i --save class-validator class-transformer

修改message.dto.ts:

import { Type } from 'class-transformer';
import { IsString, IsInt, Min, MaxLength, IsDateString } from 'class-validator';
export class MessageDto {
  @IsInt()
  @Min(1)
  fromUserId: number;
  @IsInt()
  @Min(1)
  toUserId: number;
  @IsString()
  @MaxLength(20)
  title: string;
  @IsDateString()
  sentDate: Date;
  @IsString()
  body: string;
}

创建一个验证管道nest g pi validation

import {
  ArgumentMetadata,
  BadRequestException,
  Injectable,
  PipeTransform,
} from '@nestjs/common';
import { plainToClass } from 'class-transformer';
import { validate } from 'class-validator';

@Injectable()
export class ValidationPipe implements PipeTransform {
  async transform(value: any, { metatype }: ArgumentMetadata) {
    if (!metatype || !this.isPlainType(metatype)) {
      return value;
    }
    const object = plainToClass(metatype, value);
    const errors = await validate(object);
    if (errors.length > 0) {
      console.log(errors);
      let strErrors = errors
        .map((er) => {
          return Object.entries(er.constraints)
            .map((c) => {
              return c[1];
            })
            .join('\n');
        })
        .join('\n');
      throw new BadRequestException(`${strErrors}\n验证失败`);
    }
    return value;
  }

  private isPlainType(metatype: Function): boolean {
    const types: Function[] = [String, Boolean, Number, Array, Object];
    return !types.includes(metatype);
  }
}

注意,原先的transform的第二个参数metadata改为解构的metatype,用于判断传入的对象是否为普通的类型。

JOI和class-validator个有特色,且两者有些许细微差别:例如,JOI允许传入值为字符串,只要能够转换为数字就不会抛出异常,但是class-validator则必须是JSON格式严格的数值(不带引号);再有:class-validator允许自定义格式错误的输出提示,这样对中文比较友好,而JOI则没有这个功能。

多重管道

作用域与优先级

与过滤器稍有不同,除了方法、控制器和全局作用于下,管道最细可以控制到参数颗粒度。在执行顺序上,依次是全局、控制器、方法和参数。

与过滤器类似,注册全局作用域也是两种方式,一种是在main.ts中注册,且无法委托处理依赖注入:

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe());
  await app.listen(3000);
}
bootstrap();

第二种是注册到任意一个模块中:

import { Module } from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';

@Module({
  providers: [
    {
      provide: APP_PIPE,
      useClass: ValidationPipe,
    },
  ],
})
export class AppModule {}

注意:,除了参数层级的管道声明,其他的管道声明只能识别以@Body()装饰器声明的参数。例如:

@Controller()
@UsePipes(APipe)
export class AppController {
	@Get('get')
  @UsePipes(BPipe)
  get(@Query('id', CPipe) id: number): string {
    return `result: ${id ** 2}`;
  }
}

在上述代码中,只有CPipe的transform能接收到id值,APipeBPipe和全局管道,能被调用执行,且metadata是有内容的(接口的参数形态,以上代码为例{ metatype: [Function: Number], type: 'query', data: 'id' }),但是value为undefined

多管道

与中间件略有不同,中间件并不强调某几个即将执行的中间件顺序如何,但是管道有较为明确的执行顺序关系。除了上述不同作用域上执行的顺序不同,在同一个层级中,也可以有多个按顺序执行的管道。例如:

  @Get('get')
  @UsePipes(Method2Pipe, MethodPipe)
  get(@Query('id', CustomPipe) id: number): string {
    return `result: ${id ** 2}`;
  }

在方法作用域中,存在两个管道,那么Nest会从左到右依次执行。利用这个特性,我们非但可以创建用于验证的参数的管道,还可以创建用于设置默认值的管道。

// /src/default-for-int.pipe.ts
import { ArgumentMetadata, Injectable, PipeTransform } from '@nestjs/common';

@Injectable()
export class DefaultForIntPipe implements PipeTransform {
  transform(value: any, metadata: ArgumentMetadata) {
    // console.log('default value pipe');
    if (!value) {
      return 2;
    }
    return value;
  }
}

此时,如果调用时没有带有任何参数,那么传递给下一个管道时,value为2。