{T}

NestJS数据校验管道ValidationPipe详解

NestJS数据校验管道ValidationPipe详解

学习目标:理解数据校验的重要性、掌握 ValidationPipe 工作原理、熟练使用 class-validator 和 class-transformer 进行数据校验。


一、数据校验的重要性

1.1 为什么需要数据校验?

code
数据校验的重要性:
│
├── 1. 数据安全性
│   ├── 防止恶意数据注入
│   ├── 防止 SQL 注入攻击
│   └── 防止 XSS 攻击
│
├── 2. 数据完整性
│   ├── 确保必填字段不为空
│   ├── 确保数据格式正确
│   └── 确保数据类型正确
│
├── 3. 业务逻辑正确性
│   ├── 确保数据符合业务规则
│   ├── 防止非法数据进入业务逻辑
│   └── 提前拦截无效请求
│
└── 4. 性能优化
    ├── 减少无效的数据库查询
    ├── 减少服务器资源消耗
    └── 提前返回错误,避免后续处理

1.2 数据校验的位置

code
数据校验的两个位置:
│
├── 前端校验
│   ├── 目的:提升用户体验
│   ├── 优点:快速反馈,无需请求服务器
│   ├── 缺点:可以被绕过(禁用 JavaScript)
│   └── 结论:前端校验不是安全的保障
│
└── 后端校验
    ├── 目的:保障数据安全
    ├── 优点:无法绕过,安全可靠
    ├── 必须性:必须进行后端校验
    └── 结论:后端校验是最后一道防线

重要原则

  • 前端校验 + 后端校验 = 最佳实践
  • 只做前端校验 = 不安全
  • 后端校验是必须的

二、NestJS ValidationPipe 概述

2.1 NestJS 内置的 Pipe

NestJS 提供了多种内置 Pipe:

Pipe类型作用
ValidationPipe校验数据校验(结合 class-validator)
ParseIntPipe转换string → number
ParseFloatPipe转换string → float
ParseBoolPipe转换string → boolean
ParseArrayPipe转换string → array
ParseUUIDPipe校验验证 UUID 格式
ParseEnumPipe校验验证枚举值

2.2 ValidationPipe 的作用

code
ValidationPipe 的作用:
│
├── 1. 数据校验
│   ├── 校验必填字段
│   ├── 校验数据类型
│   ├── 校验数据格式
│   └── 校验业务规则
│
├── 2. 数据转换
│   ├── JSON 对象 → Class 实例
│   ├── 自动去除多余字段
│   └── 自动类型转换
│
├── 3. 错误处理
│   ├── 自动抛出校验错误
│   ├── 返回详细的错误信息
│   └── 统一的错误格式
│
└── 4. 提前拦截
    ├── 校验失败不进入 Controller
    ├── 减少后续处理压力
    └── 提升系统安全性

三、核心依赖库详解

3.1 class-validator 简介

官方定义:基于装饰器的类型校验工具,用于校验 class 类的属性。

GitHub 地址https://github.com/typestack/class-validator

核心功能

code
class-validator 的核心功能:
│
├── 1. 装饰器校验
│   ├── @IsString() - 校验字符串
│   ├── @IsNumber() - 校验数字
│   ├── @IsEmail() - 校验邮箱
│   ├── @IsInt() - 校验整数
│   ├── @IsBoolean() - 校验布尔值
│   ├── @IsDate() - 校验日期
│   ├── @IsArray() - 校验数组
│   ├── @IsEnum() - 校验枚举
│   ├── @Min() - 最小值
│   ├── @Max() - 最大值
│   ├── @MinLength() - 最小长度
│   ├── @MaxLength() - 最大长度
│   ├── @IsNotEmpty() - 非空校验
│   ├── @IsOptional() - 可选字段
│   └── ... 更多装饰器
│
├── 2. 嵌套校验
│   ├── @ValidateNested() - 嵌套对象校验
│   └── @Type() - 类型转换
│
├── 3. 自定义校验
│   ├── @ValidatorConstraint() - 自定义校验器
│   └── @ValidateBy() - 自定义校验规则
│
└── 4. 分组校验
    └── groups: ['create', 'update'] - 不同场景校验

基本使用示例

typescript
import { IsString, IsInt, IsEmail, Min, Max, IsNotEmpty } from 'class-validator';

export class CreateUserDto {
  @IsString({ message: 'name 必须是字符串' })
  @IsNotEmpty({ message: 'name 不能为空' })
  name: string;

  @IsInt({ message: 'age 必须是整数' })
  @Min(0, { message: 'age 不能小于 0' })
  @Max(150, { message: 'age 不能大于 150' })
  age: number;

  @IsEmail({}, { message: 'email 格式不正确' })
  email: string;
}

3.2 class-transformer 简介

官方定义:基于装饰器的对象转换、序列化、反序列化工具。

GitHub 地址https://github.com/typestack/class-transformer

核心功能

code
class-transformer 的核心功能:
│
├── 1. 对象转换
│   ├── plainToClass() - 普通对象 → Class 实例
│   ├── plainToInstance() - 普通对象 → Class 实例(推荐)
│   ├── classToPlain() - Class 实例 → 普通对象
│   └── instanceToPlain() - Class 实例 → 普通对象(推荐)
│
├── 2. 装饰器
│   ├── @Expose() - 暴露属性
│   ├── @Exclude() - 排除属性
│   ├── @Type() - 类型转换
│   ├── @Transform() - 自定义转换
│   └── @Exclude() - 排除属性
│
└── 3. 序列化控制
    ├── 控制哪些属性被序列化
    ├── 控制哪些属性被反序列化
    └── 支持嵌套对象转换

为什么需要 class-transformer?

typescript
//  问题:普通的 JSON 对象无法使用实例方法
const userJson = {
  id: 1,
  firstName: 'Tom',
  lastName: 'Smith',
};

// 访问属性需要使用对象属性访问
console.log(userJson['firstName']);  // Tom
console.log(userJson.firstName);      // Tom

//  解决:使用 class-transformer 转换为实例
class User {
  id: number;
  firstName: string;
  lastName: string;

  // 可以定义实例方法
  getFullName(): string {
    return `${this.firstName} ${this.lastName}`;
  }
}

const user = plainToInstance(User, userJson);
console.log(user.getFullName());  // Tom Smith
console.log(user.firstName);      // Tom

class-transformer 使用示例

typescript
import { plainToInstance, instanceToPlain, Expose, Exclude, Type } from 'class-transformer';

export class UserDto {
  @Expose()
  id: number;

  @Expose()
  firstName: string;

  @Expose()
  lastName: string;

  @Exclude()
  password: string;  // 不暴露密码字段

  @Expose()
  @Type(() => Date)
  createdAt: Date;
}

// 普通对象 → Class 实例
const plainUser = {
  id: 1,
  firstName: 'Tom',
  lastName: 'Smith',
  password: '123456',
  createdAt: '2024-01-01T00:00:00.000Z',
};

const userInstance = plainToInstance(UserDto, plainUser);
// userInstance 是 UserDto 的实例
// userInstance.password 会被排除

// Class 实例 → 普通对象
const plainObject = instanceToPlain(userInstance);
// plainObject 是普通对象
// plainObject 中不包含 password 字段

3.3 两个库的关系

code
class-validator 和 class-transformer 的关系:
│
├── class-transformer
│   ├── 负责数据转换
│   ├── JSON 对象 → Class 实例
│   ├── 控制序列化/反序列化
│   └── 为 class-validator 准备数据
│
├── class-validator
│   ├── 负责数据校验
│   ├── 校验 Class 实例的属性
│   ├── 提供丰富的校验装饰器
│   └── 返回校验结果或错误
│
└── ValidationPipe
    ├── 协调两个库的工作
    ├── 自动调用 class-transformer 转换
    ├── 自动调用 class-validator 校验
    └── 统一处理校验结果

工作流程

code
ValidationPipe 工作流程:
│
├── 第一步:接收请求数据
│   └── { "name": "Tom", "age": 25, "email": "tom@example.com" }
│
├── 第二步:class-transformer 转换
│   ├── 将 JSON 对象转换为 DTO 类实例
│   └── CreateUserDto 实例
│
├── 第三步:class-validator 校验
│   ├── 校验 DTO 实例的属性
│   ├── 检查装饰器规则
│   └── 返回校验结果
│
└── 第四步:结果处理
    ├── 校验成功 → 进入 Controller
    └── 校验失败 → 抛出异常,返回错误信息

四、ValidationPipe 工作原理

4.1 管道的两大类型

code
NestJS Pipe 的两大类型:
│
├── 1. 转换类型 Pipe(Transformation)
│   ├── 作用:将输入数据转换为所需格式
│   ├── 示例:ParseIntPipe、ParseBoolPipe
│   ├── 场景:类型转换、格式转换
│   └── 流程:string → number
│
└── 2. 校验类型 Pipe(Validation)
    ├── 作用:验证输入数据是否符合要求
    ├── 示例:ValidationPipe、ParseUUIDPipe
    ├── 场景:数据校验、格式验证
    └── 流程:数据 → 校验 → 通过/失败

4.2 ValidationPipe 完整工作流程

code
ValidationPipe 完整工作流程图:
│
├── 第一步:用户发起请求
│   └── POST /users { "name": "", "age": -1, "email": "invalid" }
│
├── 第二步:请求到达 Controller 前
│   └── 经过 ValidationPipe
│
├── 第三步:ValidationPipe 调用 class-transformer
│   ├── 将 JSON 对象转换为 DTO 类实例
│   └── const dto = plainToInstance(CreateUserDto, requestBody)
│
├── 第四步:ValidationPipe 调用 class-validator
│   ├── 校验 DTO 实例的所有属性
│   ├── 检查每个属性的装饰器规则
│   └── const errors = await validate(dto)
│
├── 第五步:判断校验结果
│   ├── errors.length === 0 → 校验成功
│   └── errors.length > 0 → 校验失败
│
├── 第六步 A:校验成功
│   ├── 将 DTO 实例传递给 Controller
│   ├── Controller 调用 Service
│   ├── Service 执行业务逻辑
│   └── 返回响应给前端
│
└── 第六步 B:校验失败
    ├── 抛出 BadRequestException
    ├── 不进入 Controller
    ├── 不执行后续业务逻辑
    └── 返回详细的错误信息给前端

4.3 ValidationPipe 工作原理详解

请求流程对比

code
有 ValidationPipe:
│
用户请求 → ValidationPipe → 校验成功 → Controller → Service → Database
                     ↓
                  校验失败 → 返回错误(不进入 Controller)

无 ValidationPipe:
│
用户请求 → Controller → Service → Database
             ↓
         可能写入无效数据

ValidationPipe 的优势

code
ValidationPipe 的优势:
│
├── 1. 提前拦截
│   ├── 校验失败不进入 Controller
│   ├── 不执行业务逻辑
│   └── 减少服务器资源消耗
│
├── 2. 减少数据库压力
│   ├── 无效数据不进入 Service
│   ├── 无效数据不查询数据库
│   └── 避免无效的数据库操作
│
├── 3. 统一的错误格式
│   ├── 自动抛出 BadRequestException
│   ├── 返回详细的错误信息
│   └── 前端易于处理错误
│
└── 4. 代码简洁
    ├── 使用装饰器定义规则
    ├── 无需手动编写校验逻辑
    └── 易于维护和扩展

4.4 校验失败的处理

校验失败时的行为

typescript
// 用户提交的数据
{
  "name": "",           // 不能为空
  "age": -1,           // 不能小于 0
  "email": "invalid"   // 不是有效的邮箱格式
}

// ValidationPipe 校验失败后的响应
{
  "statusCode": 400,
  "message": [
    "name 不能为空",
    "age 不能小于 0",
    "email 格式不正确"
  ],
  "error": "Bad Request"
}

重要特性

  • 校验失败会立即返回错误
  • 不会进入 Controller 方法
  • 不会执行后续业务逻辑
  • 不会访问数据库

五、class-validator 常用装饰器

5.1 类型校验装饰器

装饰器作用示例
@IsString()校验字符串@IsString()
@IsNumber()校验数字@IsNumber()
@IsInt()校验整数@IsInt()
@IsBoolean()校验布尔值@IsBoolean()
@IsDate()校验日期@IsDate()
@IsArray()校验数组@IsArray()
@IsEnum()校验枚举@IsEnum(Status)
@IsObject()校验对象@IsObject()

5.2 字符串校验装饰器

装饰器作用示例
@IsEmail()校验邮箱格式@IsEmail()
@IsUrl()校验 URL 格式@IsUrl()
@IsUUID()校验 UUID 格式@IsUUID()
@IsDate()校验日期格式@IsDateString()
@IsIP()校验 IP 地址@IsIP()
@IsJSON()校验 JSON 字符串@IsJSON()
@IsMongoId()校验 MongoDB ID@IsMongoId()

5.3 长度和范围校验装饰器

装饰器作用示例
@MinLength(min)最小长度@MinLength(6)
@MaxLength(max)最大长度@MaxLength(20)
@Length(min, max)固定长度范围@Length(6, 20)
@Min(min)最小值@Min(0)
@Max(max)最大值@Max(150)
@Range(min, max)数值范围@Range(0, 150)

5.4 必填和可选装饰器

装饰器作用示例
@IsNotEmpty()非空校验@IsNotEmpty()
@IsOptional()可选字段@IsOptional()
@IsEmpty()必须为空@IsEmpty()
@IsDefined()必须定义@IsDefined()

5.5 相等性校验装饰器

装饰器作用示例
@Equals(value)等于某个值@Equals('admin')
@NotEquals(value)不等于某个值@NotEquals('guest')
@IsIn(values)在列表中@IsIn(['admin', 'user'])
@IsNotIn(values)不在列表中@IsNotIn(['guest', 'banned'])

5.6 常用装饰器示例

typescript
import {
  IsString,
  IsInt,
  IsEmail,
  IsNotEmpty,
  IsOptional,
  Min,
  Max,
  MinLength,
  MaxLength,
  IsEnum,
  IsArray,
  IsDate,
} from 'class-validator';

export class CreateUserDto {
  // 字符串校验
  @IsString({ message: 'name 必须是字符串' })
  @IsNotEmpty({ message: 'name 不能为空' })
  @MinLength(2, { message: 'name 至少 2 个字符' })
  @MaxLength(20, { message: 'name 最多 20 个字符' })
  name: string;

  // 数字校验
  @IsInt({ message: 'age 必须是整数' })
  @Min(0, { message: 'age 不能小于 0' })
  @Max(150, { message: 'age 不能大于 150' })
  age: number;

  // 邮箱校验
  @IsEmail({}, { message: 'email 格式不正确' })
  @IsNotEmpty({ message: 'email 不能为空' })
  email: string;

  // 可选字段
  @IsOptional()
  @IsString()
  @MaxLength(200)
  bio?: string;

  // 枚举校验
  @IsEnum(['admin', 'user', 'guest'], { message: 'role 必须是 admin、user 或 guest' })
  role: string;

  // 数组校验
  @IsArray({ message: 'tags 必须是数组' })
  @IsString({ each: true, message: 'tags 中的每个元素必须是字符串' })
  tags: string[];

  // 日期校验
  @IsDate({ message: 'birthday 必须是日期' })
  @IsOptional()
  birthday?: Date;
}

六、自定义校验装饰器

6.1 自定义校验器

创建自定义校验器

typescript
import {
  ValidatorConstraint,
  ValidatorConstraintInterface,
  registerDecorator,
  ValidationOptions,
  ValidationArguments,
} from 'class-validator';

// 1. 定义校验器
@ValidatorConstraint({ name: 'isLongerThan', async: false })
export class IsLongerThanConstraint implements ValidatorConstraintInterface {
  validate(propertyValue: any, args: ValidationArguments) {
    const [relatedPropertyName] = args.constraints;
    const relatedValue = (args.object as any)[relatedPropertyName];
    
    return (
      typeof propertyValue === 'string' &&
      typeof relatedValue === 'string' &&
      propertyValue.length > relatedValue.length
    );
  }

  defaultMessage(args: ValidationArguments) {
    const [relatedPropertyName] = args.constraints;
    return `${args.property} 必须比 ${relatedPropertyName} 长`;
  }
}

// 2. 定义装饰器
export function IsLongerThan(
  property: string,
  validationOptions?: ValidationOptions,
) {
  return function (object: Object, propertyName: string) {
    registerDecorator({
      target: object.constructor,
      propertyName: propertyName,
      options: validationOptions,
      constraints: [property],
      validator: IsLongerThanConstraint,
    });
  };
}

使用自定义装饰器

typescript
export class CreateUserDto {
  @IsString()
  firstName: string;

  @IsString()
  @IsLongerThan('firstName', { message: 'lastName 必须比 firstName 长' })
  lastName: string;
}

6.2 常见自定义校验示例

示例一:手机号校验

typescript
import { ValidatorConstraint, ValidatorConstraintInterface } from 'class-validator';

@ValidatorConstraint({ name: 'isPhone', async: false })
export class IsPhoneConstraint implements ValidatorConstraintInterface {
  validate(phone: any) {
    if (typeof phone !== 'string') return false;
    
    // 中国大陆手机号正则
    const phoneRegex = /^1[3-9]\d{9}$/;
    return phoneRegex.test(phone);
  }

  defaultMessage() {
    return '手机号格式不正确';
  }
}

// 使用装饰器
export function IsPhone(validationOptions?: ValidationOptions) {
  return function (object: Object, propertyName: string) {
    registerDecorator({
      target: object.constructor,
      propertyName: propertyName,
      options: validationOptions,
      validator: IsPhoneConstraint,
    });
  };
}

示例二:密码强度校验

typescript
@ValidatorConstraint({ name: 'isStrongPassword', async: false })
export class IsStrongPasswordConstraint implements ValidatorConstraintInterface {
  validate(password: any) {
    if (typeof password !== 'string') return false;
    
    // 至少 8 位,包含大小写字母、数字和特殊字符
    const regex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/;
    return regex.test(password);
  }

  defaultMessage() {
    return '密码至少 8 位,必须包含大小写字母、数字和特殊字符';
  }
}

七、ValidationPipe 配置选项

7.1 全局配置 ValidationPipe

在 main.ts 中配置

typescript
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // 全局配置 ValidationPipe
  app.useGlobalPipes(
    new ValidationPipe({
      // 选项配置
    }),
  );

  await app.listen(3000);
}
bootstrap();

7.2 ValidationPipe 配置选项详解

typescript
app.useGlobalPipes(
  new ValidationPipe({
    // 1. 转换选项
    transform: true,                    // 自动转换类型(推荐)
    transformOptions: {
      enableImplicitConversion: true,   // 启用隐式类型转换
      excludeExtraneousValues: true,    // 排除未使用 @Expose() 装饰的属性
    },

    // 2. 校验选项
    disableErrorMessages: false,        // 是否禁用详细错误信息(生产环境可设为 true)
    validationError: {
      target: false,                    // 不在错误中包含目标对象
      value: false,                     // 不在错误中包含错误值
    },

    // 3. 白名单选项
    whitelist: true,                    // 启用白名单(推荐)
    forbidNonWhitelisted: true,         // 拒绝未在白名单中的属性(推荐)

    // 4. 跳过属性
    skipMissingProperties: false,       // 是否跳过缺失的属性
    skipNullProperties: false,          // 是否跳过 null 属性
    skipUndefinedProperties: false,     // 是否跳过 undefined 属性

    // 5. 分组校验
    always: false,                      // 始终校验,不管分组
    groups: [],                         // 校验分组

    // 6. 停止校验
    stopAtFirstError: false,            // 遇到第一个错误就停止(推荐)
  }),
);

7.3 关键配置说明

1. transform: true(自动类型转换)

typescript
// DTO 定义
export class CreateUserDto {
  @IsInt()
  age: number;
}

// 用户提交的数据
{
  "age": "25"  // string 类型
}

// transform: false → age 是 "25"(string)
// transform: true  → age 是 25(number)

2. whitelist: true + forbidNonWhitelisted: true(白名单机制)

typescript
// DTO 定义
export class CreateUserDto {
  @IsString()
  name: string;

  @IsInt()
  age: number;
}

// 用户提交的数据(包含恶意字段)
{
  "name": "Tom",
  "age": 25,
  "isAdmin": true,    // 恶意字段
  "role": "admin"     // 恶意字段
}

// whitelist: true, forbidNonWhitelisted: true
// 结果:抛出错误,拒绝包含未定义字段的请求 

// whitelist: true, forbidNonWhitelisted: false
// 结果:自动过滤掉 isAdmin 和 role 字段 

// whitelist: false
// 结果:保留所有字段(包括恶意字段)

3. stopAtFirstError: true(遇到第一个错误就停止)

typescript
// 用户提交的数据
{
  "name": "",
  "age": -1,
  "email": "invalid"
}

// stopAtFirstError: false
// 返回所有错误:
// ["name 不能为空", "age 不能小于 0", "email 格式不正确"]

// stopAtFirstError: true
// 返回第一个错误:
// ["name 不能为空"]

7.4 生产环境推荐配置

typescript
// 开发环境配置
const devValidationOptions = {
  transform: true,
  whitelist: true,
  forbidNonWhitelisted: true,
  stopAtFirstError: false,
  disableErrorMessages: false,  // 显示详细错误信息
};

// 生产环境配置
const prodValidationOptions = {
  transform: true,
  whitelist: true,
  forbidNonWhitelisted: true,
  stopAtFirstError: true,
  disableErrorMessages: true,   // 隐藏详细错误信息(安全)
};

// 根据环境选择配置
app.useGlobalPipes(
  new ValidationPipe(
    process.env.NODE_ENV === 'production'
      ? prodValidationOptions
      : devValidationOptions
  ),
);

八、完整实战示例

8.1 安装依赖

bash
# 安装 class-validator 和 class-transformer
$ pnpm add class-validator class-transformer

8.2 创建 DTO

CreateUserDto

typescript
// src/modules/user/dto/create-user.dto.ts
import {
  IsString,
  IsInt,
  IsEmail,
  IsNotEmpty,
  IsOptional,
  Min,
  Max,
  MinLength,
  MaxLength,
  IsEnum,
  IsArray,
  IsDateString,
  IsPhoneNumber,
} from 'class-validator';

export enum UserRole {
  ADMIN = 'admin',
  USER = 'user',
  GUEST = 'guest',
}

export class CreateUserDto {
  // 必填字段
  @IsString({ message: '用户名必须是字符串' })
  @IsNotEmpty({ message: '用户名不能为空' })
  @MinLength(2, { message: '用户名至少 2 个字符' })
  @MaxLength(20, { message: '用户名最多 20 个字符' })
  username: string;

  @IsString({ message: '密码必须是字符串' })
  @IsNotEmpty({ message: '密码不能为空' })
  @MinLength(8, { message: '密码至少 8 个字符' })
  @MaxLength(20, { message: '密码最多 20 个字符' })
  password: string;

  @IsEmail({}, { message: '邮箱格式不正确' })
  @IsNotEmpty({ message: '邮箱不能为空' })
  email: string;

  // 可选字段
  @IsOptional()
  @IsString({ message: '昵称必须是字符串' })
  @MaxLength(50, { message: '昵称最多 50 个字符' })
  nickname?: string;

  @IsOptional()
  @IsInt({ message: '年龄必须是整数' })
  @Min(0, { message: '年龄不能小于 0' })
  @Max(150, { message: '年龄不能大于 150' })
  age?: number;

  @IsOptional()
  @IsEnum(UserRole, { message: '角色必须是 admin、user 或 guest' })
  role?: UserRole;

  @IsOptional()
  @IsArray({ message: '标签必须是数组' })
  @IsString({ each: true, message: '标签中的每个元素必须是字符串' })
  tags?: string[];

  @IsOptional()
  @IsDateString({}, { message: '生日格式不正确' })
  birthday?: string;
}

8.3 配置全局 ValidationPipe

typescript
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // 配置全局 ValidationPipe
  app.useGlobalPipes(
    new ValidationPipe({
      transform: true,                   // 自动类型转换
      whitelist: true,                   // 启用白名单
      forbidNonWhitelisted: true,        // 拒绝未定义的字段
      stopAtFirstError: false,           // 返回所有错误
      disableErrorMessages: false,       // 显示详细错误信息
      validationError: {
        target: false,
        value: false,
      },
    }),
  );

  await app.listen(3000);
}
bootstrap();

8.4 Controller 使用 DTO

typescript
// src/modules/user/user.controller.ts
import { Controller, Post, Body } from '@nestjs/common';
import { UserService } from './user.service';
import { CreateUserDto } from './dto/create-user.dto';

@Controller('users')
export class UserController {
  constructor(private readonly userService: UserService) {}

  @Post()
  async create(@Body() createUserDto: CreateUserDto) {
    // createUserDto 已经经过校验,数据是安全的
    return this.userService.create(createUserDto);
  }
}

8.5 测试示例

测试一:校验成功

bash
# 请求
POST http://localhost:3000/users
Content-Type: application/json

{
  "username": "tomsmith",
  "password": "password123",
  "email": "tom@example.com",
  "age": 25,
  "role": "user",
  "tags": ["developer", "designer"]
}

# 响应
{
  "id": 1,
  "username": "tomsmith",
  "email": "tom@example.com",
  "age": 25,
  "role": "user",
  "tags": ["developer", "designer"]
}

测试二:校验失败(字段为空)

bash
# 请求
POST http://localhost:3000/users
Content-Type: application/json

{
  "username": "",
  "password": "123",
  "email": "invalid-email"
}

# 响应
{
  "statusCode": 400,
  "message": [
    "用户名不能为空",
    "用户名至少 2 个字符",
    "密码至少 8 个字符",
    "邮箱格式不正确"
  ],
  "error": "Bad Request"
}

测试三:校验失败(包含恶意字段)

bash
# 请求
POST http://localhost:3000/users
Content-Type: application/json

{
  "username": "tomsmith",
  "password": "password123",
  "email": "tom@example.com",
  "isAdmin": true,    // 恶意字段
  "role": "admin"     // 恶意字段
}

# 响应(forbidNonWhitelisted: true)
{
  "statusCode": 400,
  "message": [
    "property isAdmin should not exist",
    "property role should not exist"
  ],
  "error": "Bad Request"
}

九、校验管道的最佳实践

9.1 DTO 设计原则

code
DTO 设计原则:
│
├── 1. 单一职责
│   ├── CreateUserDto:创建用户
│   ├── UpdateUserDto:更新用户
│   └── QueryUserDto:查询用户
│
├── 2. 使用 PartialType
│   ├── UpdateUserDto extends PartialType(CreateUserDto)
│   └── 所有字段变为可选
│
├── 3. 分组校验
│   ├── groups: ['create'] - 创建时校验
│   ├── groups: ['update'] - 更新时校验
│   └── 不同场景不同校验规则
│
└── 4. 继承复用
    ├── BaseDto:公共字段
    └── CreateUserDto extends BaseDto

9.2 错误消息国际化

typescript
// 使用函数动态返回错误消息
@IsString({
  message: (args: ValidationArguments) => {
    return `${args.property} 必须是字符串`;
  },
})
name: string;

9.3 嵌套对象校验

typescript
import { ValidateNested, IsString, Type } from 'class-validator';

export class AddressDto {
  @IsString()
  city: string;

  @IsString()
  street: string;
}

export class CreateUserDto {
  @IsString()
  name: string;

  @ValidateNested()
  @Type(() => AddressDto)
  address: AddressDto;
}

9.4 异步校验

typescript
import { ValidatorConstraint, ValidatorConstraintInterface } from 'class-validator';
import { UserService } from './user.service';

@ValidatorConstraint({ name: 'isUserAlreadyExist', async: true })
export class IsUserAlreadyExistConstraint implements ValidatorConstraintInterface {
  constructor(private userService: UserService) {}

  async validate(email: string) {
    const user = await this.userService.findByEmail(email);
    return !user;  // 用户不存在时返回 true
  }

  defaultMessage() {
    return '用户已存在';
  }
}

十、常见问题与解决方案

10.1 校验不生效

问题:配置了 ValidationPipe,但校验不生效。

原因

  1. 没有全局配置 ValidationPipe
  2. DTO 中没有使用校验装饰器
  3. 没有安装 class-validator 和 class-transformer

解决方案

typescript
// 1. 确保安装了依赖
$ pnpm add class-validator class-transformer

// 2. 在 main.ts 中全局配置
app.useGlobalPipes(new ValidationPipe());

// 3. 在 DTO 中使用装饰器
export class CreateUserDto {
  @IsString()
  name: string;
}

10.2 类型转换失败

问题:提交的数据类型没有自动转换。

原因:没有开启 transform: true

解决方案

typescript
app.useGlobalPipes(
  new ValidationPipe({
    transform: true,  // 启用自动类型转换
  }),
);

10.3 恶意字段未过滤

问题:提交的恶意字段(如 isAdmin)没有被过滤。

原因:没有开启白名单机制。

解决方案

typescript
app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,              // 启用白名单
    forbidNonWhitelisted: true,   // 拒绝未定义的字段
  }),
);

10.4 错误信息过于详细

问题:生产环境不想暴露详细的错误信息。

原因disableErrorMessagesfalse

解决方案

typescript
// 生产环境配置
app.useGlobalPipes(
  new ValidationPipe({
    disableErrorMessages: true,  // 禁用详细错误信息
  }),
);

十一、学习要点总结

11.1 核心知识点

code
本节核心知识点:
│
├── 数据校验的重要性
│   ├── 前端校验 + 后端校验
│   ├── 后端校验是必须的
│   └── 保障数据安全和完整性
│
├── ValidationPipe 工作原理
│   ├── 用户请求 → ValidationPipe
│   ├── class-transformer 转换数据
│   ├── class-validator 校验数据
│   ├── 校验成功 → Controller
│   └── 校验失败 → 返回错误
│
├── class-validator
│   ├── 基于装饰器的类型校验
│   ├── 提供丰富的内置装饰器
│   ├── 支持自定义校验器
│   └── 校验 Class 实例的属性
│
├── class-transformer
│   ├── 基于装饰器的对象转换
│   ├── JSON 对象 → Class 实例
│   ├── 控制序列化/反序列化
│   └── 为 class-validator 准备数据
│
├── ValidationPipe 配置
│   ├── transform: true(类型转换)
│   ├── whitelist: true(白名单)
│   ├── forbidNonWhitelisted: true(拒绝恶意字段)
│   └── stopAtFirstError: true(遇到第一个错误停止)
│
└── 最佳实践
    ├── DTO 单一职责
    ├── 使用 PartialType 复用
    ├── 嵌套对象校验
    └── 分组校验

11.2 学习路径规划

code
学习路径:
│
├── 第一阶段:理解概念(1 天)
│   ├── 理解数据校验的重要性
│   ├── 理解 ValidationPipe 工作原理
│   └── 理解 class-validator 和 class-transformer 的作用
│
├── 第二阶段:实践使用(2-3 天)
│   ├── 安装依赖
│   ├── 创建 DTO
│   ├── 配置全局 ValidationPipe
│   └── 测试校验功能
│
└── 第三阶段:深入应用(持续)
    ├── 自定义校验装饰器
    ├── 嵌套对象校验
    ├── 分组校验
    └── 异步校验

11.3 重要程度标注

code
重要程度说明:
│
├──  必须掌握
│   ├── 数据校验的重要性
│   ├── ValidationPipe 工作原理
│   ├── class-validator 常用装饰器
│   ├── ValidationPipe 关键配置
│   └── 创建 DTO 并使用装饰器
│
├──  重要
│   ├── class-transformer 的作用
│   ├── ValidationPipe 配置选项
│   ├── 白名单机制
│   └── 类型自动转换
│
└──  了解
    ├── 自定义校验装饰器
    ├── 嵌套对象校验
    ├── 分组校验
    └── 异步校验

重要提示:这一节是 NestJS 数据校验的核心内容,理解 ValidationPipe 工作原理、熟练使用 class-validator 装饰器、掌握 ValidationPipe 配置,对实际项目开发非常重要!特别是要理解校验管道的工作原理,以及如何通过白名单机制防止恶意数据注入!

下一节预告:下一节将学习完整的 CRUD 接口实现,包括创建、更新、删除等操作的数据校验和业务逻辑处理。