项目开源地址(本专栏实证代码的教程仓)
https://gitee.com/yanjinqiang/corp-rag-tutorial(复制到浏览器打开)
承接上篇:上一篇《Guards 守卫》拆了链路第二站——守卫裁决"这个人能不能来",读的是
@Public()/@RequireAdmin()写下的暗号。裁决放行之后,请求继续往里走,该轮到"来的人带的参数合不合格"了。 这篇拆链路第三站:管道怎么把"裸 JSON"收拾成"可信 DTO"?ValidationPipe的三个开关各管什么?以及 08 期预告过的那个洞——纯空白串" "能骗过@MinLength(1),rag-server 是用一条全局管道在它前面堵上的,这条"先归一化后校验"的顺序凭什么保证?
定位:本篇讲管道这一站:
transform契约、"转换 + 校验"两副面孔、DTO 为什么必须是 class、TrimBodyPipe逐行拆、APP_PIPE与useGlobalPipes两种全局注册的时序链(本篇机制核心)、ValidationPipe 三开关、绑定层级。不讲拦截器(12 期)、不讲异常过滤器怎么翻译 400(13 期)。读完你能自己写一条自定义全局管道,并能说清"管道顺序"在两个注册入口下由什么决定。
一、一句话回答
Pipe(管道)是 Controller 的"安检门":它在参数进方法之前跑,负责把外部传入的"裸值"转换成想要的类型、按规则校验它合不合格——不合规直接抛 400,方法体根本不会执行。
它和守卫的分工一句话:守卫管"人"(鉴权),管道管"货"(参数)。
客户端 JSON body 管道(依次过) Controller
─────────────────────────────────────────────────────────────────────────────
{ "system":"sap", ┌─ TrimBodyPipe ──── 递归 trim 所有字符串值 ┐
"question":" 你好 ", ├─ ValidationPipe ── 校验/剥离/实例化 ┘ → 拿到
"hack":"注入字段" } ───→ │ whitelist: hack 被剥/直接 400 │ 干净的
│ 规则校验: system 在预设列表? 长度? │ QueryDto
└ transform: 转成 QueryDto 实例 ┘ 实例
不合规 ──→ throw BadRequestException ──→ 异常过滤器(13 期)
记忆锚点:Middleware 是外层闸门、Guard 是读门牌暗号的门卫——Pipe 则是进门前的安检机,X 光扫的是你带的行李(参数),不是你这个人。
二、项目的全部管道现场:两个全局管道 + 规则长在 DTO 上
先如实交底:rag-server 的管道一共两条,都是全局的,但注册在两个不同的地方——这正是本篇机制核心的伏笔:
// ① app.module.ts —— TrimBodyPipe,以 APP_PIPE 注册(组合根里声明)
providers: [
{ provide: APP_GUARD, useClass: AuthGuard },
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
{ provide: APP_PIPE, useClass: TrimBodyPipe }, // ← 本篇主角之一
{ provide: APP_GUARD, useClass: DebugGuard },
...
]
// ② main.ts —— ValidationPipe,bootstrap 里手动 new、useGlobalPipes 注册
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // 剥离 DTO 里没声明的字段
forbidNonWhitelisted: true, // 带了未声明字段直接拒绝(400)
transform: true, // 把请求体转成 DTO 类的实例(含类型转换)
}),
);
而校验规则不在注册处,长在每个 DTO 类的字段上(src/rag/dto/query.dto.ts):
import { IsIn, IsOptional, IsString, MaxLength, MinLength } from "class-validator";
import { SYSTEMS, type System } from "@corprag/core";
export class QueryDto {
@ApiProperty({ description: "目标业务系统", enum: SYSTEMS as unknown as string[] })
@IsString()
@IsIn([...SYSTEMS]) // 必须在预设业务系统列表里
system!: System;
@ApiProperty({ description: "自然语言问题", minLength: 1, maxLength: 1000 })
@IsString()
@MinLength(1) // 长度 ≥ 1 —— 注意:拦不住 " ",第五节专讲
@MaxLength(1000)
question!: string;
@ApiPropertyOptional({ description: "文档类型过滤", enum: ["BADR", "FADR"] })
@IsOptional() // 可不传
@IsIn(["BADR", "FADR"]) // 传了就必须是这两个值之一
docType?: "BADR" | "FADR";
}
配套的 reindex.dto.ts 更简单——只有一个可选字段 system(不传就重建全部、传了必须是合法系统名)。
"注册处只管开关、规则长在 DTO 上"是 Nest 校验的标准姿势:加一个新接口 = 建一个新 DTO 类写规则,注册处一行不动。
触发器→机制层:看到方法参数是
@Body() dto: QueryDto、而校验开关在注册处——它们靠什么连起来? 靠参数元数据。@Body()告诉管道"这是 body",参数的类型标注(: QueryDto)告诉管道"该用哪套规则"——QueryDto这个类会被写进参数元数据,ValidationPipe 取出这个类、读它字段上的 class-validator 装饰器、对 body 逐条执行。类型标注本身不是校验,它是管道"找到规则"的路标。(还记得 10 期的守卫怎么找到暗号吗——同一个套路,元数据。)
三、管道的两副面孔:转换 + 校验,别只记住一个
Pipe 的官方定义是两个词:transforms the input and/or validates it。新手常只记住"校验器",但 Nest 内建管道大多是"纯转换"型:
| 内建管道 | 干什么 | 是转换还是校验 |
|---|---|---|
ParseIntPipe | 把字符串 "42" 转成数字 42 | 转换(转不成会顺带报错) |
ParseUUIDPipe | 校验是不是合法 UUID 格式 | 校验为主 |
ValidationPipe | 按 DTO 规则校验 + 可选转成实例 | 两者都做 |
为什么"转换"值得单列?因为 HTTP 传进来的一切都是字符串(URL 参数、query string 全是文本),而业务方法可能要 number/boolean/嵌套对象。典型用法(项目没用到,识认):
@Get("detail/:id")
detail(@Param("id", ParseIntPipe) id: number) { // URL 里的 "42" → number 42
// 这里 id 已经是 number,不是字符串
}
前端移植锚点:前端从 URL 拿参数也得自己
Number(id)。后端管道的 transform 就是把这个"字符串→类型"的脏活统一收口——方法签名说id: number,就真的给你 number,方法体里不用再转一遍。
四、为什么 DTO 必须是 class 而不是 interface
本篇最值得前端秒懂的一个机制点。校验规则要在运行时被读取,而:
- 装饰器只能挂在"运行时活着"的东西上——
interface是纯编译期类型,编译成 JS 后一行不剩;class编译后仍存在,@IsString()/@MaxLength()挂在它的属性元数据上,运行时能读到; QueryDto一个 class 同时扮演两个角色:运行时 schema(给管道读)+ 编译期类型(给 TS 检查方法签名)。
// ❌ interface:编译后消失,校验装饰器无处可挂
interface QueryDto { system: string; question: string }
// ✅ class:编译后仍在,装饰器元数据运行时可读
export class QueryDto {
@IsString()
@IsIn([...SYSTEMS])
system!: System;
...
}
前端移植锚点:DTO 类 ≈ 后端的"zod schema + TS 类型"合体。 前端校验常用 zod:
z.object({ system: z.enum([...]), question: z.string().min(1).max(1000) })——既是运行时校验器又是类型推断源。Nest 的 class-validator + class 是同一思想,只是语法从"函数链"换成"装饰器"。凡是要"运行时当规则用"的东西,都不能只用 interface。
顺带一句:字段后的 !(非空断言,system!: System)是 class-validator DTO 的固定写法——类字段不经过构造函数赋值,! 告诉 TS"运行时会被赋上",看到别疑惑。
五、TrimBodyPipe 逐行拆:堵住"纯空白串"的洞
先看这个洞有多真实。QueryDto.question 标了 @MinLength(1),但:
{ "system": "sap", "question": " " }
" " 的长度是 3,@MinLength(1) 完全拦不住——这串"什么都不是"的空白会一路流进向量检索,白跑一趟 embedding。前缀空白(" 你好 ")虽不致命,但检索质量不稳定。TrimBodyPipe(src/common/trim-body.pipe.ts,全文 41 行)就是冲这个来的:
@Injectable()
export class TrimBodyPipe implements PipeTransform {
transform(value: unknown, metadata: ArgumentMetadata): unknown {
if (metadata.type !== "body") { // ① 只碰 body,query/param 原样透传
return value;
}
return this.trimDeep(value); // ② 递归裁剪
}
private trimDeep(value: unknown): unknown {
if (typeof value === "string") {
return value.trim(); // 字符串:裁
}
if (Array.isArray(value)) {
return value.map((item) => this.trimDeep(item)); // 数组:逐项下钻
}
if (value && typeof value === "object") {
const out: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
out[key] = this.trimDeep(item); // 对象:逐字段下钻
}
return out;
}
return value; // 其它(number/boolean/null):原样
}
}
四个设计点:
transform(value, metadata)是所有管道的统一契约:第一参是"当前参数的原始值",第二参告诉它"这个参数在哪个位置"(metadata.type∈body/query/param/custom)。TrimBodyPipe 用metadata.type !== "body"做闸门——只归一化 body,query/param 一律透传,职责收得很窄;- 递归
trimDeep:body 是嵌套结构(数组套对象套字符串),必须钻到底。改造的是值,不动字段名; - 为什么做成全局管道而不是在 DTO 上写? 归一化是"全站规则",不该让每个 DTO 重复声明。一条全局管道收口,新增接口自动受益;
- 归一化 ≠ 校验:它只是"把数据收拾干净",判断合不合格仍留给后面的 ValidationPipe——两副面孔(第三节)在两条管道上的分工。
触发器:凡是"校验规则会被脏数据骗过"的地方,先想想能不能在它前面加一步归一化。
" "骗过@MinLength(1)不是 class-validator 的 bug,是"长度校验"这个规则对空白本来就无感——你要的语义其实是"trim 之后非空"。先归一化、再校验,就是把语义修到规则前面去。
六、顺序凭什么保证:APP_PIPE 与 useGlobalPipes 的时序链
现在到了本篇的机制核心。08 期说过:多个全局管道按 globalPipes 数组顺序串行执行,所以 TrimBodyPipe 先、ValidationPipe 后。但 08 期那张绑定表里,当时两个管道还都写在 main.ts 的同一个 useGlobalPipes 里,顺序 = 同一处调用的书写顺序。
现在它们分家了:TrimBodyPipe 搬进了 app.module.ts 的 APP_PIPE,ValidationPipe 留在 main.ts。分处两地,书写顺序就不再是保证——真正保证它的是两条注册路径的时序。扒 @nestjs/core 11.2.3 源码,链条是这样:
① NestFactory.create(AppModule) // main.ts 第 11 行
└─ 内部(nest-factory.js:112-114):
dependenciesScanner.scan(module) // 扫描 @Module 元数据
dependenciesScanner.applyApplicationProviders() // ★ APP_* 在这里生效
└─ APP_PIPE → applicationConfig.addGlobalPipe(pipe)
└─ globalPipes.push(TrimBodyPipe) // application-config.js:36-38
// globalPipes = [TrimBodyPipe]
② await create(...) 返回之后,main.ts:25 才执行
app.useGlobalPipes(new ValidationPipe({...}))
└─ globalPipes = globalPipes.concat(pipes) // application-config.js:39-41
// ★ concat 是"追加在尾部"
→ globalPipes = [TrimBodyPipe, ValidationPipe]
三个结论:
- 不变量"先 trim 后校验"仍然成立,但保证方式变了:不是"同一行代码里的书写顺序",而是——扫描期的
APP_PIPE必然先入数组(create内部就完成),useGlobalPipes只能concat追加在后面。只要你的useGlobalPipes写在await NestFactory.create()之后(也只能之后,得先拿到 app 实例),这个顺序就锁死了; concat而不是覆盖是关键细节:useGlobalPipes没有清空之前的数组,而是接在尾部。若它改成覆盖,APP_PIPE的管道就会被顶掉——Nest 用"追加"语义保证了两个入口共存;- 这也是个脆弱点:顺序如今靠的是"时序 + concat 语义"这种隐式机制,而不是一眼可见的书写顺序。哪天有人把 TrimBodyPipe 也挪去 main.ts 并顺手写在
useGlobalPipes(ValidationPipe)之后,洞就重新开了——而且没有任何报错,只是" "又能进来了。依赖隐式顺序的地方,值得一条注释钉死。(顺带如实说:trim-body.pipe.ts文件头那段"必须注册在 ValidationPipe 之前(main.ts 里 useGlobalPipes 的注册顺序即执行顺序)"的注释,写于两个管道还同在 main.ts 的时期——管道搬家了,注释还留在原地,这本身就是"改代码别忘改注释"的活例子。)
对照 10 期的守卫:守卫的顺序靠"providers 数组注册序",管道这里多了"两个注册入口的时序"——同构的问题是"全局组件的执行序由什么决定",守卫只有一条注册路径,管道有两条。
七、ValidationPipe 三个开关逐拆:收什么、翻不翻脸、整成什么样
whitelist: true —— 剥离未声明字段
// body 传了 DTO 没声明的 hack 字段
{ system: "sap", question: "…", hack: "注入" }
↓ whitelist: true
方法拿到:{ system: "sap", question: "…" } // hack 被剥掉
为什么要有它:安全。攻击者往请求体塞多余字段,若后端"按收到的对象整体存库/透传"(mass assignment / 批量赋值攻击),可能被塞进 role、isAdmin 这类不该客户端改的字段。whitelist 从根上保证"你没声明、我绝不收"。
forbidNonWhitelisted: true —— 带了多余字段直接翻脸
不是默默剥掉,而是直接 400 拒掉整个请求:
| 策略 | 行为 | 适合 |
|---|---|---|
只开 whitelist | 多余字段静默剥离 | 宽松,兼容旧客户端乱传字段 |
whitelist + forbidNonWhitelisted | 多余字段直接 400 | 严格,第一时间暴露"谁在乱传" |
项目两个都开 = 最严格档。代价是前端多传字段会直接吃 400——但这正是"尽早失败",比字段被默默吞掉好排查。
transform: true —— 转成 DTO 实例
把纯 JSON body 实例化成 DTO 类(内部走 class-transformer 的 plainToInstance):
dto instanceof QueryDto === true,不再只是"碰巧长一样的 plain object";- 嵌套类型、基础类型转换递归生效——query string 传的
"123"若 DTO 声明count: number,会转成123。
三开关合起来就是安检策略:收什么(whitelist)→ 不收就翻脸还是忍(forbidNonWhitelisted)→ 收进来整成什么样(transform)。 Controller 方法体里能放心地
dto.system,02 期那句"拿到的 dto 是已被校验、洗白、转换过的",靠的就是这一档。
八、装饰器写规则、管道读规则:又是元数据
把一条规则(@MaxLength(1000))的完整旅程摊开:
声明期:query.dto.ts
@MaxLength(1000) ← 装饰器把 { max: 1000 } 写进 question 属性的元数据
question!: string;
运行期:ValidationPipe
① 从参数元数据得知 body 的类型是 QueryDto(类)—— 靠 : QueryDto 标注
② 读 QueryDto 上所有属性装饰器收集的规则
③ 逐条执行:question 长度 > 1000?
④ 超了 → 收集错误 → throw BadRequestException(message 是错误数组)
class-validator 装饰器和 10 期拆过的 @Public()/@RequireAdmin() 是同一件事——SetMetadata 写元数据,区别只是消费者:
| 装饰器 | 写什么元数据 | 谁在运行时读 |
|---|---|---|
@Public() / @RequireAdmin() | { is_public: true } | AuthGuard(Reflector) |
@IsString() / @MaxLength(1000) | 校验规则 | ValidationPipe(class-validator) |
@ApiProperty() | Swagger 文档字段 | SwaggerModule |
Nest 里到处是"装饰器写元数据、外层组件读元数据"——守卫暗号、DTO 校验规则、Swagger 描述,本质同构。会读一个,就会读全部。
还有一个伏笔要埋:ValidationPipe 抛的 BadRequestException,其 message 常是数组(一条规则错一条,如 ["question must be longer than or equal to 1 characters", ...])。这个数组会流到异常过滤器,过滤器要把它翻译成给前端的错误契约——为什么过滤器里要写"message 可能是数组"的兜底,13 期在那里收口。
九、绑定层级与项目没用到的形态
管道可以绑在四个层级:
// ① 全局(APP_PIPE,项目用法之一)—— 组合根声明,DI 实例化,所有路由所有参数
{ provide: APP_PIPE, useClass: TrimBodyPipe }
// ①' 全局(useGlobalPipes,项目用法之二)—— bootstrap 处手动 new
app.useGlobalPipes(new ValidationPipe({...}));
// ② 控制器级:整个控制器的方法都套
@UsePipes(new ValidationPipe({ whitelist: true }))
@Controller("api")
export class RagController { ... }
// ③ 路由级:只这一个方法
@Post("query")
@UsePipes(new ValidationPipe({ whitelist: true }))
async query(...) { ... }
// ④ 参数级:只作用于某个参数(内建管道最常见的挂法)
async detail(@Param("id", ParseIntPipe) id: number) { ... }
执行顺序(08 期已证,这里收拢)
- 全局 → 控制器 → 路由 → 参数:越靠外越先执行,参数级离方法体最近、最后跑;
- 全局内部:
APP_PIPE按 providers 注册序 +useGlobalPipes追加在后(第六节的时序链); - 多参数倒序:一个方法多个参数、都过全局 ValidationPipe 时,按"最后声明的参数最先"处理——项目所有端点都是单个
@Body(),不触发。
APP_PIPE 与 useGlobalPipes,写代码选哪个?
APP_PIPE(app.module.ts) | useGlobalPipes(main.ts) | |
|---|---|---|
| 实例化 | DI 容器实例化,可构造注入、可被测试替身替换 | 手动 new,容器外 |
| 归口 | 组合根(07 期:AppModule 就是组合根) | bootstrap 容器外(和 main.ts 里那个 config 直连同类的债) |
| 适合 | 有依赖、要进 DI 体系的管道(如 TrimBodyPipe 将来要扩展) | 纯配置型、一次性的管道(ValidationPipe 带一堆选项) |
项目的分法恰好踩在这条线上:自定义的 TrimBodyPipe 走 DI(将来可注入、可测),标准件的 ValidationPipe 走 bootstrap 配置。但要记住第六节:两个入口共存时,顺序靠时序保证。
诚实说明:项目没用到参数级/控制器级
没路径参数、query 也不复杂(RAG 查询参数都走 body DTO),所以 ParseIntPipe、@UsePipes 一个都没用。一条全局管道 + 规则长在 DTO,覆盖了全部校验诉求——跟前面几篇一个调性:形态跟着复杂度走,别为用而用。
十、管道常见坑
- 校验规则写了但不生效 → 查三件事:DTO 是不是 interface(要 class,第四节);管道有没有真的挂上;方法参数有没有
@Body() dto: QueryDto类型标注(没有标注,管道不知道用哪套规则); - 接口是 PATCH/部分更新却被拒 → 可选字段没标
@IsOptional()(如reindex.dto.ts的system)。"可选"是声明出来的,不是 TS 的?说了算; - 前端收到的校验错误 message 是数组 → ValidationPipe 的 400 message 常为数组,契约统一要在异常过滤器里兜底(13 期);
- 手动
new QueryDto()以为也会校验 → 不会。管道只在 HTTP 边界生效,Service 里手动构造不触发任何校验。校验发生在"入口",不在"内部"——想内部也强制,自己调 class-validator 的validate(); - 把 transform 当万能 → 它只做"声明过的字段"的转换 + 实例化,不做"未声明字段的收纳"(那是 whitelist 反向的事);
- 归一化管道排在校验管道之后 → 白做。
" "会先过@MinLength(1)(长度 3,通过)再被 trim 成"",垃圾串已经进门了。顺序就是语义; - 在管道里做重业务逻辑 → 管道每个请求每个参数都跑,职责收窄在"转换 + 校验";查库、鉴权这类事各有各的层。
十一、前端心智一眼记 + 自测
| 前端概念 | 对应 Nest Pipes | 本质 |
|---|---|---|
| zod / yup 运行时 schema + 类型推断 | DTO class + class-validator | 运行时规则 + 编译期类型合体 |
z.object(...).parse() 在边界校验 | ValidationPipe 在 Controller 前校验 | 边界校验,内部默认可信 |
表单 schema 里的 .trim() / preprocess | TrimBodyPipe 先归一化 | 先洗数据,再谈规则 |
URL 参数要手动 Number(id) | ParseIntPipe | 字符串→类型统一收口 |
| 只收"声明过的字段"防注入 | whitelist / forbidNonWhitelisted | 防 mass assignment |
| zod schema 必须是运行时值、不能是 type | DTO 用 class 不用 interface | 运行时规则需要"活着"的类型 |
| 中间件 vs 守卫 vs 管道的分工 | 09/10/11 三篇连起来 | 预处理 / 裁决人 / 安检货 |
一条最重要的对应:前端的 zod 已经把"schema 即类型"的心智建立好了,Nest 的 DTO + class-validator 是同一个思想的装饰器写法——真正的增量只有两个:一是"规则长在类上所以编译后仍在"(interface 党会栽这里),二是"多条管道的顺序是语义的一部分"(zod 里你写 .trim().min(1) 链式顺序天然可见,Nest 里全局管道的顺序藏在注册时序里)。
4 个自测题(先自己答,再看答案)
-
为什么
QueryDto用 class + 装饰器,而不是 interface? → interface 是纯编译期类型,编译后消失,校验装饰器无处可挂;class 编译后仍在,@IsString()/@MaxLength()挂在属性元数据上,运行时 ValidationPipe 能读到。一个 class 同时当"运行时 schema + 编译期类型"。DTO ≈ 前端 zod schema + TS 类型的合体。 -
whitelist和forbidNonWhitelisted都开着,客户端多传了 DTO 没声明的字段,会发生什么? → 直接 400 拒掉整个请求,不是默默剥掉。whitelist 决定"未声明字段不收",forbidNonWhitelisted 决定"带了就翻脸"。叠加 = 最严格档,防 mass assignment(往 body 塞isAdmin这类字段篡改)。 -
TrimBodyPipe为什么注册在APP_PIPE、ValidationPipe留在useGlobalPipes,顺序却一定是 trim 在前?如果把顺序反过来会怎样? → 时序链:NestFactory.create内部扫描就把APP_PIPE的 TrimBodyPipepush进globalPipes;useGlobalPipes在 create 返回后才执行,且是concat追加在尾部——所以数组恒为[TrimBodyPipe, ValidationPipe]。反过来则" "先过@MinLength(1)(长度 3,通过)再被 trim 成"",垃圾串已经进向量检索了——没有任何报错,洞是静默打开的,这就是"顺序即语义"。 -
方法体里
dto.system的类型安全,靠的是 TS 的: QueryDto标注吗? → 不是,TS 标注只是编译期契约、运行时消失。真正生效的三步:ValidationPipe 从参数元数据找到 QueryDto 类 → 逐条执行字段上的 class-validator 规则 →transform: true把 body 实例化成 QueryDto。标注只是"管道找到该用哪套规则"的路标。
十二、本篇收束与下一篇
管道这一站拆完:它是 Controller 前的安检机,两副面孔(转换 + 校验)落在两条全局管道上——TrimBodyPipe 先把数据洗干净(递归 trim、只碰 body),ValidationPipe 再按"长在 DTO 上的规则"收口(剥未声明的、拒多余的、实例化成类)。而"先归一化后校验"这条顺序,在一个走 APP_PIPE、一个走 useGlobalPipes 的现状下,靠的是注册时序与 concat 语义——顺序即语义,静默且脆弱,值得注释钉死。
参数安检完,请求终于要进 Controller 了——但别忘了,进出还有一对"包裹式"的关卡。
下一篇(12 期《Interceptors 拦截器》)拆链路第四站:LoggingInterceptor 怎么用 next.handle() 把"管道 → 控制器 → Service"整个包进一个 RxJS 流、日志与耗时统计为什么天然落在它身上、以及"包裹式"和管道的"穿线式"差在哪——08 期那张时序图里 next.handle() 触发向下的箭头,在那里展开。