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 验证的完整数据流:

DTO 验证数据流架构 HTTP 请求 全局 ValidationPipe whitelist + transform class-transformer JSON → DTO 实例 验证 Controller 处理 数据已清洗且类型正确 BadRequestException 返回 400 错误 关键说明: 1. whitelist: true 会在验证前自动剔除未定义属性,防止字段注入。 2. transform: true 确保 Controller 接收的是 DTO 实例而非普通对象。 3. 验证失败时,exceptionFactory 可自定义错误响应格式。

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 防止非法状态值