Nest 使用笔记
第二章:参数接收与校验——ValidationPipe、DTO 与自定义装饰器
打开 learnhub 的 main.ts 和真实 DTO,讲透全局 ValidationPipe 三个选项各自做什么、class-validator 怎么用、为什么 query 参数全是字符串、PageDto 计算属性为什么依赖 transform、自定义 @CurrentUser / @Public 装饰器的工作原理,以及为什么永远不该把 Entity 直接当 @Body 接收。每段代码都在 learnhub 里指得到文件。
- Nest
- 入门
- 校验
第一章里 /hello?name=小明 这种接口是玩具——name 拿到什么就用什么,前端传空字符串、传一万字、传个 <script> 都照单全收。真实项目里这一关过不去:进业务代码之前必须先校验「字段齐不齐、类型对不对、长度合不合规」。这一章把第一章 main.ts 里第 ④ 件全局配置——ValidationPipe——在 learnhub 的真实代码里彻底展开。
先搞懂:参数从哪来、为什么必须校验
HTTP 协议层面,所有从 URL、query string、header 来的参数本质都是字符串,body 如果是 JSON 也只是「字符串里碰巧长得像数字」。哪怕前端写 ?page=1,到 Nest 里拿到的也是 "1" 这个字符串,不是数字 1。这是后面整章的根因——不解决它,类型注解就是骗自己。
校验这件事有三种位置可以放,各有利弊:
- 写在 controller 里:
if (!dto.title) throw new BadRequestException()。直观,但每个接口都要重复写,业务代码一半在if-校验、一半在干活。 - 写在 service 里:和业务逻辑混在一起,且校验失败时已经进了业务层,错误返回不统一。
- 写在 DTO + 全局管道里(learnhub 的做法):规则用装饰器声明在 DTO 上,全局
ValidationPipe在进 controller 之前自动执行——非法请求根本到不了 handler,业务代码只看见干净合法的数据。
第三种是 Nest 的标准做法。它的代价是你要熟 class-validator 的装饰器和 class-transformer 的转换规则,但收益是校验和业务彻底解耦、规则和接口契约写在一起、新接口零成本继承。
这一章在 learnhub 里要讲透的东西:全局 ValidationPipe 三个选项各自为什么开、class-validator 装饰器怎么用、query 字符串陷阱怎么绕、分页 DTO 的计算属性为什么依赖 transform、嵌套对象怎么校验、以及 @CurrentUser / @Public 这种自定义装饰器背后到底干了什么。
这一章你会做出什么
- 看懂 learnhub
main.ts里ValidationPipe的三个选项,知道关掉任何一个会出什么事。 - 在真实
CreatePostDto/QueryPostDto/PageDto里吃透class-validator+class-transformer的常用装饰器。 - 掌握五种取参方式(
@Param/@Query/@Body/@Headers/@Ip)和它们的真实类型。 - 看懂自定义
@CurrentUser、@Public装饰器的实现,为第十一章 JWT 做铺垫。 - 明白「DTO 永远不和 Entity 混用」这条纪律背后的设计压力。
前置:第一章已经跑过 learnhub 的 main.ts,对全局管道的位置有印象。
第一步:全局 ValidationPipe——三个选项各做什么
learnhub 的启动里只挂了一个全局管道,但带了三个选项:
// learnhub/src/main.ts
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
}),
);
这一小段是整个校验体系的总开关。把三个选项一个个拆开看,才知道关掉哪一个会出什么事。
transform: true——把进来的普通对象「实例化」成 DTO 类的实例。HTTP body 经过 JSON 解析后是个普通对象(Object.create(null) 或 {} 的字面量),它没有原型上的 getter。这一点对 learnhub 的 PageDto 是命门:PageDto 上有 get skip() / get take() 两个计算属性,如果 body 没被转成 PageDto 实例,q.skip 就拿不到值(不是 undefined,是直接没这个属性)。transform 还顺手做了一件关键事——按 DTO 上的 @Type(() => Number) 把字符串转成数字(第四步细讲)。关掉 transform,DTO 里的计算属性全废、@Type 也全失效。
whitelist: true——请求体里多出来的字段(DTO 没声明的)会被自动剥掉。比如 CreatePostDto 只声明了 title / content / tagIds,前端却传了 { title, content, tagIds, role: "admin" },role 会被静默丢掉,handler 拿到的 dto 里没有它。这是反 mass-assignment 的第一道闸——防止前端通过「多塞字段」篡改不该改的列。
forbidNonWhitelisted: true——比 whitelist 更狠:多出来的字段不是静默丢,而是直接 400 拒绝,并把多余字段名塞进错误信息返回。learnhub 把它打开,等于在接口契约上画了条硬线:「DTO 里没声明的字段,一律视为非法请求」。
思考:forbidNonWhitelisted 对 learnhub 这种公开 API 是对的——前端写错了立刻暴露,不留下「我传了字段被静默吞掉、于是排查三天」的坑。但内部服务间调用(比如微服务、定时任务调接口)有时候反而要关掉它:内部调用方经常先于服务端升级、带了新字段,如果后端用 forbidNonWhitelisted,新字段一旦出现就直接 400,内部调用全部失败。规则是:对外用严格、对内看团队节奏。learnhub 是单体后端对外,所以全开。
还有一个选项 learnhub 没开但值得知道:disableErrorMessages: false(默认就是 false)。错误信息在开发期有用,生产期把每条校验错误都序列化返回会有性能和信息泄露的小代价——大流量服务会显式设成 true,只返回「校验失败」不返回细节。learnhub 没开是因为它在内部教学阶段,错误细节比性能更重要。
第二步:五种取参方式——@Param / @Query / @Body / @Headers / @Ip
把 learnhub 帖子控制器里所有取参装饰器集中看一遍:
// learnhub/src/modules/post/post.controller.ts
@Controller({ path: 'posts', version: '1' })
export class PostController {
constructor(private readonly postService: PostService) {}
@Post()
@RequirePermission('post:create')
create(@Body() dto: CreatePostDto, @CurrentUser() user: RequestUser): Promise<PostEntity> {
return this.postService.create(dto, user.userId);
}
@Public()
@Get()
list(@Query() q: QueryPostDto): Promise<PageResult<PostEntity>> {
return this.postService.findMany(q);
}
@Public()
@Get('search')
search(
@Query('q') q: string,
@Query('page') page = 1,
@Query('size') size = 10,
) {
return this.postService.search(q ?? '', Number(page), Number(size));
}
@Public()
@Get(':id')
detail(@Param('id') id: number): Promise<PostEntity> {
return this.postService.findOne(id);
}
@Patch(':id')
update(
@Param('id') id: number,
@Body() dto: UpdatePostDto,
@CurrentUser() user: RequestUser,
): Promise<PostEntity> {
return this.postService.update(id, dto, user.userId);
}
}
五种取参方式按出现频率排:
@Body():取请求体。create(@Body() dto: CreatePostDto)把整个 body 映射成 DTO 实例(第一步的transform干这件事)。也可以@Body('field')取单个字段,但那样就丢了 DTO 的整体校验,learnhub 里没有这种用法。- **
@Query()/@Query('field')**:取 URL query string。list(@Query() q: QueryPostDto)是把所有 query 参数整体映射成 DTO(?page=1&pageSize=10&keyword=...直接落到 DTO 字段上),这种写法配合 DTO 校验最干净。search里用@Query('q')是取单字段——因为 search 的参数太简单(就 q/page/size),单独写个 DTO 划不来。 @Param('id'):取路径参数(/posts/42里的42)。注意:路径参数也是字符串,detail(@Param('id') id: number)这个number类型注解能成立,是因为全局ValidationPipe的transform开着——它会在管道里把"42"转成42。关掉transform,id实际是字符串"42",类型注解骗过编译器、骗不过运行时,传给findOne(42)就在 TypeORM 那一层出问题。@Headers('accept-language'):取请求头。learnhub 的 i18n 演示接口用了它:
// learnhub/src/modules/i18n/i18n.controller.ts
@Public()
@Get('greet')
greet(@Headers('accept-language') lang?: string) {
const resolved = this.i18n.resolve(lang);
return { lang: resolved, msg: this.i18n.t(resolved, 'greet.hello') };
}
header 名大小写无关——Nest 用 Express/Fastify 的 header 取值,内部统一小写。不传参数 @Headers() 拿到整个 header 对象,但大多数场景你只需要一两个。
@Ip():取客户端 IP。learnhub 当前没有直接用,常见场景是限流、风控、日志埋点。Nest 内部读的是request.ip,在反向代理(Nginx)后面要配合app.set('trust proxy', ...)才能拿到真实 IP,否则拿到的是代理的 IP——这是部署期才会踩的坑,先记住。
@Session() 取 session 对象,learnhub 用 JWT 无状态认证(第十一章),所以整个项目里没有 session 用法。如果项目走 cookie-session 那套,才会在 session 里放登录态。
第三步:DTO 的写法——class-validator 装饰器巡览
打开 CreatePostDto,逐装饰器看真实项目里到底用了哪些:
// learnhub/src/modules/post/dto/create-post.dto.ts
export class CreatePostDto {
@ApiProperty({ example: 'TypeORM 三种关系怎么记' })
@IsString()
@Length(1, 100)
title: string;
@ApiProperty({ example: '一对多看 owning side...' })
@IsString()
@IsNotEmpty()
content: string;
@ApiPropertyOptional({ type: [Number], description: '标签 ID 列表(可选)' })
@IsOptional()
@IsArray()
@ArrayNotEmpty()
@IsInt({ each: true })
@Type(() => Number)
tagIds?: number[];
}
几个关键点,按装饰器顺序看:
@IsString():先确保是字符串。class-validator 的规则是「按声明顺序执行,一条不过就这个字段直接挂掉」。title上@IsString()在@Length()前面——如果传了123(数字),先挂在@IsString,错误信息是「必须是字符串」;写反了,会先挂在@Length,错误信息不知所云。@Length(1, 100):字符串长度上下界(含端点)。class-validator 还有@MinLength/@MaxLength各管一头,@Length是一次性两头都管。learnhub 给标题 1–100,是和post.entity.ts里@Column({ length: 100 })对齐的——DTO 的长度上限必须和数据库列长度一致,否则校验过了、写库时 MySQL 截断或报错,错误就会越过校验层泄到数据层。@IsNotEmpty():拒绝空字符串""。注意它和@IsString()的关系——单独@IsString()是允许""的,因为空字符串也是合法字符串。发帖内容不能是空字符串,所以加@IsNotEmpty()。@IsOptional():字段不存在时跳过后续所有校验。tagIds?是可选的——不传走@IsOptional放行,传了就必须满足@IsArray/@ArrayNotEmpty/@IsInt({ each: true })。注意:@IsOptional检查的是undefined(字段不存在),不是null。前端传tagIds: null不会被@IsOptional放行,会被@IsArray拒。如果想接受 null,要写@IsOptional()+@ValidateIf((o) => o.tagIds != null)。@IsArray()+@ArrayNotEmpty():先确认是数组、再确认非空。learnhub 不允许发帖时传一个空标签数组(要么不传、要么至少一个),所以@ArrayNotEmpty()。如果业务允许空数组,去掉它即可。@IsInt({ each: true }):数组里每个元素都得是整数。each: true是 class-validator 的「数组遍历开关」,对@IsString/@IsNumber等装饰器都生效。@Type(() => Number):这条是class-transformer的,不是 class-validator 的——但它和@IsInt({ each: true })必须配套出现,下一节展开。
@ApiProperty / @ApiPropertyOptional 是 Swagger 用的,给文档生成提供示例和描述,不参与运行时校验。它们和校验装饰器写在同一个字段上、看起来混在一起,但功能完全分离。Swagger 第十四章细讲。
第四步:query 参数的字符串陷阱——@Type(() => Number) 怎么救场
这是上一章里 transform: true 选项真正发挥作用的地方。看 QueryPostDto:
// learnhub/src/modules/post/dto/query-post.dto.ts
export class QueryPostDto extends PageDto {
@ApiPropertyOptional({ description: '按标签过滤' })
@IsOptional()
@Type(() => Number)
@IsInt()
tagId?: number;
@ApiPropertyOptional({ description: '按作者过滤' })
@IsOptional()
@Type(() => Number)
@IsInt()
authorId?: number;
}
请求 GET /api/v1/posts?page=2&pageSize=10&tagId=5,HTTP 层面 Nest 拿到的是:
{ page: "2", pageSize: "10", tagId: "5" } // 全是字符串
如果不做任何处理,@IsInt() 会全部失败——"5" 不是整数、是字符串。这时候有两种救法:
@Type(() => Number)(learnhub 选的):class-transformer在transform: true的管道里先把字段值按Number构造一次(Number("5")→5),然后才交给 class-validator 校验。写在@IsInt前面,所以校验时已经是数字 5 了。这是和「DTO 风格」一致的做法——规则都写在 DTO 上,controller 不用关心。ParseIntPipe:在 controller 参数上写@Query('tagId', new ParseIntPipe()) tagId: number。这是 Nest 自带的「参数级管道」,对单个字段做转换+校验。但 query DTO 是整体接收的,对每个字段都套一个 pipe 太啰嗦,learnhub 在 DTO 里走@Type。
为什么 @Type(() => Number) 写法要重复 () => Number 这个箭头函数、不能直接写 @Type(Number)?因为 class-transformer 需要的是一个「构造器」而不是「实例」,写 @Type(Number) 是把 Number 当成实例传进去(虽然巧合能跑,但语义不对)。() => Number 明确返回构造器,是官方推荐写法。
这个机制最漂亮的应用是 PageDto:
// learnhub/src/common/dto/page.dto.ts
export class PageDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page: number = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize: number = 10;
@IsOptional()
@IsString()
keyword?: string;
get skip(): number {
return (this.page - 1) * this.pageSize;
}
get take(): number {
return this.pageSize;
}
}
注意三件事:
page = 1和pageSize = 10是默认值。前端不传时,class-validator 跳过校验(因为@IsOptional),但字段还是有值——通过默认值赋的。这是 DTO 提供默认值的合法位置。pageSize上@Max(100)——防止前端传pageSize=10000一次性把数据库拖死。这种「上限」校验在分页 DTO 里必须有。get skip()/get take()是计算属性——它们不存在于请求里,是 DTO 派生出来的。
注意:get skip() 能用,前提有两条——第一,transform: true 已经把对象实例化成 PageDto(普通对象没有 getter);第二,page / pageSize 已经被 @Type(() => Number) 转成了真正的数字。少任何一条,(this.page - 1) * this.pageSize 都会算出 NaN(字符串相减得到 NaN),传给 TypeORM 的 skip(NaN).take(NaN) 就是灾难。
QueryPostDto extends PageDto——所有列表接口共享同一份分页契约。评论列表、用户列表都继承 PageDto,分页参数校验、计算属性、上限保护一次写好到处复用。这是 DTO 继承的真实价值,不是面向对象的炫技。
第五步:嵌套对象校验——@ValidateNested + @Type
learnhub 的评论 DTO 是个看起来「嵌套」但其实不是的例子:
// learnhub/src/modules/comment/dto/create-comment.dto.ts
export class CreateCommentDto {
@ApiProperty({ description: '帖子 ID' })
@Type(() => Number)
@IsInt()
postId: number;
@ApiProperty({ example: '好文!' })
@IsString()
@IsNotEmpty()
content: string;
@ApiPropertyOptional({ description: '父评论 ID(回复评论时传)' })
@IsOptional()
@Type(() => Number)
@IsInt()
parentId?: number;
}
它只有扁平的 number 字段(都用 @Type(() => Number) 解决字符串问题),没有真正的嵌套对象——postId / parentId 是数字,不是另一个对象。learnhub 目前没有需要嵌套对象校验的场景。
但如果你要在 learnhub 里加一个「带作者信息一起发帖」的接口(请求体里 author 是个对象),class-validator 默认不会递归校验嵌套对象——@IsDefined() 只检查字段存在,不进入嵌套对象的字段校验。要让它递归,必须两个装饰器配套:
// 假设的扩展:嵌套对象的写法(learnhub 当前没有,作为模式补充)
import { ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
class AuthorDto {
@IsString() @Length(2, 30)
name: string;
@IsEmail()
email: string;
}
class CreatePostWithAuthorDto {
@IsString() @Length(1, 100)
title: string;
@ValidateNested()
@Type(() => AuthorDto) // 必须配 @Type,否则 @ValidateNested 拿不到类元信息
author: AuthorDto;
}
注意:@ValidateNested 必须配 @Type(() => AuthorDto)——前者告诉 class-validator「这个字段是另一个 DTO,请递归校验」,后者告诉 class-transformer「把字段值实例化成 AuthorDto,否则它只是个普通对象、原型上没规则」。漏掉 @Type,@ValidateNested 就什么都校验不到——这是嵌套校验最常见的「我写了为什么不生效」坑。数组里嵌对象则写 @Type(() => AuthorDto) 配 @ValidateNested({ each: true })。
第六步:自定义参数装饰器——@CurrentUser 的原理
post.controller.ts 里反复出现的 @CurrentUser() 不是 Nest 内置的:
// learnhub/src/core/decorators/current-user.decorator.ts
export interface RequestUser {
userId: number;
username: string;
}
export const CurrentUser = createParamDecorator(
(_data: unknown, ctx: ExecutionContext): RequestUser => {
const req = ctx.switchToHttp().getRequest();
return req.user as RequestUser;
},
);
createParamDecorator 是 Nest 提供的「自定义参数装饰器工厂」。它接收一个回调,回调返回什么,handler 里这个参数就是什么。这里的逻辑简单到一句话——从 request 上把 user 字段拿出来。
req.user 是哪来的?现在还是空的。第十一章讲 JWT 时,JwtAuthGuard 会在 token 校验通过后把 payload({ userId, username })写到 req.user 上。所以 @CurrentUser() 现在用、第十一 章 才能拿到真实数据——但装饰器本身的实现先写好,因为它和接口签名绑在一起,先定形后填充是正常节奏。
ExecutionContext 是 Nest 对执行上下文的抽象——switchToHttp() 切到 HTTP 上下文(同一个 ExecutionContext 也覆盖 RPC、WebSocket,所以要先切)。getRequest() 拿到 Express 的 Request 对象。这种「在装饰器里直接碰 request」的写法很常见,但要克制——业务逻辑往 service 走,装饰器只负责取参数、不做业务判断。
第一个参数 _data 是「装饰器参数」:写成 @CurrentUser('field') 时 'field' 会传进来。learnhub 这里不需要(永远返回整个 req.user),所以命名成 _data 表示忽略。如果将来要支持 @CurrentUser('userId') 只取一个字段,回调里读 data 做分支即可。
为什么不用 @Req() 直接拿 request?因为 @Req() 把整个 Express Request 暴露到 handler 里,handler 就和 Express 耦合了(换 Fastify 就要改 handler)。@CurrentUser() 抽出来一个类型明确的最小契约 RequestUser,handler 只依赖这个接口,不依赖底层框架——这是「依赖倒置」在参数层的具体落地。
第七步:自定义元数据装饰器——@Public 与 Reflector
@Public() 是另一种装饰器,它不取参数、而是往 handler 上贴元数据:
// learnhub/src/core/decorators/public.decorator.ts
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
SetMetadata 是 Nest 提供的工具——把 { isPublic: true } 这条元数据贴到被装饰的 handler(或类)上。它本身不产生任何运行时行为,真正的消费方是守卫(Guard)。第十一章的全局 JwtAuthGuard 会做这样的事:
// 守卫里的伪代码(learnhub 第十一章会真正实现)
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private reflector: Reflector) {}
canActivate(ctx: ExecutionContext): boolean {
// 用 Reflector 把 @Public() 贴的元数据读出来
const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [
ctx.getHandler(),
ctx.getClass(),
]);
if (isPublic) return true; // 标了 @Public 就跳过认证
// 否则校验 JWT...
}
}
Reflector 是 Nest 专门用来「反向读元数据」的工具——@Public() 是写、Reflector 是读。这一写一读就是 Nest 里声明式权限/行为的标准模式:在 controller 上贴标签声明意图、在守卫里集中执行规则。learnhub 的 @RequirePermission('post:create') 走的是同一套机制(贴 permissions 元数据、PermissionGuard 读出来校验),只是数据结构换成数组。
注意:@Public() 在 learnhub 当前阶段(第二章)什么都不会做——还没有全局守卫消费它。但接口上先贴着,第十一章把守卫一加,立刻生效。这种「先标记、后实现」是真实项目常见的节奏,不要因为「现在不生效」就先不写。
第八步:DTO 与 Entity 的纪律——别把 Entity 当 @Body
这一节没有新代码,但它是这一章最容易被人跳过、又最容易在实战里出事的设计纪律。
learnhub 里 CreatePostDto 和 Post(Entity)字段很像,但它们是两个类,绝不混用。原因有三层:
- mass-assignment:Entity 上有
viewCount/likeCount/pinned/createTime这些不该由前端传的字段。如果@Body() dto: Post,前端传{ title, content, viewCount: 999999 },即便有whitelist,只要字段名对得上就会被赋值——前端就能直接给自己刷赞、刷浏览量、置顶自己的帖子。DTO 只暴露允许用户填的字段,是反 mass-assignment 的硬隔离。 - 类型契约:Entity 的字段反映库里的存储(
createTime: Date、viewCount: number),DTO 的字段反映前端的输入(title: string、tagIds: number[])。两者形状根本不一样——tagIds是 DTO 的输入、tags是 Entity 的关系字段;createTime是 Entity 的自动列、DTO 里根本没有。强行复用会让两边都变形。 - 校验规则归属:
@Length(1, 100)是「用户输入的约束」,应该贴在 DTO 上;@Column({ length: 100 })是「数据库列的约束」,贴在 Entity 上。两个约束目标不同、来源不同,混在一个类里就把两种关注点压在一起,改一处影响另一处。
// 反面教材:千万别这么干(别学这个)
@Post()
create(@Body() post: Post): Promise<Post> { // 直接收 Entity
return this.postRepo.save(post); // viewCount、pinned、author 全靠前端自觉
}
learnhub 的真实写法永远是 @Body() dto: CreatePostDto → service 把 DTO 字段挑出来、拼到 Entity 上 → save。第八章 PostService.create 里你能看到这一步——它显式地从 dto 拿 title / content / tagIds,绝不直接 Object.assign(post, dto)。
第九步:合法与非法请求,各会发生什么
把 learnhub 跑起来(第一章已讲),用 curl 触发校验:
# 登录拿 token(第十一章细讲,先用)
curl -X POST http://localhost:3000/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123456"}'
# {"accessToken":"eyJ...","refreshToken":"eyJ..."}
合法发帖(标题 1–100 字、内容非空、tagIds 全是整数):
curl -X POST http://localhost:3000/api/v1/posts \
-H "Content-Type: application/json" \
-H "Authorization: Bearer eyJ..." \
-d '{"title":"校验这章看完了","content":"@Type 那段是关键","tagIds":[1,2]}'
# 201,返回新帖子(DTO 校验通过、进入 PostService.create)
非法发帖(标题太长、内容空、tagIds 里有字符串):
curl -X POST http://localhost:3000/api/v1/posts \
-H "Content-Type: application/json" \
-H "Authorization: Bearer eyJ..." \
-d '{"title":"超长超长超长...(100 字以上)...","content":"","tagIds":[1,"abc"]}'
# 400,返回:
# {
# "message": ["title must be shorter than or equal to 100 characters",
# "content should not be empty",
# "each value in tagIds must be an integer number"],
# "error": "Bad Request",
# "statusCode": 400
# }
多传字段(whitelist + forbidNonWhitelisted 起作用):
curl -X POST http://localhost:3000/api/v1/posts \
-H "Content-Type: application/json" \
-H "Authorization: Bearer eyJ..." \
-d '{"title":"测试","content":"内容","pinned":true}'
# 400,返回:
# {
# "message": ["property pinned should not exist"],
# "error": "Bad Request",
# "statusCode": 400
# }
最后这条是 forbidNonWhitelisted 的功劳——前端想偷偷把帖子置顶,被直接拒掉。如果只开 whitelist 不开 forbidNonWhitelisted,pinned 会被静默丢掉、请求 201 成功——前端以为置顶了、其实没置顶,调试起来更痛苦。这就是第一步说「公开 API 一定要开 forbidNonWhitelisted」的实证。
这一章的成果
- 看懂 learnhub
main.ts里ValidationPipe三个选项各自做什么、关掉会出什么事:transform把对象实例化(保住PageDto计算属性和@Type转换)、whitelist静默剥多余字段、forbidNonWhitelisted直接拒绝多余字段。 - 在
CreatePostDto/QueryPostDto里吃透class-validator装饰器(@IsString/@Length/@IsNotEmpty/@IsArray/@ArrayNotEmpty/@IsInt({ each: true })/@IsOptional)和class-transformer的@Type(() => Number)。 - 掌握五种取参(
@Param/@Query/@Body/@Headers/@Ip),并知道 query 和 path 参数都是字符串、要靠@Type或ParseIntPipe转换。 - 理解
PageDto这个可复用模式:默认值 + 计算属性 +@Max(100)上限保护,所有列表 DTO 继承它。 - 看懂
@CurrentUser(createParamDecorator,从req.user取)和@Public(SetMetadata,等守卫消费)背后的机制,为第十一章 JWT 做铺垫。 - 明白 DTO 和 Entity 不能混用的三层原因(mass-assignment、类型契约、校验归属)。
常见问题
- 校验不生效:先确认
main.ts里app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })),再确认装了class-validator+class-transformer。learnhub 的package.json里都有。 @IsInt总是失败、即便传的是数字:大概率是 query/path 参数没被转换。检查 DTO 上有没有@Type(() => Number),或全局ValidationPipe的transform是否打开。body 里的 JSON 数字不需要@Type(JSON 解析时已经是数字),query 才需要。PageDto的get skip()返回 NaN:几乎一定是transform: false或@Type(() => Number)缺了。(this.page - 1) * this.pageSize里只要page/pageSize有一个是字符串,减法就出 NaN。- DTO 上写了规则但请求带的多余字段还是被接收了:检查
whitelist是不是开着。如果开了forbidNonWhitelisted还能进,说明字段名恰好和 DTO 上的对上了——不是多余字段。 - 嵌套对象校验不生效:
@ValidateNested必须配@Type(() => NestedDto),缺一个就废。数组嵌套要加{ each: true }。 - 想统一错误格式(都包成
{code, message, data}):用全局ExceptionFilter,第五章 AOP 会讲。 @CurrentUser()拿到 undefined:现在(第二章)是预期的——第十一章的JwtAuthGuard还没接上,req.user还没人写。装饰器本身没错。
下一章讲 IoC 与依赖注入——为什么 controller 里写一句 private readonly postService: PostService 就能用、Nest 是怎么自动 new 和组装这些对象、provider 的几种注册方式(useClass / useValue / useFactory)各适合什么场景。这是理解 Nest 剩下所有特性的核心一课。