大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于
Tiptap的富文本编辑、NestJS后端服务、实时协作与智能化工作流等核心模块。在这个项目的持续打磨过程中,我积累了不少实战经验,不只是
Tiptap的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。如果你对 AI 全栈开发、Agent、长期记忆、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信
yunmz777一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐
前面几篇里,控制器、服务、模块的关系已经铺开了。接下来是一个很现实的问题:参数一进控制器,能不能直接往服务层传。
技术上可以。@Body()、@Query()、@Param() 拿到的都是未经你类声明约束的原始形态,类型上也往往是宽松的。
真做项目时,这种写法会很快变成隐患。请求来自外部,外部输入不能默认可信:字段可能缺失、类型可能串了、字符串里可能塞了根本转不成数字的内容,甚至还可能多带几个你从未在文档里写过的键。
这就是 DTO 要解决的问题。
DTO 是 Data Transfer Object 的缩写。先把它想成"接口层的数据契约"。它不承载业务过程,只回答这几件事:
- 这次请求允许出现哪些字段
- 每个字段期望的类型是什么
- 哪些是必填
- 除类型以外还要满足哪些约束
拿"创建用户"来说,若没有契约,你很容易遇到:
name是空字符串email根本不像邮箱age传成了"abc"- 客户端悄悄带上
role: "admin"
脏数据一旦进了服务层或持久层,再排查就要沿着整条调用链往回找,成本很高。
所以 DTO 的价值不只是给参数"加个类型标注",而是把接口边界写死,让不合法的东西尽量在进门时被拦下。
下面是一个最基础的入参契约,字段上的装饰器来自 class-validator,后面接上 ValidationPipe 后才会真正生效:
import { IsEmail, IsInt, IsString, Min, MinLength } from "class-validator";
/** 创建用户接口允许的请求体形状 */
export class CreateUserDto {
@IsString()
@MinLength(2)
name: string;
@IsEmail()
email: string;
@IsInt()
@Min(0)
age: number;
}
这个类既不是表结构,也不是领域实体,它只是说:创建用户这条接口,合法请求体至少长这样。
class-validator 与 class-transformer
在 NestJS 里,DTO 通常和两个库成对出现:
class-validator管规则,字段对不对、满不满足约束class-transformer管形态,把普通对象转成类实例,并在需要时做类型转换
一句话分工:class-validator 问"对不对",class-transformer 问"怎么变成声明里的那种形状"。
查询字符串里的数字、嵌套对象里的子对象,往往都要靠转换配合校验,否则你会一直在和业务代码里多余的 Number()、parseInt 打交道。
下面这个查询 DTO 同时用到了两边:@Type(() => Number) 先把 page 尽量变成数字,再用 @IsInt()、@Min(1) 收紧范围。
import { Type } from "class-transformer";
import { IsEmail, IsInt, IsOptional, IsString, Min } from "class-validator";
/** 用户列表查询:关键词可选,页码可选且至少为 1 */
export class QueryUsersDto {
@IsOptional()
@IsString()
keyword?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
}
为什么查询参数特别需要 @Type。因为从 HTTP 进应用时,查询串几乎都是字符串。?page=2 在多数时候先是 "2",不转一把,@IsInt() 很容易和你的直觉拧着。
全局开启 ValidationPipe 且设置 transform: true 时,还可以再配合 transformOptions.enableImplicitConversion,对部分简单类型做隐式转换。嵌套结构、联合形态仍然更推荐显式写 @Type,可读性更好,也少踩坑。
依赖若尚未安装,在项目根目录执行:
pnpm add class-validator class-transformer
装好后,DTO 上的装饰器才有运行时意义。
ValidationPipe 的用法
光定义 DTO 类,请求进来并不会自动校验。真正把契约接进管道的是 ValidationPipe。
把它想成控制器前的一道闸:参数先按 DTO 规则过一遍,过了才进方法体,不过则直接短路成错误响应。
默认情况下,校验失败会抛出 BadRequestException,HTTP 状态码一般是 400。响应体里常见 message 字段,内容多为字符串数组,逐项列出哪条规则没通过,便于联调。
最常见的做法是在 main.ts 里全局挂上管道:
import { ValidationPipe } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
}),
);
await app.listen(3000);
}
void bootstrap();
全局启用之后,只要在参数位置写了具体的 DTO 类型(而不是泛泛的 object),Nest 就会尝试按类做转换和校验。
import { Body, Controller, Post } from "@nestjs/common";
import { CreateUserDto } from "./dto/create-user.dto";
@Controller("users")
export class UsersController {
@Post()
create(@Body() body: CreateUserDto): CreateUserDto {
// 能执行到这里时,body 已通过校验并按 DTO 做过转换
return body;
}
}
不满足 CreateUserDto 时,create() 不会执行,客户端会先收到校验错误。服务层就可以少写一层重复的"字段是不是 string"式的防御代码。
如果某个路由要临时关掉转换或换一套规则,可以用控制器级或方法级管道覆盖默认行为,不必动全局配置:
import { Body, Controller, Post, UsePipes, ValidationPipe } from "@nestjs/common";
import { CreateUserDto } from "./dto/create-user.dto";
@Controller("users")
export class UsersController {
@Post("draft")
@UsePipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: false,
}),
)
saveDraft(@Body() body: CreateUserDto): CreateUserDto {
return body;
}
}
对多数业务项目,全局一套偏严格的默认值,再在少数路径上放宽,往往比完全不用全局管道省心。
白名单、转换与多余字段
ValidationPipe 的价值不止于报错。whitelist、forbidNonWhitelisted、transform 三个开关配合起来,可以把入口擦得很干净。
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
whitelist
whitelist: true 时,只有 DTO 上声明过的属性会留在对象上。多出来的键会被剥掉。
若 DTO 只有 name 和 email,客户端却带了 role、isAdmin,这些多余字段不会跟着进控制器方法。很多风险来自"多传了不该收的字段",而不只是字段值写错。
forbidNonWhitelisted
forbidNonWhitelisted: true 再收紧一档:只要出现未声明字段,直接判失败,而不是悄悄删掉。
公开 API、对接第三方、强契约场景更适合打开它。
transform
transform: true 会启用 class-transformer,把原始负载转成类实例,并按装饰器做类型转换。
例如查询串里的 page=2 可以变成数字 2,避免整份业务代码里到处是手动的 Number()。
实际顺序可以粗略理解成:先尽量转成 DTO 实例并做类型转换,再跑 class-validator,最后按白名单剥掉多余属性。校验失败会在进入控制器之前返回,不会混进半合法对象。
参数并不是原样流进控制器,而是先被整理成契约允许的形状。收益不只是少报错,而是入口这一圈边界可控、可测、可讲清楚。
嵌套对象与数组
请求体里常有嵌套结构,例如地址、标签列表。外层 DTO 校验到了,内层仍是普通对象,规则不会自动往下传。
常见写法是对嵌套属性再声明一个 DTO 类,在外层加上 @ValidateNested(),并用 @Type(() => InnerDto) 指明怎么实例化内层。数组则配合 @IsArray()、@ArrayMinSize() 等与集合相关的装饰器。
import { Type } from "class-transformer";
import {
IsArray,
IsString,
MinLength,
ValidateNested,
} from "class-validator";
export class AddressDto {
@IsString()
@MinLength(1)
city: string;
}
export class CreateOrderDto {
@ValidateNested()
@Type(() => AddressDto)
address: AddressDto;
@IsArray()
@IsString({ each: true })
tags: string[];
}
嵌套越深,越要在类型和装饰器上写清楚,否则很容易出现"外层过了、内层仍是任意 JSON"的假象。
从已有 DTO 派生
更新接口常常和创建接口只差"全部可选"。手写两份几乎相同的类容易漂移,可以用 @nestjs/mapped-types 里的 PartialType 从创建 DTO 派生更新 DTO,装饰器会一并变成可选校验。
import { PartialType } from "@nestjs/mapped-types";
import { CreateUserDto } from "./create-user.dto";
/** 更新用户:字段与创建一致,但均可选 */
export class UpdateUserDto extends PartialType(CreateUserDto) {}
安装依赖:
pnpm add @nestjs/mapped-types
还有 PickType、OmitType 等,用在"只要子集字段"的场景,思路相同:一份源契约,多份视图,而不是复制粘贴改几个字母。
DTO、Entity、VO 不要混用
后期常见的大坑,是把长得差不多的类来回复用。数据库实体直接当入参 DTO 用,或把带密码哈希的实体原样返回给前端,短期省事,长期边界全糊。
DTO、Entity、VO 都可以是一组字段,但站位不同:
DTO对准接口进出的契约Entity对准持久化与领域状态VO对准对外展示或某次响应的裁剪结果
同一张用户表在三层里的切片往往不一样。
UserEntity 里可能有 id、name、email、passwordHash、createdAt、updatedAt。创建用户的 CreateUserDto 只要 name、email、password。返回前端的 UserProfileVo 可能只给 id、name、email。看起来都在描述用户,语义并不相同。
混用会带来:入参与存储绑死、内部字段意外暴露、一个类为了兼容多种场景不断长歪、改一处字段牵动所有层。
/** 创建接口入参 */
export class CreateUserDto {
name: string;
email: string;
password: string;
}
/** 与数据库表或 ORM 实体对齐 */
export class UserEntity {
id: string;
name: string;
email: string;
passwordHash: string;
createdAt: Date;
}
/** 返回给前端的公开资料,不含密码类敏感字段 */
export class UserProfileVo {
id: string;
name: string;
email: string;
}
即便字段重叠,也不要因为"看着像"就合成一个类。习惯上可以记:DTO 站在门口,Entity 站在存储与领域内部,VO 站在对外可见的应答形状。
小结
这一篇想建立的,不局限于"会贴几个校验装饰器",而是这条判断:
接口参数不能默认可信。
DTO 把边界写清楚,class-validator 写规则,class-transformer 做实例化与转换,ValidationPipe 把它们嵌进请求生命周期。白名单和严格拒绝多余字段,则是在契约之上再加一层安全习惯。
若下面这些已经变成你的默认思路,这一章就到位了:
- 控制器拿到的外部数据不要裸用
- 入参用
DTO声明,并配合管道校验与转换 - 嵌套与数组要有对应的嵌套
DTO与集合装饰器 - 需要时用
PartialType等工具派生,避免复制粘贴 DTO、Entity、VO各司其职,不因字段相似就混成一类
下一节会看配置与环境变量。除了 HTTP 负载,运行时的开关和密钥同样需要被约束和管理。