4. 数据传输对象(DTO)与验证
在 NestJS 应用中,客户端发送的请求数据往往不可信。DTO(Data Transfer Object)模式与验证管道共同构成了数据进入业务逻辑前的第一道防线。本章将系统讲解 DTO 的设计原则、class-validator 与 class-transformer 的集成方式,以及全局 ValidationPipe 的配置策略。
4.1 DTO 模式介绍
DTO 是一种设计模式,用于在不同层之间传输数据。在 NestJS 中,DTO 通常是一个简单的 TypeScript 类,用于定义 API 请求体的结构,并配合装饰器声明验证规则。
核心作用:
- 数据契约:明确 API 期望的数据结构与类型。
- 验证边界:在数据进入 Service 层之前完成格式校验。
- 文档生成:为 Swagger/OpenAPI 提供类型元数据。
- 安全过滤:防止未预期的字段注入(通过 whitelist 机制)。
| 对比维度 | 使用 DTO | 不使用 DTO |
|---|---|---|
| 类型安全 | 强类型,编译期检查 | 弱类型,运行时易出错 |
| 验证逻辑 | 集中管理,装饰器声明 | 分散在 Controller/Service |
| 文档生成 | 自动生成 API 模型 | 需手动编写 |
| 维护成本 | 低,变更一处即可 | 高,需多处同步修改 |
4.2 class-validator 与 class-transformer 集成
NestJS 推荐使用 class-validator 进行声明式验证,class-transformer 负责将普通 JSON 对象转换为类实例。两者配合使用,才能让 ValidationPipe 正常工作。
4.2.1 安装依赖
npm install class-validator class-transformer
4.2.2 定义 DTO 类
以下是一个创建用户的 DTO 示例,展示了常用验证装饰器的组合使用:
import {
IsString,
IsEmail,
IsOptional,
MinLength,
MaxLength,
Matches,
IsEnum,
} from 'class-validator';
export enum UserRole {
ADMIN = 'admin',
USER = 'user',
GUEST = 'guest',
}
export class CreateUserDto {
@IsString()
@MinLength(2, { message: '用户名至少2个字符' })
@MaxLength(20)
@Matches(/^[a-zA-Z0-9_]+$/, {
message: '用户名只能包含字母、数字和下划线',
})
username: string;
@IsEmail({}, { message: '请输入有效的邮箱地址' })
email: string;
@IsString()
@MinLength(8)
@MaxLength(32)
password: string;
@IsOptional()
@IsEnum(UserRole, { message: '角色必须是 admin、user 或 guest' })
role?: UserRole;
}
4.2.3 class-transformer 的隐式转换
当请求体中的字段类型与 DTO 属性类型不匹配时,class-transformer 可以自动进行类型转换:
import { Type } from 'class-transformer';
import { IsNumber, IsArray, ValidateNested } from 'class-validator';
export class OrderItemDto {
@IsString()
productId: string;
@Type(() => Number) // 将字符串 "2" 转换为数字 2
@IsNumber()
quantity: number;
}
export class CreateOrderDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => OrderItemDto)
items: OrderItemDto[];
}
最佳实践:始终在 DTO 属性上使用
@Type() 装饰器显式声明嵌套类型,避免 class-transformer 因缺少元数据而跳过转换。
4.3 全局 ValidationPipe 配置
ValidationPipe 是 NestJS 内置的管道,它利用 class-validator 和 class-transformer 自动验证传入的请求数据。全局注册可以避免在每个 Controller 中重复声明。
4.3.1 基础配置
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
// 关键配置项
whitelist: true, // 自动剔除 DTO 中未定义的属性
forbidNonWhitelisted: true, // 当存在未定义属性时抛出异常
transform: true, // 自动将请求数据转换为 DTO 实例
transformOptions: {
enableImplicitConversion: false, // 关闭隐式转换,推荐显式使用 @Type()
},
}),
);
await app.listen(3000);
}
bootstrap();
4.3.2 配置项详解
| 配置项 | 类型 | 默认值 | 说明 |
|---|---|---|---|
whitelist |
boolean | false | 为 true 时,自动剔除 DTO 中未使用验证装饰器的属性 |
forbidNonWhitelisted |
boolean | false | 为 true 时,请求包含未定义属性则抛出 BadRequestException |
transform |
boolean | false | 为 true 时,将普通对象转换为 DTO 类实例 |
disableErrorMessages |
boolean | false | 生产环境建议设为 true,避免泄露验证细节 |
exceptionFactory |
Function | 默认 | 自定义验证失败时的异常格式 |
4.3.3 自定义异常格式
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
exceptionFactory: (errors) => {
const formattedErrors = errors.map((err) => ({
field: err.property,
constraints: err.constraints,
}));
return new BadRequestException({
statusCode: 400,
message: '请求数据验证失败',
errors: formattedErrors,
});
},
}),
);
安全提示:在生产环境中,务必设置
disableErrorMessages: true,避免将字段验证细节(如正则表达式、枚举值列表)暴露给攻击者。同时,始终启用 whitelist 和 forbidNonWhitelisted,防止通过额外字段进行参数注入攻击。
4.4 DTO 验证流程架构
下图展示了从 HTTP 请求到达 Controller 之前,DTO 验证的完整数据流:
4.5 常见验证场景与最佳实践
4.5.1 条件验证
使用 @ValidateIf 实现条件验证:
import { ValidateIf, IsNotEmpty } from 'class-validator';
export class UpdateProfileDto {
@IsOptional()
@IsString()
name?: string;
@ValidateIf((o) => o.email !== undefined)
@IsNotEmpty({ message: '邮箱不能为空' })
email?: string;
}
4.5.2 自定义验证装饰器
import { registerDecorator, ValidationOptions, ValidationArguments } from 'class-validator';
export function IsPasswordStrong(validationOptions?: ValidationOptions) {
return function (object: Object, propertyName: string) {
registerDecorator({
name: 'isPasswordStrong',
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
validator: {
validate(value: any, args: ValidationArguments) {
return typeof value === 'string' &&
/[A-Z]/.test(value) && // 至少一个大写字母
/[a-z]/.test(value) && // 至少一个小写字母
/[0-9]/.test(value) && // 至少一个数字
/[!@#$%^&*]/.test(value); // 至少一个特殊字符
},
defaultMessage(args: ValidationArguments) {
return '密码必须包含大写字母、小写字母、数字和特殊字符';
},
},
});
};
}
4.5.3 验证组(Validation Groups)
同一 DTO 在不同场景下使用不同的验证规则:
import { IsString, MinLength, ValidateIf } from 'class-validator';
export class UserDto {
@IsString({ groups: ['create'] })
@MinLength(3, { groups: ['create'] })
@ValidateIf((o) => o.id !== undefined, { groups: ['update'] })
@IsString({ groups: ['update'] })
username: string;
}
// 在 Controller 中使用
@Post()
create(@Body(new ValidationPipe({ groups: ['create'] })) dto: UserDto) { }
@Patch(':id')
update(@Body(new ValidationPipe({ groups: ['update'] })) dto: UserDto) { }
性能建议:对于高并发接口,考虑在 DTO 中使用
@IsOptional() 替代 @IsDefined(),减少不必要的验证开销。同时,将复杂的自定义验证器设计为异步(async)时,注意不要阻塞事件循环。
4.6 安全验证清单
| 安全维度 | 配置/实践 | 防护目标 |
|---|---|---|
| 字段白名单 | whitelist: true + forbidNonWhitelisted: true |
防止额外字段注入攻击 |
| 类型转换 | transform: true + 显式 @Type() |
防止类型混淆攻击 |
| 错误信息 | 生产环境 disableErrorMessages: true |
避免泄露验证细节 |
| 输入长度 | @MinLength / @MaxLength |
防止缓冲区溢出或资源耗尽 |
| 特殊字符 | @Matches 正则白名单 |
防止 XSS 或 SQL 注入 |
| 枚举校验 | @IsEnum |
防止非法状态值 |