Pipe 与校验
这一课你将能做到
- 全局启用
ValidationPipe,配置 whitelist / forbidNonWhitelisted / transform
- 用 class-validator 装饰器约束题目 DTO,非法请求体直接得到 400
- 用
ParseIntPipe、ParseEnumPipe 等把路径与查询参数安全转换
为什么要学
Pipe(管道)人话:请求到达 Controller 方法之前,先做一次「转换」或「校验」。
最常用的是全局 ValidationPipe:它依据 DTO 上的装饰器检查 JSON——字段缺失、类型错误、选项数组为空、answerIndex 不是数字,都会被直接拦下并返回 400,业务方法根本看不到脏数据。这等于在每个接口入口统一设了一道关卡,而不是在每个方法里手写一堆 if。
它对应 Spring 的 @Valid 加 Bean Validation(JSR-303):规则声明在字段上,框架负责执行。
先建立图像
HTTP JSON
→ ValidationPipe(whitelist / forbid / transform)
→ 合法:plain object 被构造成 CreateQuestionDto 实例
→ QuestionsController.create(dto)
→ QuestionsService
非法 → 400 + 字段级错误信息(进不了业务)
三个核心选项:
| 选项 | 人话解释 |
|---|
whitelist: true | 自动剥掉 DTO 里没声明的字段 |
forbidNonWhitelisted: true | 一旦多传未知字段,直接拒绝(400) |
transform: true | 把普通对象转成 DTO 类实例,并按参数类型做转换 |
跟着做
步骤 1:安装校验依赖
npm install class-validator class-transformer
class-validator 提供规则装饰器,class-transformer 负责把 JSON 对象转换成类实例,二者是 Nest 官方校验文档的标准搭档。
步骤 2:全局启用 ValidationPipe
修改 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.setGlobalPrefix("api");
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
此后所有以 class 接收的 @Body() 都会自动校验。
步骤 3:给 CreateQuestionDto 加约束
替换 src/questions/dto/create-question.dto.ts:
import {
ArrayMinSize,
IsArray,
IsEnum,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
} from "class-validator";
export class CreateQuestionDto {
@IsString()
@MinLength(4, { message: "题干至少 4 个字符" })
@MaxLength(500)
stem: string;
@IsArray()
@ArrayMinSize(2, { message: "至少提供 2 个选项" })
@IsString({ each: true })
options: string[];
@IsInt()
@Min(0)
@Max(7)
answerIndex: number;
@IsEnum(["easy", "medium", "hard"], {
message: "difficulty 只能是 easy、medium、hard",
})
difficulty: "easy" | "medium" | "hard";
@IsOptional()
@IsArray()
@IsString({ each: true })
tags?: string[];
}
curl -i -X POST http://localhost:3000/api/questions -H "Content-Type: application/json" -d "{\"stem\":\"x\",\"options\":[],\"answerIndex\":9,\"difficulty\":\"unknown\",\"hacked\":true}"
应得到 400,响应体 message 是一个数组,逐条列出哪个字段违反了什么规则;未知字段 hacked 也会被拒绝。
步骤 4:验证 whitelist 的剥离行为
临时把 forbidNonWhitelisted 改为 false,只保留 whitelist: true,同样多传 hacked。请求可能成功,但进入 Service 的 DTO 里没有这个字段——它被静默剥离了。
生产 API 建议保持 forbidNonWhitelisted: true:让调用方立刻知道自己传了接口不认的字段,而不是埋下「以为生效了其实被忽略」的坑。验证完改回 true。
步骤 5:参数级管道转换数字与枚举
import {
DefaultValuePipe,
Get,
ParseEnumPipe,
ParseIntPipe,
Query,
} from "@nestjs/common";
@Get()
findAll(
@Query("page", new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query("difficulty", new ParseEnumPipe(["easy", "medium", "hard"]))
difficulty?: "easy" | "medium" | "hard",
@Query("tag") tag?: string,
) {
return this.questions.search({ page, difficulty, tag });
}
@Get(":id")
findOne(@Param("id", ParseIntPipe) id: number) {
return this.questions.findOne(id);
}
/api/questions?page=abc → 400(无法转换为整数)
/api/questions?difficulty=xxx → 400(不在枚举内)
/api/questions/1 → 200,id 已经是 number
注意 ParseEnumPipe 对必填查询参数在缺省时也会报错;若该参数可选,把它做成可选管道或自定义校验(入门阶段可以对 difficulty 直接不用 ParseEnumPipe,交给 DTO 之外的判断)。
步骤 6:给提交 DTO 加校验
新建 src/submissions/dto/submit-answer.dto.ts:
import { IsInt, Max, Min } from "class-validator";
export class SubmitAnswerDto {
@IsInt()
@Min(1)
questionId: number;
@IsInt()
@Min(0)
@Max(7)
selectedIndex: number;
}
然后让 Controller 使用它。这样空 body、字符串 id、负下标全都在入口被挡住,SubmissionsService 只需要处理判分本身。
步骤 7:写一个自定义转换管道(选做)
新建 src/common/pipes/trim.pipe.ts,实现 PipeTransform 接口:
import { Injectable, PipeTransform } from "@nestjs/common";
@Injectable()
export class TrimPipe implements PipeTransform {
transform(value: unknown) {
if (typeof value === "string") {
return value.trim();
}
if (value && typeof value === "object") {
for (const key of Object.keys(value)) {
const v = (value as Record<string, unknown>)[key];
if (typeof v === "string") {
(value as Record<string, string>)[key] = v.trim();
}
}
}
return value;
}
}
用 @UsePipes(new TrimPipe()) 挂在方法上即可去除字符串首尾空白。所有内置管道也都实现同一个 PipeTransform 接口——transform(value, metadata) 返回什么,Controller 拿到的就是什么。
讲清楚要点
- 输入不可信,校验必须前置:Pipe 在业务之前执行,脏数据不会扩散到 Service。
- 规则声明在 DTO 上:一处声明、所有使用该 DTO 的接口都生效,新增字段只改 DTO。
- class 是运行时校验的载体:interface 编译后消失,装饰器需要附着在 class 上。
- 多余字段必须处理:whitelist 剥离、forbid 拒绝,避免字段污染和潜在的安全问题。
- Query/Param 默认是字符串:用 Parse 系列管道显式转换,转换失败得到 400 而不是 NaN 进入业务。
- 格式合法不等于业务合法:校验通过只说明形状对;题干是否重复仍由 Service 查数据决定。
你可能会懵的地方
-
全局 Pipe 和方法级 Pipe 冲突吗?
初学者常担心二者冲突——不冲突,会叠加执行。全局 ValidationPipe 负责 body 校验;单个参数上的 ParseIntPipe 负责转换,各司其职。
-
transform: true 不开会怎样?
@Body() 拿到的是普通对象而不是 DTO 类实例,依赖实例的方法(如 class-transformer 的默认值)不会生效。建议保持开启。
-
校验信息能用中文吗?
可以,通过 message 自定义;大型项目一般返回错误码,由前端做国际化展示。
-
class-validator 和 Zod 怎么选?
class-validator 是 Nest 官方文档路径,装饰器风格统一;Zod 在社区也有对应 Pipe,schema 推导更强。入门先跟官方方案,团队已有 Zod 规范再切换。
-
为什么全局 pipe 对 @Query 单个参数不自动校验?
ValidationPipe 主要针对带 class 装饰器的整体对象;单个标量参数用内置的 Parse 管道处理。
错法对照
| 错法 | 问题 | 正解 |
|---|
| DTO 用 interface | 运行时没有元数据可校验 | 用 class + 装饰器 |
| 只靠 TS 类型挡脏数据 | 类型编译后全部擦除 | 启用 ValidationPipe |
Number(page) 不处理 NaN | 脏分页值进入查询 | ParseIntPipe |
| 关闭 whitelist 并信任多余字段 | 字段污染、越权赋值风险 | whitelist + forbid |
| 在每个方法手写 if 校验 | 重复、易漏、难维护 | 规则收敛到 DTO |
卡点急救
- 校验完全没触发:确认全局 Pipe 注册在
main.ts、DTO 是 class、请求头是 Content-Type: application/json。
each: true 不生效:检查 options 字段确实是数组;装饰器组合顺序应为 @IsArray() 在前、@IsString({ each: true }) 在后。
- 枚举查询参数一直 400:ParseEnumPipe 参数应传与 DTO 一致的值列表;可选查询参数不要用强制管道,改用默认值或自定义校验。
面试常问
-
NestJS 的 Pipe 解决什么问题?执行时机是什么?
Pipe 在 Guard 之后、Controller 方法调用之前执行,负责参数转换与校验;ValidationPipe 把不合规请求统一拦截为 400,保证业务方法只接收合法数据。
-
whitelist 与 forbidNonWhitelisted 有什么区别?
whitelist 只是移除 DTO 未声明的属性;forbidNonWhitelisted 进一步在出现多余属性时直接抛 400。后者对调用方更严格、契约更明确。
-
对应 Spring 的什么机制?
ValidationPipe 加 class-validator 对应 Spring MVC 的 @Valid 加 Hibernate Validator;参数转换对应 Converter / Formatter;都是声明式、在入口执行。
自测三问
- whitelist 与 forbidNonWhitelisted 的差别是什么?
- 为什么非法 answerIndex 进不了 QuestionsService.create?
- ParseIntPipe 解决了查询参数的什么本质问题?
小结一句话
ValidationPipe 加 DTO 装饰器构成入口校验体系,Parse 系列管道负责安全的参数转换。
延伸