nest.js 管道(类型转换和数据校验)
前言
使用管道功能的前提是拥有@Injectable()
装饰器的类,管道可以用于数据类型的转换和校验,也可以自定义实现一个管道,但是需要符合接口PipeTransform
。
nest官方提供了9个内置管道:
ValidationPipe
一般用于全局的校验管道,后面会说ParseIntPipe
转换为整数类型,其实就是number去浮点数了ParseFloatPipe
转为浮点数ParseBoolPipe
转布尔值ParseArrayPipe
转数组ParseUUIDPipe
转uuidParseEnumPipe
转为枚举DefaultValuePipe
默认值,适用于一些参数可以不传,然后使用这个加入默认值ParseFilePipe
文件
这些管道都是从@nestjs/common
引入使用。
一些具体例子:管道
使用管道转换数据类型
nest给控制器controller
,提供了一些参数装饰器用于获取后端接收的参数,如:
@Param(key?: string)
接受地址路径参数,类似于vue的动态路由参数@Body(key?: string)
post这种请求传过来的参数对象@Query(key?: string)
url地址?后面的参数
更多的装饰器可以去查看nest文档控制器相关文档:控制器
我们在控制器中获取一个参数:
import { Controller, Get, Param } from "@nestjs/common";
@Controller()
export class AppController {
constructor() {}
@Get(":id")
getHello(@Param("id") id: number): string {
console.log(id);
return "ss";
}
}
通过Get
声明一个get请求,这个请求地址会有一个动态地址参数:id
,然后在具体的函数参数里,通过@Param
获取到这个名为id的参数,并且类型我们要求是number数字。
通过这个地址访问:http://localhost:3000/1
但是我们获取id的类型,其实得到的是string,因为地址参数必然是string。
console.log(typeof id); //string
但是实际上这个id可能就是对应数据库中的id,那么它得是一个number才能使用,于是我们需要进行类型转换。
import { Controller, Get, Param, ParseIntPipe } from "@nestjs/common";
@Controller()
export class AppController {
constructor() {}
@Get(":id")
getHello(@Param("id", ParseIntPipe) id: number): string {
console.log(typeof id); //number
return "ss";
}
}
引入整数管道,在Param
中将管道作为参数传递过去,这些获取参数的方法是可以接受多个管道的,如果有需要的话,我们可以传入。
关于类型转换的使用就是这样了,配合获取参数的装饰器使用即可。
自定义类型转换管道
我们可以在src中创建一个pipe的目录用于存放所有的管道。
在pipe目录创建一个test.pipe.ts
文件
import { PipeTransform, Injectable, ArgumentMetadata } from "@nestjs/common";
@Injectable()
export class TestPipe implements PipeTransform {
transform(value: any, metadata: ArgumentMetadata) {
console.log(value, metadata);
return value;
}
}
引入PipeTransform
作为接口要求,通过implements
实现接口约束。
ArgumentMetadata
是元信息类型
value
则是管道接收到的具体的参数,比如上面的ParseIntPipe
的value参数就是id。
Injectable
必须
我们实现一个transform方法即可。
ArgumentMetadata 类型定义:
export interface ArgumentMetadata {
type: 'body' | 'query' | 'param' | 'custom';
metatype?: Type<unknown>;
data?: string;
}
参数 | 描述 |
---|---|
type | 告诉我们参数是一个 body @Body() ,query @Query() ,param @Param() 还是自定义参数 在这里阅读更多。 |
metatype | 参数的元类型,例如 String 。 如果在函数签名中省略类型声明,或者使用原生 JavaScript,则为 undefined 。 |
data | 传递给装饰器的字符串,例如 @Body('string') 。如果您将括号留空,则为 undefined 。 |
根据meta信息我们可以自定义一些转换方式,其中metatype是可以拿到这个参数要求的类型的,比如上面id要求的是number,那么在这就能拿到number的构造函数。
使用自定义管道的时候,import引入使用。
import { Controller, Get, Param } from "@nestjs/common";
import { TestPipe } from "./pipe/test.pipe";
@Controller()
export class AppController {
constructor() {}
@Get(":id")
getHello(@Param("id", TestPipe) id: number): string {
console.log(id);
return "ss";
}
}
默认参数
import { Controller, Get, Param, DefaultValuePipe } from "@nestjs/common";
@Controller()
export class AppController {
constructor() {}
@Get()
getHello(@Param("id", new DefaultValuePipe(1)) id: number): string {
console.log(id); //1
return "ss";
}
}
默认参数的管道需要new出来。
验证
管道的另一个作用就是验证,判断用户传过来的参数是否合法,比较正统的做法就是先创建一个dto数据类,然后给这个数据类加上验证的装饰器,然后在控制器或者在服务中,运行一个validate
的方法,来具体判断这个参数是否合法。
等于是在管道运行阶段做的是绑定验证器,在控制器或者服务函数体内,才做校验。
当然简单一点我们也可以在管道内直接就抛出一个错位表明验证没有通过,但是这不是正统做法,维护性会差一些。
这里有一个后端知识dto,简单解释一下:dto就是数据传输对象,用于服务器接口接的参数的规范,是作为数据的类型约束,在nest中我们还对dto加上了校验用的装饰器,用于校验处理。
而对于响应数据规范,比如api返回的数据,使用的是vo,这个以后再说了。
推荐我们验证配合:class-validator class-transformer
插件,我们搞类验证器。
为此先安装依赖:
pnpm i class-validator class-transformer
创建dto
在src目录下创建dto目录,用于存放dto的数据类。
然后创建一个test.dto.ts
文件:
import { IsNotEmpty, IsString, IsInt } from "class-validator";
import { Transform } from "class-transformer";
export class TestDto {
@IsNotEmpty({ message: "name不能为空" })
@IsString({ message: "name必须是字符串" })
name: string;
@Transform(({ value }) => {
if (typeof value === "string" && value.trim() !== "") {
return Number(value);
}
return value;
})
@IsNotEmpty({ message: "age不能为空" })
@IsInt({ message: "age必须是数字" })
age: number;
}
声明一个TestDto
类,它有两个属性:name、age,分别有对应的类型,然后引入校验包装器校验他们的数据,由于默认使用x-www-form-urlencoded
协议,所有的数据都是string,所以在age判定之前我们需要使用Transform
做一个数据类型转换。
目前有一个遗憾,就是class-validator的校验,它没有存在前置判定的这种方式,也就是说在age字段判断的时候,哪怕IsNotEmpty
校验不通过,还是会触发后面的IsInt
校验,可能有其他办法,但是暂时没找到好的解决方案,所以暂时先这样吧!
引入dto
配置好dot之后,在控制器引入并声明为参数数据类型:
import { Controller, Get, Body, Post } from "@nestjs/common";
import { AppService } from "./app.service";
import { TestPipe } from "./pipe/test.pipe";
import { TestDto } from "./dto/test.dto";
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
@Post("/test")
postTest(@Body(TestPipe) data: TestDto) {
return "success";
}
}
管道中的校验
然后我们再去自定义的管道中处理校验:
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from "@nestjs/common";
import { validate } from "class-validator";
import { plainToInstance } from "class-transformer";
@Injectable()
export class TestPipe implements PipeTransform {
async transform(value: any, metadata: ArgumentMetadata) {
const { metatype } = metadata;
//如果没有声明为自定义类型则直接返回
if (!metatype || !this.toValidate(metatype)) return value;
//将value转为为实例化的dto对象
// console.log(value);
const object = plainToInstance(metatype, value);
// console.log(object);
//校验对象数据是否合法
const errors = await validate(object);
// console.log(errors);
if (errors.length > 0) throw new BadRequestException("数据校验失败");
return value;
}
private toValidate(metatype: Function): boolean {
const types: Function[] = [String, Boolean, Number, Array, Object];
return !types.includes(metatype);
}
}
利用plainToInstance
函数将dto类和具体的参数实例化成一个dto实例。
理论上返回的dto实例的属性应该是dto类上所声明的,但是实际上是它会把value上所有的属性都复制给实例化后的dto类,但是我们是ts,虽然实际运行的对象存在着未声明的属性,但是ts不会有提示,使用未声明的属性会有警告报错,所以也还能接受这个小问题。
我们将实例化后的dto对象传入validate
方法进行校验,他是一个异步的方法,nest也支持异步的管道,所以我们可以使用async和await实现同步写法,然后返回的值是一个数组,数组里包含着所有的校验不通过产生的错误对象(ValidationError)。
例:
[
{
target: TestDto { name: 'asdasd', age: NaN, sd: '1' },
value: NaN,
property: 'age',
children: [],
constraints: { isInt: 'age必须是数字' }
}
]
然后我们判断,如果这个数组不为空,就抛出一个nest提供的BadRequestException
请求异常的错误实例。
此时前端得到的结果:
{
"statusCode": 400,
"message": "数据校验失败",
"error": "Bad Request"
}
这样一套数据校验就完事了。
自定义全局管道校验
上面的管道校验代码其实是很通用的代码,nest官方已经帮我们封装了,它就是ValidationPipe
,我们可以直接用:
import { Controller, Get, Body, Post, ValidationPipe } from "@nestjs/common";
import { AppService } from "./app.service";
import { TestDto } from "./dto/test.dto";
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
@Post("/test")
postTest(@Body(ValidationPipe) data: TestDto) {
return "success";
}
}
效果也是一样的,但是每个接口都要写这个管道有些麻烦,官方提供了全局注册的方式,这样就不用每次都import引入然后使用了。
在main.ts中我们useGlobalPipes进行注册:
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();
但是官方的错误消息可能并不是我们想要的,我们可能需要它在返回的错误信息里告知一下对应的字段名是什么?
于是我们可以自定义个校验管道,创建一个validate.pipe.ts文件在pipe目录下:
import { ValidationError, ValidationPipe } from "@nestjs/common";
export class ValidatePipe extends ValidationPipe {
protected mapChildrenToValidationErrors(error: ValidationError, parentPath?: string): ValidationError[] {
const errors = super.mapChildrenToValidationErrors(error, parentPath);
errors.forEach((item) => {
for (let key in item.constraints) {
item.constraints[key] = `${item.property}-${item.constraints[key]}`;
}
});
return errors;
}
}
我们继承ValidationPipe
管道类,然后自定义它的mapChildrenToValidationErrors
方法,由于需要保证原来的功能不变,我们使用super关键词调用父类的方法,得到结果后再自定义。
由于需要保证ValidationError
的结构,我们只能调整constraints
中的错误文本,拼接一个字段key,然后再通过后续的自定义过滤器,将string转换为对象。
error对象内容例子:
{
target: TestDto { name: 'sad', age: '', sd: '1' },
value: '',
property: 'age',
children: [],
constraints: { isInt: 'age必须是数字', isNotEmpty: 'age不能为空' }
}
父类的mapChildrenToValidationErrors其实就是包了个数组,结果如下:
[{
target: TestDto { name: 'sad', age: '', sd: '1' },
value: '',
property: 'age',
children: [],
constraints: { isInt: 'age必须是数字', isNotEmpty: 'age不能为空' }
}]
我们调整后的结果为:
[
{
target: TestDto { name: 'sad', age: '', sd: '1' },
value: '',
property: 'age',
children: [],
constraints: { isInt: 'age-age必须是数字', isNotEmpty: 'age-age不能为空' }
}
]
注意使用的时候在main.ts中注册:
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { ValidatePipe } from "./pipe/validate.pipe";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidatePipe());
await app.listen(3000);
}
bootstrap();
自定义全局过滤器处理报错消息
由于自定义校验管道的报错消息其实还是不能够使用的,所以我们还得通过过滤器将其转为如下对象格式:
{
field: "xxx",
message: ["xxx", "xxx"]
}
我们在src目录下创建一个filter目录用于存放过滤器,然后创建一个validate.filter.ts
文件。
import { ArgumentsHost, BadRequestException, Catch, ExceptionFilter, HttpException } from "@nestjs/common";
import { 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();
//针对参数校验错误的处理
if (exception instanceof BadRequestException) {
const responseData = exception.getResponse() as any;
const newResponseData = {
code: status,
data: null,
msg: responseData.message.map((item) => {
const arr = item.split("-");
if (arr.length > 1) {
return {
field: arr[0],
message: arr[1],
};
}
return item;
}),
};
//返回新的错误信息
return response.status(status).json(newResponseData);
}
return response;
}
}
大体的方式还是得看官方文档,有点晦涩,大体意思是判断,如果当前的返回体是校验错误BadRequestException
,那么就对齐做特殊处理,比如将消息转为对象的形式返回给前端。
得到的结果:
{
"code": 400,
"data": null,
"msg": [
{
"field": "age",
"message": "age必须是数字"
},
{
"field": "age",
"message": "age不能为空"
}
]
}
这种方式也可以用于统一返回的信息对象格式,如上述常见的格式。
实战:实现一个密码重复校验
class-validator提供的校验肯定是不够实际业务使用的,所以一般都会提供实现自定义校验的方式,这里我们就要实现一个对注册账号时,密码和二次确认密码的校验。
首先我们在src目录下创建一个rules目录,用于存放自定的校验方法。
首先先了解一下实现原理,我们在dto上面的password属性使用一个验证密码的装饰器,而二次确认的密码,可以不用再开一个属性,而是在密码属性上绑定一个用于验证二次密码的装饰器。
有点拗口,其实很简单,我们会约定俗成一个规范,需要二次验证的属性是原属性名在后面拼接一个固定名的字符串。
password:string;
password_confirmed:string;
_confirmed
就是约定好的名字,这样就不用再开一个属性了。
然后验证的时候直接拼接key然后比对就行了,因为class-validator的装饰器有个args
参数,类型为ValidationArguments
,里面的object属性就是dto实例对象,我们可以从这个对象上获取所有属性。
args参数例子:
{
targetName: 'TestDto',
property: 'password',
object: TestDto {
name: 'sad',
age: '',
password: '1',
password_confirmed: '2'
},
value: '1',
constraints: undefined
}
创建一个isConfirmed.rule.ts
文件
import { ValidatorConstraint, ValidatorConstraintInterface, ValidationArguments } from "class-validator";
@ValidatorConstraint()
export class IsConfirmed implements ValidatorConstraintInterface {
validate(text: string, args: ValidationArguments) {
return text === args.object[`${args.property}_confirmed`];
}
/** 默认错误文案 */
defaultMessage(args: ValidationArguments) {
return "二次确认不一致";
}
}
遵循ValidatorConstraintInterface
接口规范,实现validate
方法,defaultMessage是默认错误文案,如果在使用装饰器的时候传入的自定义信息,则使用自定义的信息。
validate方法返回一个布尔值,这个方法可以是异步的。
text参数就是装饰器装饰的属性值,也就是我们的password,下面会有具体的使用:
我们在dto中使用自定义的校验:
import { IsNotEmpty, IsString, IsInt, Validate } from "class-validator";
import { Transform } from "class-transformer";
import { IsConfirmed } from "src/rules/isConfirmed.rule";
export class TestDto {
@IsNotEmpty({ message: "name不能为空" })
@IsString({ message: "name必须是字符串" })
name: string;
@Transform(({ value }) => {
if (typeof value === "string" && value.trim() !== "") {
return Number(value);
}
return value;
})
@IsNotEmpty({ message: "age不能为空" })
@IsInt({ message: "age必须是数字" })
age: number;
@Validate(IsConfirmed)
password: string;
}
通过@Validate将自定义装饰器传入。
这种方式有一种好处就是很高的通用性,我可以用于任何需要二次验证的值的地方,只需要它的二次验证的值遵循约定key的后缀使用_confirmed
。
自定义错误信息:
@Validate(IsConfirmed, { message: "二次确认的密码不正确" })
实战:用户唯一验证
当我们注册用户的时候,肯定是需要判断这个用户是否已经注册过了,为此我们可以实现一个自定义的装饰器验证,其实也就是体验下validate方法是可以异步的,因为我们需要使用到prisma查询数据库。
自定义装饰器的使用就不需要通过@Validate
了,但是相对的声明起来会比较麻烦一些:
创建一个isNotExists.rule.ts
文件
import { PrismaClient } from "@prisma/client";
import { registerDecorator, ValidationOptions, ValidationArguments } from "class-validator";
export function IsNotExists(sqlTable: string, validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
name: "isNotExists",
target: object.constructor,
propertyName: propertyName,
constraints: [sqlTable],
options: validationOptions,
validator: {
async validate(value: any, args: ValidationArguments) {
const prisma = new PrismaClient();
const findData = await prisma[sqlTable].findFirst({
where: {
[args.property]: value,
},
});
return !findData;
},
/** 默认错误文案 */
defaultMessage(args: ValidationArguments) {
return "已存在";
},
},
});
};
}
sqlTable
是自定义的参数,为了更加通用,这个参数用于表示我们需要查询mysql里的哪个表,这个就是表的名字,通过args.property
获取这个字段名,然后value就是具体的值了,然后进行查询,findData如果找到了,就是一个完整的数据对象,如果没找到就是null,我们进行布尔求反即可,表示这个内容不存在。
使用时:
import { IsNotEmpty, IsString, IsInt, Validate } from "class-validator";
import { Transform } from "class-transformer";
import { IsConfirmed } from "src/rules/isConfirmed.rule";
import { IsNotExists } from "src/rules/isNotExists.rule";
export class TestDto {
@IsNotEmpty({ message: "name不能为空" })
@IsString({ message: "name必须是字符串" })
name: string;
@Transform(({ value }) => {
if (typeof value === "string" && value.trim() !== "") {
return Number(value);
}
return value;
})
@IsNotEmpty({ message: "age不能为空" })
@IsInt({ message: "age必须是数字" })
age: number;
@Validate(IsConfirmed, { message: "二次确认的密码不正确" })
password: string;
@IsNotExists("user")
email: string;
}
这个用起来就比自定义校验方法会方便一些。
本文系作者 @木灵鱼儿 原创发布在木灵鱼儿站点。未经许可,禁止转载。
暂无评论数据