Nestjs DTO与管道校验、异常过滤器
简介
在学习使用Nsetjs中了解了DTO这个东西,但是作为一个前端开发人员,其实不太懂这个东西是干什么的,于是自己去了解了一些,并记录一下。
什么是DTO?
nestjs框架本身借鉴了很多后端框架的经验,其中DTO就是一种在java开发广泛使用的一种设计模式。
DTO的全称是Data Transfer Object,是一个用于客户端与后端服务传输数据的一种对象形式,在nestjs中更细节一点,就是前端传输的数据会在Controller控制层使用前,被转换成DTO数据对象,其实就是class类通过一些方式将数据赋值到这个类的实例对象上。
后续的操作读取其实都是使用的DTO对象,同时我们的数据校验也是使用DTO对象,通过属性装饰器添加校验规则的方式。
DTO在定义上还有更多的功能,这个目前还没感受到,先贴一下:
DTO的目的是在不同层之间实现数据的快速、高效传输。它可以将多个相关的数据字段封装到一个对象中,以便一次性传输,而不需要多次请求或传输多个数据字段。这可以减少网络通信的开销,并提高应用程序的性能。
DTO通常是轻量级的,只包含数据字段和对应的getter和setter方法。它们通常不包含业务逻辑或复杂的操作。DTO的设计应该根据具体的应用场景和数据传输需求进行,以确保数据的准确性和完整性。
在实际应用中,DTO可以用于将数据库查询结果封装为可传输的对象,在分布式系统中传输数据,或者在前端和后端之间传输数据。它可以帮助解耦不同层之间的依赖关系,并提供更好的灵活性和可扩展性。
如何在nestjs中实现一个DTO
官方文档说我们可以通过接口(interface)或者类(class)来实现DTO,官方更加推荐使用类的方式,因为使用接口会使得内容在编译后消失,因为ts最终会被转换js,而类型相关内容都会被剔除,而class是js的标准实现,在编译后得以保留。
因为可以保留,所以在通过nestjs的管道进行访问数据时,可以获取到元数据,给功能实现提供了更多可行性。
我们先创建一个dto文件,在src目录下创建dto目录,里面创建一个文件:test.dto.ts
export class TestDto {
readonly name: string;
readonly age: number;
}
然后我们在app.controller.ts
中引入并使用它:
import { Body, Controller, Get, Post } from "@nestjs/common";
import { AppService } from "./app.service";
import { TestDto } from "./dto/test.dto";
@Controller()
export class AppController {
constructor(private appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
@Post("test")
getTest(@Body() data: TestDto): string {
console.log(data);
return "test";
}
}
我们创建一个test接口,是一个post请求,接受的body数据是data参数,它的类型是TestDto
类。
此时我们请求http://localhost:3000/test
,传入参数就能打印出具体的对象了,例:
{ name: '段艳', age: '27' }
事实上如果前端传递的数据不止这两个参数,其实也会赋值到data对象上,而TestDto
在这里更像是一个类型声明。
它真正的作用是与管道配合,实现一个校验功能。
补充(针对后面):class-transformer这个库在默认情况下是会将数据对象的属性全部赋值到DTO实例对象上,但是官方的文档里有说,通过额外的配置选项中的excludeExtraneousValues
属性,可以实现这个效果,具体可以查看文档,并且该库也提供了数据转换的装饰器。
不知道nestjs是否也提供了类似选项。
管道配合使用
nestjs有两种管道校验的方式,一种是对象结构校验,一种是类验证。
对象结构校验
对象结构校验是一种很传统的校验方式,通过Joi
这种验证库,实现一个自定义管道去校验,下面是示例:
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;
}
}
其中schema
是一个joi的校验对象,里面是具体的校验规则,这个对象由外部提供,以实现一个更加通用的校验管道。
然后我们在控制器使用管道:
@Post()
@UsePipes(new JoiValidationPipe(createCatSchema))
async create(@Body() createCatDto: CreateCatDto) {
this.catsService.create(createCatDto);
}
这种方式显然没有发挥出DTO的作用,我们可以试试类校验。
类校验
先安装一下依赖:
pnpm i class-validator class-transformer
找到DTO文件,我们添加一些校验要求:
import { IsString, IsInt } from 'class-validator';
export class CreateCatDto {
@IsString()
name: string;
@IsInt()
age: number;
@IsString()
breed: string;
}
再实现一个基于class-validator
校验方式的管道:
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);
}
}
可以看到官方示例中toValidate
是一个有意思的操作,它的作用是用于判断校验的参数类型是不是一个class
类,因为class类型不在数组types中。
如果我们是一个非DTO对象的参数类型,就不走校验。
然后通过plainToInstance
方法,将DTO类实例化,并将value参数赋值到DTO实例上去,object则是DTO的实例。
为什么不能是一个普通的对象,而必须是DTO实例?
因为我们需要获取DTO上的校验元数据,所以必须是它的实例。
通过validate
实现校验,由于本身是一个异步校验,我们需要使用await
接收到参数,然后判断数组errors有无值,有值就报错。从这一步也能看出,其实管道也是支持异步的。
管道总结
通过上面两种校验方式,我们对数据的校验有了简单的认识,其中类校验将是我们未来文章中的默认校验方式,所以下面会继续深入这个讲述更复杂的一些场景。
当我们使用类校验的时候,DTO所承载的意义才更加明确,它包含了数据的类型和校验所用到的规则,甚至对于数据的格式化我们也会通过在DTO中的装饰器去添加描述,最终通过plainToInstance
来得到最终需要的对象。
全局校验
事实上我们如果只是需要达到上面的代码效果,其实官方已经给我封装好了一个全局的校验管道ValidationPipe
,我们只需要在main文件中,在app上注册全局管道,就不用每个调用处都去import并new 一个管道了,它会应用在每个服务上。
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { ValidationPipe } from "@nestjs/common";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe());
await app.listen(3000);
}
bootstrap();
官方对于ValidationPipe
的处理其实比我们想象的还要多,其中就有对于DTO类判断的toValidate
方法,当我们的参数不正确时,便会返回以下报错格式:
{
"message": [
"name must be an integer number",
"age must be an integer number"
],
"error": "Bad Request",
"statusCode": 400
}
可以看到错误的消息是一个数组,但是如果我们需要更加具体的信息,比如这种格式:
[
{
field: "name",
message: ["must be an integer number"]
},
{
field: "age",
message: ["must be an integer number"]
}
]
其实也不是不能做,我们可以通过继承的方式,来实现这个效果。
自定义全局校验管道
创建一个validate.ts
文件
import { ValidationPipe } from "@nestjs/common";
import type { ValidationError } from "@nestjs/common";
export class CustomValidation extends ValidationPipe {
protected mapChildrenToValidationErrors(error: ValidationError, parentPath?: string): ValidationError[] {
const errors = super.mapChildrenToValidationErrors(error, parentPath);
errors.forEach((error) => {
Object.keys(error.constraints).forEach((key) => {
error.constraints[key] = `${error.property}⓿${error.constraints[key]}`;
});
});
return errors;
}
}
我们先调用父级的mapChildrenToValidationErrors
,再根据返回的值特殊操作一下。
errors的内容如下:
[
ValidationError {
target: TestDto { name: '叶秀兰', age: '11', sex: '女' },
value: '叶秀兰',
property: 'name',
children: [],
constraints: {
max: 'name must not be greater than 10',
min: 'name must not be less than 5',
isInt: 'name must be an integer number'
}
}
]
[
ValidationError {
target: TestDto { name: '叶秀兰', age: '11', sex: '女' },
value: '11',
property: 'age',
children: [],
constraints: { isInt: 'age must be an integer number' }
}
]
可以看到每个字段都会调用一次mapChildrenToValidationErrors
生成一个错误消息数组,其中property
就是字段名,constraints
则包含了所有的错误信息。
我们通过重写constraints里面每个属性的值,将字段名拼接进去。
然后我们再通过全局的异常过滤器将数据变成我们想要的。
校验管道内置的两个方法
我们在实现自定义校验管道的时候,覆写了mapChildrenToValidationErrors
方法,实际上关于处理错误信息的方法是有两个的:
flattenValidationErrors
,扁平化处理错误消息;mapChildrenToValidationErrors
,映射嵌套错误。
flattenValidationErrors
flattenValidationErrors方法要求返回的值必须是一个字符串数组string[]
,这个其实就是nestjs返回给前端的响应对象中的message属性的值。
示例值:
[ 'password⓿密码长度为6-20位', 'age⓿test.age不能为空' ]
它的本意就是将原来的错误消息对象扁平化转换成错误消息数组。
mapChildrenToValidationErrors
mapChildrenToValidationErrors本意是用于处理嵌套DTO校验错误对象的,我们举个例子,首先我们实现一个嵌套的DTO类。
import { Type } from "class-transformer";
import { IsEmail, IsNotEmpty, Length, ValidateNested } from "class-validator";
import { IsFieldExist } from "../../utils/class-validator-extends";
class TestDto {
@IsNotEmpty({ message: "age不能为空" })
age: number;
@IsNotEmpty({ message: "name不能为空" })
name: string;
}
/** 登录DTO */
export class LoginDto {
/** 邮箱 */
@IsNotEmpty({ message: "邮箱不能为空" })
@IsEmail({}, { message: "邮箱格式不正确" })
@IsFieldExist("user", { allowExist: true }, { message: "该邮箱未注册" })
email: string;
/** 密码 */
@IsNotEmpty({ message: "密码不能为空" })
@Length(6, 20, { message: "密码长度为6-20位" })
password: string;
@IsNotEmpty({ message: "test不能为空" })
@ValidateNested()
@Type(() => TestDto)
test: TestDto;
}
LoginDto通过test属性嵌套了TestDto,我们需要通过ValidateNested
装饰器告知验证器,需要验证嵌套对象,但是这个只是告知验证嵌套对象,并不饱含test属性本身,所以如果是个必填,那么就得自己加上IsNotEmpty
验证不为空。
Type
装饰器则是告知在实例化dto对象并赋值的时候,它的嵌套对象类型是什么。
当我们去校验LoginDto
时,会先运行flattenValidationErrors
,假设参数如下:
{
"email": "l.adfcydnkh@dfm.ne",
"password": "1236",
"test": {}
}
flattenValidationErrors会得到包含两个错误对象的数组:
[
ValidationError {
target: LoginDto {
email: 'l.adfcydnkh@dfm.ne',
password: '1236',
test: TestDto {}
},
value: '1236',
property: 'password',
children: [],
constraints: { isLength: '密码长度为6-20位' }
},
ValidationError {
target: LoginDto {
email: 'l.adfcydnkh@dfm.ne',
password: '1236',
test: TestDto {}
},
value: TestDto {},
property: 'test',
children: [ [ValidationError], [ValidationError] ]
}
]
你会发现test
属性的错误对象有些特殊,因为它的constraints
不在有明确的错误信息,反而是在children
属性里又包含了两个错误对象。
既然我们要扁平化消息,那自然要想办法把children里面的错误对象信息提取出来,于是就会调用mapChildrenToValidationErrors
,它会将错误对象的消息提取出来并赋值到错误对象的constraints
属性中。
没有调用之前:
{
target: LoginDto {
email: 'l.adfcydnkh@dfm.ne',
password: '1236',
test: TestDto {}
},
value: TestDto {},
property: 'test',
children: [
ValidationError {
target: TestDto {},
value: undefined,
property: 'age',
children: [],
constraints: [Object]
},
ValidationError {
target: TestDto {},
value: undefined,
property: 'name',
children: [],
constraints: [Object]
}
]
}
调用mapChildrenToValidationErrors
之后:
[
{
target: TestDto {},
value: undefined,
property: 'age',
children: [],
constraints: { isNotEmpty: 'test.age不能为空' }
},
{
target: TestDto {},
value: undefined,
property: 'name',
children: [],
constraints: { isNotEmpty: 'test.name不能为空' }
}
]
此时这个错误对象数组返回值被flattenValidationErrors
拿到之后,会将constraints
中的错误信息提取出来,push到string[]
数组中返回,作为message响应值。
最终返回的message:
[ 'password⓿密码长度为6-20位', 'age⓿test.age不能为空', 'name⓿test.name不能为空' ]
如果mapChildrenToValidationErrors接收的是一个非嵌套错误对象,它会原样返回该对象,不做修改。
校验管道总结
由于不管是什么错误,mapChildrenToValidationErrors
都会被调用,只有它能拿到固定格式的错误对象。
const errors = super.mapChildrenToValidationErrors(error, parentPath)
此时的errors数组中的每个错误对象,格式如下:
{
target: LoginDto {
email: 'l.adfcydnkh@dfm.ne',
password: '1236',
test: TestDto {}
},
value: '1236',
property: 'password',
children: [],
constraints: { isLength: '密码长度为6-20位' }
}
{
target: TestDto {},
value: undefined,
property: 'age',
children: [],
constraints: { isNotEmpty: 'test.age不能为空' }
}
我们可以通过constraints
拿到校验错误信息。
全局异常过滤器
nestjs本身有自己的异常过滤器,但是如果要更加精细的控制,比如我们需要调整返回的数据,可以通过自定义异常过滤器实现。
创建一个validate-exception.ts
文件
import { ExceptionFilter, Catch, HttpException, BadRequestException } from "@nestjs/common";
import type { ArgumentsHost } from "@nestjs/common";
import type { Response } from "express";
@Catch(HttpException)
export class ValidateExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const status = exception.getStatus();
const results = exception.getResponse() as any;
if (exception instanceof BadRequestException && results.message[0].includes("⓿")) {
const message: Array<{ field: string; message: Array<string> }> = [];
results.message.forEach((item) => {
const [key, val] = item.split("⓿") as [string, string];
const findData = message.find((item) => item.field === key);
if (findData) {
findData.message.push(val);
} else {
message.push({ field: key, message: [val] });
}
});
return response.status(status).json({
statusCode: results.statusCode,
error: results.error,
message
});
}
return response.status(status).json(results);
}
}
需要注意,由于nestjs使用的底层是express,所以我们的响应对象需要通过response.json()
发送给前端,其中results
是框架生成的响应结果,我们只需基于这个结果,判断如果是BadRequestException
,一个请求错误类,那么就进入到我们自定义环节。
BadRequestException 一般常用于校验参数失败,但是它不仅限于参数校验错误,它可以用于表示任何客户端请求错误,所以我又追加了一个results.message[0].includes("⓿")
判断,因为只有校验不通过时才会有这个特殊字符,其他的错误没有,从而不会影响到其他报错结果。
通过return
进行分割,如果我们不在最后书写response.status(status).json(results)
会导致其他报错结果一直等待响应中,所以直接返回原结果即可。
此时我们在查看参数结果:
{
"statusCode": 400,
"error": "Bad Request",
"message": [
{
"field": "name",
"message": [
"name must not be greater than 10",
"name must not be less than 5",
"name must be an integer number"
]
},
{
"field": "age",
"message": [
"age must be an integer number"
]
}
]
}
搞定,初次DTO与管道、过滤器的使用我们到此结束。
官方校验管道的预设参数
我们继承了ValidationPipe
做了自己的实现,针对DTO的校验实现了自定义格式的错误信息处理,但是官方对于DTO的一些处理不仅于此,他还有很多配置项,下面是一个示例:
custom-validation.pipe.ts
import { ValidationPipe } from "@nestjs/common";
import type { ValidationError } from "@nestjs/common";
export class CustomValidationPipe extends ValidationPipe {
constructor() {
super({
whitelist: true
});
}
protected mapChildrenToValidationErrors(error: ValidationError, parentPath?: string): ValidationError[] {
const errors = super.mapChildrenToValidationErrors(error, parentPath);
errors.forEach((error) => {
Object.keys(error.constraints).forEach((key) => {
error.constraints[key] = `${error.property}⓿${error.constraints[key]}`;
});
});
return errors;
}
}
通过super给父类传递参数。
参数有以下:
transform
- 类型:
boolean
- 默认值:
false
- 描述:当设置为
true
时,ValidationPipe
会尝试将请求的数据类型转换为DTO类的数据类型。这对于确保经过验证的数据与你的类型定义相匹配非常有用。
disableErrorMessages
- 类型:
boolean
- 默认值:
false
- 描述:如果设为
true
,则在验证失败时,不会在异常响应中返回具体的错误信息。这有助于避免暴露可能敏感的字段验证规则。
exceptionFactory
- 类型:
Function
- 描述:允许自定义当数据验证失败时抛出的异常。这个函数接收验证失败产生的错误数组,并返回一个异常实例。
stopAtFirstError
- 类型:
boolean
- 默认值:
false
- 描述:当该选项为
true
时,ValidationPipe
将在遇到第一个验证错误时停止进一步的验证。这可以减少响应体中的错误数量,并可能提升性能(尤其是在有异步验证器时)。
whitelist
- 类型:
boolean
- 默认值:
false
- 描述:如果设置为
true
,ValidationPipe
将自动剥离掉DTO之外的所有属性。这有助于防止意料之外的数据进入你的应用。
forbidNonWhitelisted
- 类型:
boolean
- 默认值:
false
- 描述:当与
whitelist
结合使用时,如果请求的数据包含DTO中未定义的任何属性,并且此选项设置为true
,则ValidationPipe
将抛出一个BadRequestException
。
transformOptions
- 类型:
ClassTransformOptions
- 描述:允许在执行转换时传递选项给
class-transformer
库。
validateCustomDecorators
- 类型:
boolean
- 默认值:
false
- 描述:如果设置为
true
,则ValidationPipe
会验证带有自定义装饰器的属性(默认情况下,只验证带有class-validator
装饰器的属性)。
skipMissingProperties
- 类型:
boolean
- 默认值:
false
- 描述:在进行验证时,如果某个属性在传入的对象中不存在,则忽略对该属性的验证。
skipNullProperties
- 类型:
boolean
- 默认值:
false
- 描述:在进行验证时,如果某个属性的值为
null
,则忽略对该属性的验证。
skipUndefinedProperties
- 类型:
boolean
- 默认值:
false
- 描述:在进行验证时,如果某个属性的值为
undefined
,则忽略对该属性的验证。
groups
- 类型:
string[]
- 描述:仅对标记为特定组的属性进行验证。这对于条件验证非常有用。
请注意,NestJS和相关库(如class-validator
和class-transformer
)会不断更新和演进,因此建议查看最新的官方文档以获取最准确的信息。
全局注入管道和过滤器
app.modules.ts
import { Module } from "@nestjs/common";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";
import { APP_FILTER, APP_PIPE } from "@nestjs/core";
import { CustomValidationPipe } from "@/common/pipes";
import { HttpExceptionFilter } from "@/common/filters";
@Module({
imports: [],
controllers: [AppController],
providers: [
AppService,
/** 全局自定义DTO校验管道 */
{
provide: APP_PIPE,
useClass: CustomValidationPipe
},
/** 全局错误过滤器 */
{
provide: APP_FILTER,
useClass: HttpExceptionFilter
}
]
})
export class AppModule {}
本文系作者 @木灵鱼儿 原创发布在木灵鱼儿站点。未经许可,禁止转载。
暂无评论数据