Nestjs的管道与DTO验证

在之前的搭建CRUD代码内容中,我们提到过管道配合class-validator做验证,这一节我们来看一下什么是管道,以及验证的一个流程。

我们先通过nest g res phones --no-spec生成一个新的模块并将其controllerservice中的预置内容清空掉,至于entitydto暂时先不关注,我们先通过简单示例来说一下管道。

初始代码内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// phones.controller.ts
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
} from '@nestjs/common'
import { PhonesService } from './phones.service'
import { CreatePhoneDto } from './dto/create-phone.dto'
import { UpdatePhoneDto } from './dto/update-phone.dto'

@Controller('phones')
export class PhonesController {
constructor(private readonly phonesService: PhonesService) {}
}
1
2
3
4
5
6
7
// phones.service.ts
import { Injectable } from '@nestjs/common'
import { CreatePhoneDto } from './dto/create-phone.dto'
import { UpdatePhoneDto } from './dto/update-phone.dto'

@Injectable()
export class PhonesService {}

管道

管道有两个典型的应用场景:

  • 转换:管道将输入数据转换为所需的数据输出(例如,将字符串转换为整数)
  • 验证:对输入数据进行验证,如果验证成功继续传递; 验证失败则抛出异常

Nest 自带九个开箱即用的管道,即

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

转换管道

ParseIntPipe

先来看一个用法简单的管道ParseIntPipe,我们如果想要在方法参数级别绑定管道,实现管道与特定的路由处理程序方法相关联,并确保它在该方法被调用之前运行,可以通过如下方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// phones.controller.ts
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
ParseIntPipe,
} from '@nestjs/common'
import { PhonesService } from './phones.service'
import { CreatePhoneDto } from './dto/create-phone.dto'
import { UpdatePhoneDto } from './dto/update-phone.dto'

@Controller('phones')
export class PhonesController {
constructor(private readonly phonesService: PhonesService) {}
@Get('findBy/:ram')
findByRam(@Param('ram', ParseIntPipe) ram: number) {
return {
ram,
ramType: typeof ram,
}
}
}

接下来调试接口:

1
2
3
4
5
// localhost:3000/nest/phones/findBy/8
{
"ram": 8,
"ramType": "number"
}

我们传递了一个param参数,毫无疑问ram参数是一个字符串,但我们得到的结果,ram参数是一个number,这就是ParseIntPie的能力。

如果不能转换为数值会怎么样呢?

1
2
3
4
5
6
// localhost:3000/nest/phones/findBy/abc
{
"statusCode": 400,
"message": "Validation failed (numeric string is expected)",
"error": "Bad Request"
}

答案是会抛出一个错误,将abc转换为数值本就不可能的。截一段ParseIntPipe的源码,内部transform的实现:

1
2
3
4
5
6
async transform(value, metadata) {
if (!this.isNumeric(value)) {
throw this.exceptionFactory('Validation failed (numeric string is expected)');
}
return parseInt(value, 10);
}

现在肯定有人有疑问,我们之前绑定内容都是用的@UserXXXX装饰器,为什么管道不是这样呢?其实管道绑定也有一个@UserPipes装饰器,我们后边会说,这里这么用因为比较方便,可以看一下@Param的类型声明就懂了:

1
2
// @Param
export declare function Param(property: string, ...pipes: (Type<PipeTransform> | PipeTransform)[]): ParameterDecorator;

其他的一些转换管道其实是查不多的,我们来大概看一下。

ParseFloatPipe

controller实现:

1
2
3
4
5
6
7
8
// phones.controller.ts
@Get('parseFloat/:n')
parseFloat(@Param('n', ParseFloatPipe) n: number) {
return {
n,
dataType: typeof n,
}
}

调试接口测试:

1
2
3
4
5
// localhost:3000/nest/phones/parseFloat/1.2345
{
"n": 1.2345,
"dataType": "number"
}

ParseBoolPipe

controller实现:

pipe不仅可以应用于param,query和body装饰器也是可以的

1
2
3
4
5
6
7
8
// phones.controller.ts
@Get('parseBool')
parseBool(@Query('flag', ParseBoolPipe) flag: boolean) {
return {
flag,
dataType: typeof flag,
}
}

调试接口:

1
2
3
4
5
// localhost:3000/nest/phones/parseBool?flag=false
{
"flag": false,
"dataType": "boolean"
}

ParseArrayPipe

controller实现:

1
2
3
4
5
6
7
8
// phones.controller.ts
@Get('parseArray')
parseArray(@Query('array', ParseArrayPipe) array: string[]) {
return {
array,
dataType: typeof array,
}
}

调试接口:

1
2
3
4
5
6
7
8
9
10
11
// localhost:3000/nest/phones/parseArray?array=a,b,c,d,e
{
"array": [
"a",
"b",
"c",
"d",
"e"
],
"dataType": "object"
}

不难发现,ParseArrayPipe默认是以,分割字符串实现的。

可以以其他字符来分割吗?当然可以,看一下其声明文件就能发现了,我们可以传入配置项,不过这样的话我们就要注入一个实例,而不是一个类了。

1
2
3
4
5
6
7
8
9
10
// phones.controller.ts
@Get('parseArray')
parseArray(
@Query('array', new ParseArrayPipe({ separator: '.' })) array: string[],
) {
return {
array,
dataType: typeof array,
}
}

.分割字符调试接口:

1
2
3
4
5
6
7
8
9
10
11
// localhost:3000/nest/phones/parseArray?array=a.b.c.d.e
{
"array": [
"a",
"b",
"c",
"d",
"e"
],
"dataType": "object"
}

能得到同样的响应内容。

ParseUUIDPipe

ParseUUIDPipe应该算是转换管道还是验证管道呢?看一下其transform方法实现能发现,如果参数不是uuid会抛出错误,如果是uuid则将这个值返回,看起来好像是一个验证管道哦。不过既然命名为ParseXXXXPipe应该还是转换管道的。是否可以理解为,将字符串转换为uuid,失败则抛出错误。

1
2
3
4
5
6
7
// ParseUUIDPipe的transform实现
async transform(value, metadata) {
if (!this.isUUID(value, this.version)) {
throw this.exceptionFactory(`Validation failed (uuid${this.version ? ` v ${this.version}` : ''} is expected)`);
}
return value;
}

很简单,就不演示了吧。

ParseEnumPipe

parseEnumPipe用来判断你传入的参数是否是一个枚举值,我们先创建一个枚举:

1
2
3
4
5
enum Brand {
APPLE = 'apple',
HUAWEI = 'huawei',
SAMSUNG = 'samsung',
}

controller实现:

1
2
3
4
5
6
7
@Get('parseEnum')
parseEnum(@Query('brand', new ParseEnumPipe(Brand)) brand: string) {
return {
brand,
dataType: typeof brand,
}
}

调试接口:

1
2
3
4
5
// localhost:3000/nest/phones/parseEnum?brand=samsung
{
"brand": "samsung",
"dataType": "string"
}

samsung确实是我们的一个枚举值,如果我们传入一个错误的值呢?

1
2
3
4
5
6
// localhost:3000/nest/phones/parseEnum?brand=xiaomi
{
"statusCode": 400,
"message": "Validation failed (enum string is expected)",
"error": "Bad Request"
}

这就是ParseEnumPipe的用途。

自定义管道

每个管道必须要实现的泛型接口PipeTransform<T, R>,泛型 T 表明输入的 value 的类型,R 表明 transfrom() 方法的返回类型。

1
2
3
4
5
6
7
8
9
10
// PipeTransform接口
export interface PipeTransform<T = any, R = any> {
/**
* Method to implement a custom pipe. Called with two parameters
*
* @param value argument before it is received by route handler method
* @param metadata contains metadata about the value
*/
transform(value: T, metadata: ArgumentMetadata): R;
}

不难发现每个管道都要实现transform方法,该方法接受两个参数:

  • value:当前处理的方法参数
  • metadata:当前处理的方法参数的元数据

什么是元数据呢?可以看一下ArgumentMetadata的类型声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// ArgumentMetadata
export interface ArgumentMetadata {
/**
* 告诉我们参数是一个 body @Body(),query @Query(),param @Param() 还是自定义参数
*/
readonly type: Paramtype;
/**
* 参数的元类型,例如 String。
* 如果在函数签名中省略类型声明,或者使用原生 JavaScript,则为 undefined。
*/
readonly metatype?: Type<any> | undefined;
/**
* 传递给装饰器的字符串,例如 @Body('string')
* 如果您将括号留空,则为 undefined。
*/
readonly data?: string | undefined;
}

上边的内容可能看起来不太容易懂,来一个小demo看一下就好了

我们新建double.pipe.ts来创建一个管道:

1
2
3
4
5
6
7
8
9
10
11
12
13
// double.pipe.ts
import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common'

@Injectable()
export class DoublePipe implements PipeTransform {
transform(value: any, metadata: ArgumentMetadata) {
console.log('value:', value)
console.log('type:', metadata.type)
console.log('metatype:', metadata.metatype)
console.log('data:', metadata.data)
return value
}
}

phones.controller.ts中应用这个管道:

1
2
3
4
5
// phones.controller.ts
@Get('diyPipe')
diyPipe(@Query('num', DoublePipe) num: string) {
return 'success'
}

现在我们访问接口http://localhost:3000/nest/phones/diyPipe?num=1234。此时我们看一下控制台的打印内容:

1
2
3
4
value: 1234
type: query
metatype: [Function: String]
data: num

我们依次来看:

  • value:就是我们通过query传递的实际内容
  • type:因为我们使用`@Query装饰器,所以type是query
  • data:我们给@Query装饰器传入了'num',所以data是num
  • metatype:为什么是[Function: String],以为我们在controller给num参数声明类型为string。

现在我们知道参数都是什么了,那我们让这个管道,实现将传入内容乘以2的效果:

我们这里没有考虑各种条件,而是直接乘以2返回了,如果想做好,可以参考ParseIntPipe的实现

1
2
3
4
5
6
7
8
9
10
// double.pipe.ts
import { PipeTransform, Injectable, ArgumentMetadata } from '@nestjs/common'

@Injectable()
export class DoublePipe implements PipeTransform {
transform(value: any, metadata: ArgumentMetadata) {
// 不考虑各种条件,默认传入的是一个数值或字符串类型的数值
return parseInt(value, 10) * 2
}
}

controller中应用:

1
2
3
4
5
6
7
// phones.controller.ts
@Get('diyPipe')
diyPipe(@Query('num', DoublePipe) num: string) {
return {
double: num,
}
}

现在调试接口:

1
2
3
4
// localhost:3000/nest/phones/diyPipe?num=1234
{
"double": 2468
}

验证管道

在之前的使用MySql搭建CRUD代码的文章中我们在最后提了一嘴dto验证,现在我们学过了管道的知识再来看一下验证管道的内容。

在之前我们是实现了一个自定义管道,其实Nestjs有内置的ValidationPipe,这一次我们就使用内置管道来实现验证功能。

由于此管道使用了 class-validatorclass-transformer 库,所以需要先安装依赖:

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

ValidationPipe的构造函数接受一个可选的配置项:

1
2
3
4
5
6
7
8
9
10
11
export interface ValidationPipeOptions extends ValidatorOptions {
transform?: boolean;
disableErrorMessages?: boolean;
transformOptions?: ClassTransformOptions;
errorHttpStatusCode?: ErrorHttpStatusCode;
exceptionFactory?: (errors: ValidationError[]) => any;
validateCustomDecorators?: boolean;
expectedType?: Type<any>;
validatorPackage?: ValidatorPackage;
transformerPackage?: TransformerPackage;
}

继承自ValidatorOptions的配置项还有很多,声明的是class-validator可用选项,关于那些请查看class-validator文档。

我们将管道绑定到全局,这样方便我们演示:

1
2
3
4
5
6
7
// main.ts
async function bootstrap() {
const app = await NestFactory.create(ApplicationModule);
app.useGlobalPipes(new ValidationPipe());
await app.listen(3000);
}
bootstrap();

验证Body

我们创建create-phone.dto.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// create-phone.dto.ts
import {
IsString,
IsNotEmpty,
IsOptional,
IsNumber,
IsBoolean,
} from 'class-validator'
export class CreatePhoneDto {
@IsNotEmpty({ message: '不能为空' })
@IsString({ message: 'name为string类型' })
name: string
@IsNotEmpty({ message: '不能为空' })
@IsString({ message: 'name为string类型' })
brand: string
@IsOptional()
@IsNumber({ allowNaN: false }, { message: 'ram为number' })
ram: number
@IsOptional()
@IsNumber({ allowNaN: false }, { message: 'ram为number' })
rom: number
@IsOptional()
@IsBoolean({ message: 'isActive是boolean类型' })
isActive: boolean
}

controller中创建一个路由程序处理请求:

1
2
3
4
5
// phones.controller.ts
@Post('create')
createPhone(@Body() createPhoneDto: CreatePhoneDto) {
return createPhoneDto
}

我们先通过一个不合规的数据请求接口:

1
2
3
4
5
// localhost:3000/nest/phones/create
{
"brand": "Apple",
"ram": 4
}

得到响应如下:

1
2
3
4
5
6
7
8
{
"statusCode": 400,
"message": [
"name为string类型",
"不能为空"
],
"error": "Bad Request"
}

这样我们就实现一个简单的dto验证了。

验证Param

ValidationPipe不仅能对Body进行验证,其他请求数据也是可以的:

我们新建一个find-phone.dto.ts

1
2
3
4
5
6
// find-phone.dto.ts
import { IsNumberString } from 'class-validator'
export class FindPhoneDto {
@IsNumberString()
id: string
}

controller中应用:

1
2
3
4
5
// phones.controller.ts
@Get('find/:id')
findById(@Param() findPhoneDto: FindPhoneDto) {
return findPhoneDto
}

剥离属性

ValidationPipe 还可以过滤掉方法处理程序不应该接收的属性。在这种情况下,我们可以对可接受的属性进行白名单,白名单中不包含的任何属性都会自动从结果对象中删除。

先来看一下不使用白名单的情况来调用创建接口:

1
2
3
4
5
6
7
// localhost:3000/nest/phones/create
{
"name": "Iphone14",
"brand": "Apple",
"ram": 4,
"remark": "color pink"
}

由于我们的处理程序直接将接收到的内容返回了,所以响应如下:

1
2
3
4
5
6
{
"name": "Iphone14",
"brand": "Apple",
"ram": 4,
"remark": "color pink"
}

现在我们开启白名单功能:

1
2
3
4
5
6
7
8
9
10
11
// main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule)
app.setGlobalPrefix('nest')
app.useGlobalPipes(
new ValidationPipe({
+ whitelist: true,
}),
)
await app.listen(3000)
}

此时同样发送请求得到的响应为:

1
2
3
4
5
{
"name": "Iphone14",
"brand": "Apple",
"ram": 4
}

可以发现,开启白名单之后,只会接收dto中定义的属性,其余无关属性会过滤掉。

映射类型

我们有了创建实例的dto,可能还会有一个用于更新的dto。在更新的时候我们可以复用create-phone.dto.ts中的内容,且更新过程中可能所有字段都是可选的,Nest 提供了 PartialType() 函数来让这个任务变得简单。

我们创建一个update-phone.dto.ts

1
2
3
4
5
6
7
8
9
10
// update-phone.dto.ts
import { PartialType } from '@nestjs/mapped-types'
import { IsNotEmpty } from 'class-validator'
import { CreatePhoneDto } from './create-phone.dto'

export class UpdatePhoneDto extends PartialType(CreatePhoneDto) {
@IsNotEmpty({ message: '请传入phone的id' })
id: string
}

现在我们执行更新操作时,传入的body必须有一个id属性,还可以拥有任何create-phone.dto.ts中定义的属性。

除了PartialType函数,Nest还提供了几个函数方便构建dto格式:

  • PickType():通过挑出输入类型的一组属性构造一个新的类型

  • OmitType() :通过挑出输入类型中的全部属性,然后移除一组特定的属性构造一个类型

  • IntersectionType() :将两个类型合并成一个类型

映射类型函数是可以组合使用的。

作者

胡兆磊

发布于

2023-02-22

更新于

2023-02-23

许可协议