概述
介绍 class-validator 和 class-transformer 实现 DTO,以及一些复杂功能的实现,部分实现参考了 NestJS 的管道源码。
项目参考:github.com/dongyuanxin/cloudpress
NestJS 中的 DTO
NestJS 文档中提到了 2 种定义 DTO 的方法:
- 方法 1: 通过 joi 库创建 schema
- 方法 2: 通过 class-validator 和 class-transformer
方法 1 就是按照 joi 库提供的规范,编写数据声明,例如规定对象的 username 字段,长度范围是[3, 30]
,且 username 字段不能为空,写法如下:
1 2 3 4 5 6
| const schema = Joi.object({ username: Joi.string() .min(3) .max(30) .required() )}
|
这种方法简单易懂,功能支持多(比如校验邮箱、网址),能在 js 中快速使用和验证,下载量最大。不足之处,在 nestjs 中,编写 schema 的同时,还得编写 typescript 的类型声明。例如一个接口,要求 body 中的 username 符合上述要求。那么除了编写 schema,为了更好的配合 ts 的使用,这里还得声明此接口的需要的 body 的类型,如下所示:
1 2 3
| export interface DemoApiBodyDto { public readonly username: string; }
|
方法 2 使用了 class-validator 和 class-transformer 这两个库,通过 ES6 语法、元编程和装饰器,实现了只需定义 schema,无须特别声明 typescript。例如:
1 2 3 4 5 6 7 8
| import { MaxLength, MinLength, IsString } from "class-validator";
export class DemoApiBodyDto { @MaxLength(10) @MinLength(1) @IsString() public readonly username: string; }
|
在 nestjs controller 中使用的时候:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| import { Controller, Body, Post, ValidationPipe } from "@nestjs/common";
@Controller("demo") export class DemoController { constructor(private readonly demoService: DemoService) {}
@Post() async demoApi( @Body(new ValidationPipe({ whitelist: true, transform: true })) body: DemoApiBodyDto ) { } }
|
这种方法写法上更简单,通过装饰器来声明字段的属性,不需要编写冗长的 schema 以及配套的 ts 类型文件。缺点就是某些场景下使用成本高,文档和生态没有 joi 完善。
如何验证嵌套对象?
例如接口要求的入参数据格式是:
1 2 3 4 5 6 7
| { address_detail: { address_info: { user_name: "这个字段不能为空"; } } }
|
那么说明入参中,address_detail 是非空对象,address_info 是非空对象,并且 user_name 需要为字符串。在 class-validator 中,写法如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| import { IsString, ValidateNested, IsNotEmptyObject } from "class-validator";
class AddressInfoDto { @IsString() public readonly user_name: string; }
class AddressDetailDto { @IsNotEmptyObject() @ValidateNested() @Type(() => AddressInfoDto) public readonly address_info: AddressInfoDto; }
export class AddProductAddressDto { @IsNotEmptyObject() @ValidateNested() @Type(() => AddressDetailDto) public readonly address_detail: AddressDetailDto; }
|
如何在 Nestjs Provider 方法中使用 class-validator ?
Nestjs 提供的是在 Controller 上使用 DTO,底层实现是借助 reflect-metadata
实现的。
但是,在对应的 Provider 上想使用 DTO,或者更通俗的说,在普通方法上,使 class-validator 定义的 DTO 生效,应该怎么做?
通过翻看 nestjs 的 ValidationPipe
管道的源码发现,它的实现逻辑是:
- 将数据转换为 DTO 实例对象
- 验证转化后的对象是否符合 DTO 要求
- 有错误,抛出 Nestjs Standard Http Exception;无错误,返回转化后的对象
这里将其抽成一个单元函数,可以被任何函数调用:
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 27 28
| import { plainToClass } from "class-transformer"; import { validate } from "class-validator";
export async function validateDto(Clazz, data) { if (!Clazz) { return data; } const obj = plainToClass(Clazz as any, data, { excludeExtraneousValues: false, }); const errors = await validate(obj); if (errors.length > 0) { const errMsg = inspectLimit( errors.map((err) => { return ( err.constraints || { [err.property]: `Please check ${err.property} format`, } ); }) ); throw new Error(errMsg); } return obj; }
|
在 Provider 的函数中,使用此函数,就能使得 class-validato 定义的 DTO 生效:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| @Injectable() export class DemoService { constructor( private readonly env: EnvService, private readonly httpAgent: HttpAgentService, private readonly logger: LoggerService, private readonly requestCtx: RequestCtxService, ) { }
public async getDemoInfo(params?: GetDemoInfoDto) { params = await validateDto(GetDemoInfoDto, params) }
|
假设定义了一个分页的 DTO:
- page 和 page_size 都是 number 类型,并且不能为空
- 经过
@IsOptional()
声明的参数,前端不传不会报错;否则,不论是否有默认值,还是 @Transform
转换,都会报错
- 如果前端没传入,会读取默认值,不会触发
@Transform
- 如果前端有传入,跳过默认值,会触发绑定的
@Transform
这些可以借助 @Transform
来实现。代码实现如下:
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 27 28 29 30 31 32
| export class DtoTransformBuilder { public static defaultString(defaultValue: string) { return ({ value }) => typeof value === "string" ? value : defaultValue; }
public static defaultInt(defaultValue: number) { return ({ value }) => parseInt(value, 10) || defaultValue; } }
export class PaginationDto { @IsOptional() @Transform(DtoTransformBuilder.defaultInt(1)) @IsInt() public readonly page: number = 1; @IsOptional() @Transform(DtoTransformBuilder.defaultInt(10)) @IsInt() public readonly page_size: number = 10;
@IsOptional() @IsIn(['v1', 'v3']) public readonly signVersion: 'v1' | 'v3' = 'v3'; }
export class SearchListDto extends PaginationDto { @IsString() public readonly kewword: string; }
|
如何复用 DTO 定义?
借助 ES6 语法,可以通过“继承”实现复用:
1 2 3 4
| export class SearchListDto extends PaginationDto { @IsString() public readonly kewword: string; }
|
或者通过@Type
,以“组合”的方式实现复用:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| class AddressInfoDto { @IsString() public readonly user_name: string; }
class AddressDetailDto { @IsNotEmptyObject() @ValidateNested() @Type(() => AddressInfoDto) public readonly address_info: AddressInfoDto;
@IsNotEmptyObject() @ValidateNested() @Type(() => PaginationDto) public readonly pagination: PaginationDto; }
|
参考链接
- NestJS 源码: https://github.com/nestjs/nest/blob/master/packages/common/pipes/validation.pipe.ts
- class-validator 替代 Joi: https://cnodejs.org/topic/5c2a0ad376c4964062a1f60f
- 验证嵌套对象:https://dev.to/avantar/validating-nested-objects-with-class-validator-in-nestjs-1gn8