白留白WHITE SPACE
返回所有文章
NestJS / 后端开发

NestJS 实战 02:DTO 与 ValidationPipe 参数校验

把用户创建接口从“裸对象接收”升级为“受约束输入”。开启全局 ValidationPipe,让非法请求在入口就被拦截。理解为什么校验要放在 Controller 入口层,而不是分散在 Service。

本文属于 NestJS BBS 实战教程,共 11 节,建议按顺序学习。

1. 这节课要实现的功能是什么

  • 把用户创建接口从“裸对象接收”升级为“受约束输入”。
  • 开启全局 ValidationPipe,让非法请求在入口就被拦截。
  • 理解为什么校验要放在 Controller 入口层,而不是分散在 Service。

2. 这节课涉及的 NestJS 知识点有哪些(先讲概念)

2.1 先理解 Pipe:它到底是什么

Pipe 是 Nest 的“参数处理器”。它会在 Controller 方法执行前,对参数做处理。
常见动作有三类:转换类型、校验格式、拒绝非法输入。

所以你可以把 Pipe 理解成 API 入口第一道“数据闸门”。

2.2 再看 ValidationPipe:为什么需要它

ValidationPipe 是最常用的 Pipe,专门负责请求参数校验。
没有它,DTO 上的 class-validator 规则不会自动执行。

它解决的是一个实际问题:
前端请求不可信,后端必须在入口统一拦截错误数据,而不是把校验散落在 Service 的 if 里。

2.3 DTO 是什么,为什么必须用 class

DTO(Data Transfer Object)是“输入契约”。
ValidationPipe 会读取 DTO 上的装饰器元数据来做校验。

因为 TypeScript 的 interface 在编译后会被擦除,运行时拿不到元数据,所以 DTO 必须用 class:

import { IsEmail, IsString, MaxLength, MinLength } from 'class-validator';

export class CreateUserDto {
  @IsString()
  @MinLength(3)
  @MaxLength(30)
  username!: string;

  @IsEmail()
  @MaxLength(255)
  email!: string;

  @IsString()
  @MinLength(8)
  @MaxLength(72)
  password!: string;
}

2.4 ValidationPipe 在请求链路里的执行位置

回答你前面的问题:会先过 Pipe,再进 Controller。
DTO 不是“先执行的一段代码”,而是 Pipe 在运行时读取的规则对象。

请求大致按这条顺序流转:

  1. 请求命中路由;
  2. Nest 读取参数元数据(@Body()、@Param()、DTO 类型);
  3. 执行对应 Pipe(含全局、方法级、参数级);
  4. 全部通过后,才进入 Controller 方法体。

2.5 只写 @Body() dto: CreateUserDto 会自动校验吗?

不会。这个写法本身只提供类型提示,不会触发运行时校验。
如果没有配置 ValidationPipe,一般不会报 400,非法数据会直接流入业务层。

@Post()
create(@Body() dto: CreateUserDto) {
  return this.usersService.create(dto);
}

2.6 如何启用 ValidationPipe(三种方式)

你可以按作用范围选择:

  • 全局:useGlobalPipes,整个应用生效;
  • 方法/控制器级:@UsePipes(...),局部生效;
  • 参数级:@Body(new ValidationPipe()),仅单个参数生效。
@Post()
@UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }))
create(@Body() dto: CreateUserDto) {
  return this.usersService.create(dto);
}

2.7 ValidationPipe 的核心配置项

  • whitelist: true:删除 DTO 未声明字段;
  • forbidNonWhitelisted: true:出现额外字段直接报错;
  • transform: true:按目标类型做转换(例如字符串转数字)。

这三个配置组合,是大多数线上 API 的基础安全配置。

2.8 常用内置 Pipe 与多 Pipe 组合

除了 ValidationPipe,常用内置 Pipe 还有:

  • ParseIntPipe:字符串转整数;
  • ParseBoolPipe:字符串转布尔;
  • ParseUUIDPipe:校验 UUID;
  • ParseArrayPipe:处理数组参数;
  • DefaultValuePipe:设置默认值。

多个 Pipe 可以叠加使用,参数级会按声明顺序执行:

@Get(':id')
findOne(@Param('id', ParseIntPipe, UserExistsPipe) id: number) {
  return this.usersService.findOneById(id);
}

这里会先把 id 转成数字,再校验用户是否存在。

2.9 APP_PIPE 是什么,为什么会有它

APP_PIPE 是 Nest 的全局 Pipe 注入令牌。
把 Provider 绑定到 APP_PIPE 后,这个 Pipe 会全局生效,并且仍然在 DI 容器里。

它存在的原因很直接:app.useGlobalPipes(new XxxPipe()) 虽然简单,但不适合需要依赖注入的全局 Pipe。

典型场景:

  • 全局 Pipe 需要注入 ConfigService;
  • 全局 Pipe 需要注入业务 Service;
  • 你希望全局 Pipe 与其他 Provider 一样统一由模块管理。

2.10 自定义 Pipe 一定要用 APP_PIPE 吗?

不需要。APP_PIPE 只是一种“全局注册方式”,不是自定义 Pipe 的必选项。

  • 只在单个接口使用:参数级或 @UsePipes 即可;
  • 需要全局生效且有依赖注入:优先 APP_PIPE;
  • 纯无依赖、快速全局启用:useGlobalPipes 就够用。

2.11 有依赖的 Provider,为什么要关注可见性

只要一个 Provider 有依赖,就必须确保依赖链可被 DI 容器解析。
不要求全部写在同一个模块,但必须能通过 providers + imports/exports 链路找到。

你可以按这条规则检查:

  1. 当前类是否注册到某个模块 providers;
  2. 它依赖的类是否也已注册;
  3. 跨模块时,提供方是否 exports,使用方是否 imports。

2.12 常见误区

  • 误区 1:只做前端校验,不做后端校验;
  • 误区 2:把校验逻辑全部写进 Service;
  • 误区 3:DTO 没有长度/格式约束,导致脏数据进入系统。

3. 实际开发中这些 NestJS 知识点被如何使用(详细代码)

3.1 src/main.ts - 开启全局校验管道

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,
      forbidNonWhitelisted: true,
      transform: true,
    }),
  );

  await app.listen(3000);
}

bootstrap();

3.2 src/users/dto/create-user.dto.ts - 定义输入契约

import { IsEmail, IsString, MaxLength, MinLength } from 'class-validator';

export class CreateUserDto {
  @IsString()
  @MinLength(3)
  @MaxLength(30)
  username!: string;

  @IsEmail()
  @MaxLength(255)
  email!: string;

  @IsString()
  @MinLength(8)
  @MaxLength(72)
  password!: string;
}

3.3 src/users/users.controller.ts - 在入口层使用 DTO 与 Pipe

import { Body, Controller, Get, Param, ParseIntPipe, Post } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UsersService } from './users.service';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  async create(@Body() dto: CreateUserDto) {
    return this.usersService.create(dto);
  }

  @Get(':id')
  async findOne(@Param('id', ParseIntPipe) id: number) {
    return this.usersService.findOneById(id);
  }
}

3.4 多个 Pipe 同时使用(方法级 + 参数级)

import { Controller, Get, Param, ParseIntPipe, UsePipes, ValidationPipe } from '@nestjs/common';
import { UserExistsPipe } from './pipes/user-exists.pipe';
import { UsersService } from './users.service';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get(':id')
  @UsePipes(new ValidationPipe({ transform: true }))
  findOne(@Param('id', ParseIntPipe, UserExistsPipe) id: number) {
    return this.usersService.findOneById(id);
  }
}

3.5 依赖注入场景下的全局 Pipe(APP_PIPE 示例)

当 Pipe 需要读取配置或注入服务时,推荐在模块里注册:

import { Module, ValidationPipe } from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';

@Module({
  providers: [
    {
      provide: APP_PIPE,
      inject: [ConfigService],
      useFactory: (config: ConfigService) =>
        new ValidationPipe({
          whitelist: true,
          transform: true,
          forbidNonWhitelisted: config.get('NODE_ENV') === 'production',
        }),
    },
  ],
})
export class AppModule {}

3.6 UserExistsPipe 示例(带依赖注入)

先定义 Pipe:

import { Injectable, PipeTransform } from '@nestjs/common';
import { UsersService } from '../users.service';

@Injectable()
export class UserExistsPipe implements PipeTransform<number, Promise<number>> {
  constructor(private readonly usersService: UsersService) {}

  async transform(value: number): Promise<number> {
    await this.usersService.findOneById(value);
    return value;
  }
}

再在 Controller 参数上使用:

@Get(':id')
findOne(@Param('id', ParseIntPipe, UserExistsPipe) id: number) {
  return this.usersService.findOneById(id);
}

最后确保依赖链可见(示例):

@Module({
  imports: [UsersModule],
  providers: [UserExistsPipe],
})
export class ThreadsModule {}

3.7 你可以这样验证效果

# 合法请求
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -d '{"username":"tom","email":"tom@example.com","password":"password123"}'
# 非法请求(email 不合法 + 多余字段 role)
curl -X POST http://localhost:3000/users \
  -H "Content-Type: application/json" \
  -d '{"username":"tom","email":"bad","password":"12345678","role":"admin"}'

你会看到后端在入口阶段直接拒绝,而不是把脏数据送进 Service。

4. 总结这节课知识点

  • DTO 是接口契约,负责把输入规则集中管理。
  • ValidationPipe 在进入 Controller 前执行,DTO 是它读取规则的载体。
  • 你掌握了多个 Pipe 的叠加方式与执行顺序。
  • 你知道了 useGlobalPipes 与 APP_PIPE 的选型边界。
  • UserExistsPipe 让“资源存在性校验”前置,减少重复业务代码。
  • 你理解了有依赖 Provider 的可见性规则(providers + imports/exports)。
  • 从这节课开始,你的 API 已具备基础“可维护 + 可防御”能力。

5. 每节课分支

  • lesson-02-dto-validation
  • 远程仓库:https://github.com/Shiuwn/nestjs-tutorial.git

返回课程目录

上一课:项目初始化与模块化分层

下一课:Repository 抽象与可替换存储

— 感谢阅读 —