简介

在学习使用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方法,实际上关于处理错误信息的方法是有两个的:

  1. flattenValidationErrors,扁平化处理错误消息;
  2. 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给父类传递参数。

参数有以下:

  1. transform
  • 类型:boolean
  • 默认值:false
  • 描述:当设置为true时,ValidationPipe会尝试将请求的数据类型转换为DTO类的数据类型。这对于确保经过验证的数据与你的类型定义相匹配非常有用。
  1. disableErrorMessages
  • 类型:boolean
  • 默认值:false
  • 描述:如果设为true,则在验证失败时,不会在异常响应中返回具体的错误信息。这有助于避免暴露可能敏感的字段验证规则。
  1. exceptionFactory
  • 类型:Function
  • 描述:允许自定义当数据验证失败时抛出的异常。这个函数接收验证失败产生的错误数组,并返回一个异常实例。
  1. stopAtFirstError
  • 类型:boolean
  • 默认值:false
  • 描述:当该选项为true时,ValidationPipe将在遇到第一个验证错误时停止进一步的验证。这可以减少响应体中的错误数量,并可能提升性能(尤其是在有异步验证器时)。
  1. whitelist
  • 类型:boolean
  • 默认值:false
  • 描述:如果设置为trueValidationPipe将自动剥离掉DTO之外的所有属性。这有助于防止意料之外的数据进入你的应用。
  1. forbidNonWhitelisted
  • 类型:boolean
  • 默认值:false
  • 描述:当与whitelist结合使用时,如果请求的数据包含DTO中未定义的任何属性,并且此选项设置为true,则ValidationPipe将抛出一个BadRequestException
  1. transformOptions
  • 类型:ClassTransformOptions
  • 描述:允许在执行转换时传递选项给class-transformer库。
  1. validateCustomDecorators
  • 类型:boolean
  • 默认值:false
  • 描述:如果设置为true,则ValidationPipe会验证带有自定义装饰器的属性(默认情况下,只验证带有class-validator装饰器的属性)。
  1. skipMissingProperties
  • 类型:boolean
  • 默认值:false
  • 描述:在进行验证时,如果某个属性在传入的对象中不存在,则忽略对该属性的验证。
  1. skipNullProperties
  • 类型:boolean
  • 默认值:false
  • 描述:在进行验证时,如果某个属性的值为null,则忽略对该属性的验证。
  1. skipUndefinedProperties
  • 类型:boolean
  • 默认值:false
  • 描述:在进行验证时,如果某个属性的值为undefined,则忽略对该属性的验证。
  1. groups
  • 类型:string[]
  • 描述:仅对标记为特定组的属性进行验证。这对于条件验证非常有用。

请注意,NestJS和相关库(如class-validatorclass-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 {}
分类: Nest.js 标签: 过滤器express表单校验管道NestjsDTO

评论

暂无评论数据

暂无评论数据

目录