NestJS源码: DTO实现
概述
介绍 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
) {
// 这里访问body,ts类型声明会生效,body上只有username字段
// ...
}
}
这种方法写法上更简单,通过装饰器来声明字段的属性,不需要编写冗长的 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) // 此字段对应的嵌套结构的DTO
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;
}
// excludeExtraneousValues = true 时,无关属性会被过滤。这里用默认值false
const obj = plainToClass(Clazz as any, data, {
excludeExtraneousValues: false,
});
const errors = await validate(obj);
if (errors.length > 0) {
// inspectLimit 是基于 util.inspect 二次封装的打印函数,请忽视
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)
// ...
}
如何使用 class-transformer 进行字段转化,并填入默认值?
假设定义了一个分页的 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;
}