J2 · 控制器、路由、DTO 与校验
Nest 的控制器和 @RestController 很像:带装饰器的方法接收请求,返回的对象被序列化成 JSON。真正的区别在别处: TypeScript 的类型在运行时会消失。所以输入校验完全依赖带装饰器的 DTO 类和一个已启用的 ValidationPipe。 没有它,什么都不会被检查。
目标
- 声明路由、选择状态码、给 API 加版本号。
- 用
class-validator写带校验的 DTO,用class-transformer做类型转换。 - 配置一个严格的全局
ValidationPipe(whitelist、forbidNonWhitelisted、transform)。 - 用
PartialType、PickType、OmitType派生更新用的 DTO。 - 交付一个存在内存里的
projects增删改查,支持查询参数分页和过滤。
Spring → NestJS
| Spring | NestJS | 要点 |
|---|---|---|
@RestController + @RequestMapping("/projects") | @Controller('projects') | 路径前缀写在类上 |
@GetMapping("/{id}")、@PostMapping | @Get(':id')、@Post() | 路径参数写成 :id |
@PathVariable、@RequestParam、@RequestBody、@RequestHeader | @Param('id')、@Query()、@Body()、@Headers() | 不带名字的 @Query() 拿到整个查询对象 |
@ResponseStatus(NO_CONTENT) | @HttpCode(HttpStatus.NO_CONTENT) | 默认 200,POST 默认 201 |
ResponseEntity<T> | 直接返回对象(或 Promise) | 有 @Res(),但它会关掉 Nest 的统一处理 |
@Valid + Bean Validation(@NotNull、@Size) | ValidationPipe + class-validator(@IsNotEmpty()、@MaxLength()) | 必须启用这个管道,否则不做任何校验 |
| Jackson(JSON ↔ 对象) | class-transformer | 请求体到达时只是普通对象,transform: true 才会把它变成 DTO 实例 |
原理
路由与装饰器
@Controller('projects') 定前缀,每个方法再加上 HTTP 方法和路径。Nest 启动时读取这些装饰器,把路由注册到 HTTP 适配器里(默认是 Express 5)。启动日志会列出每条注册的路由(上下文是 RouterExplorer),某个接口返回 404 时, 先看这里。
处理函数可以返回普通值、Promise 或 Observable:Nest 等结果出来,序列化成 JSON,再设置状态码。你不用自己操作 响应对象,正因为如此,拦截器和全局序列化才能统一生效。
状态码
GET、PATCH、DELETE 返回 200,POST 返回 201。要改状态码用 @HttpCode():不带响应体的 DELETE 返回 204, 不创建资源的 POST /projects/:id/archive 返回 200。要返回错误,就抛异常(NotFoundException、 ConflictException 等),Nest 会把它转成响应,相当于 Spring 的 ResponseStatusException。
API 版本
TaskFlow 在 setupApp() 里启用了 URI 版本: app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' })。所有路由都变成 /v1/..., 控制器不用改。某条路由可以用 version: VERSION_NEUTRAL 不参与版本(比如 /health,见 J11)。也可以按请求头或 media type 区分版本,但 URI 方式最容易调试。
DTO、class-validator 与 class-transformer
DTO 是类,不是接口:只有类在运行时存在,也只有类能挂装饰器。class-validator 提供约束(@IsString()、 @IsNotEmpty()、@MaxLength(100)、@IsUUID()、@IsEnum() 等),相当于 Bean Validation。 class-transformer 把收到的 JSON 对象转换成类的实例,并执行 @Type() / @Transform()。
TypeScript 类型不做任何校验
create(@Body() dto: CreateProjectDto) 挡不住 { "name": 42, "admin": true } 这样的请求。类型在编译时就被擦除了。 只有 ValidationPipe 读取 DTO 上的装饰器后,才会拒绝这个请求。
全局 ValidationPipe
TaskFlow 只在整个应用里注册一次,用的是 APP_PIPE provider(这样从 AppModule 启动的端到端测试里也会生效):
// Global pipes, guards, filters and interceptors declared as providers, so
// they can inject dependencies and also apply in tests built from AppModule.
providers: [
{
provide: APP_PIPE,
useValue: new ValidationPipe({
whitelist: true, // strip properties without a decorator
forbidNonWhitelisted: true, // ...or reject the request: 400
transform: true, // plain JSON -> DTO instances, "2" -> 2
}),
},
{ provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_GUARD, useClass: JwtAuthGuard }, // guards run in this order
{ provide: APP_GUARD, useClass: RolesGuard },
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
{ provide: APP_INTERCEPTOR, useClass: ClassSerializerInterceptor }, // applies @Exclude
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
],| 选项 | 作用 | 不开会怎样 |
|---|---|---|
whitelist: true | 去掉没有装饰器的属性 | 客户端能塞进意料之外的字段 |
forbidNonWhitelisted: true | 直接拒绝请求(400),而不是悄悄去掉 | 未知字段被悄悄丢掉 |
transform: true | 生成 DTO 实例,需要时把 "2" 转成 2 | dto 还是普通对象,query.page 还是字符串 |
开了 forbidNonWhitelisted 后,字段名拼错会得到一个明确的 400,而不是被静默忽略,客户端更容易排查。
PartialType、PickType、OmitType
不要复制粘贴字段,用派生的方式生成 DTO:
PartialType(CreateProjectDto):字段相同,全部变成可选,校验规则保留(用于PATCH)。PickType(CreateProjectDto, ['name']):只要部分字段。OmitType(CreateProjectDto, ['description']):去掉部分字段。- 可以组合:
PartialType(OmitType(CreateUserDto, ['password']))。
这些函数来自 @nestjs/mapped-types。装了 Swagger 之后(J11),改从 @nestjs/swagger 导入,这样还能保留接口文档的元数据。
TaskFlow 案例
生成资源
nest g resource projects # 选择 "REST API",再对 CRUD 入口选 "Yes"
nest g resource projects --type rest --crud # 结果相同,不提问这条命令会生成 projects.module.ts、projects.controller.ts、projects.service.ts、DTO 和一个空实体,并把模块加进 AppModule 的 imports。随后用下面的代码替换掉这个骨架。生成文件的完整列表见 Nest CLI 速查。
创建和更新用的 DTO
// Bean Validation on a class, checked at runtime by the global ValidationPipe.
// The TypeScript types alone validate nothing: they are erased at runtime.
export class CreateProjectDto {
@IsString()
@IsNotEmpty()
@MaxLength(100)
name: string;
@IsOptional()
@IsString()
@MaxLength(2000)
description?: string;
}// Same fields, all optional. Import PartialType from @nestjs/swagger once
// Swagger is set up (from @nestjs/mapped-types before that).
export class UpdateProjectDto extends PartialType(CreateProjectDto) {}不合法的请求在到达控制器之前就被拒绝:
POST /v1/projects
Content-Type: application/json
{ "name": "" }{
"status": 400,
"code": "BAD_REQUEST",
"message": ["name should not be empty"],
"path": "/v1/projects",
"timestamp": "2026-11-10T09:12:44.120Z",
"correlationId": "9f1c0c6e-3a51-4d6b-9f0e-2a7c51d8e0b4"
}problem+json 格式来自 J6 的全局过滤器。端到端测试还验证了:未知字段 ("owner": "me")会返回 400。
增删改查控制器
// @RestController + @RequestMapping("/projects"). With URI versioning the
// full path is /v1/projects.
@ApiTags('projects')
@ApiBearerAuth()
@Controller('projects')
export class ProjectsController {
constructor(private readonly projects: ProjectsService) {}
@Post() // 201 by default for POST
create(@Body() dto: CreateProjectDto, @CurrentUser() user: AuthUser) {
return this.projects.create(dto, user);
}
@Get()
findAll(@Query() query: ProjectQueryDto, @CurrentUser() user: AuthUser) {
return this.projects.findAll(query, user);
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthUser) {
return this.projects.findOne(id, user);
}
@Patch(':id')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateProjectDto,
@CurrentUser() user: AuthUser,
) {
return this.projects.update(id, dto, user);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
remove(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthUser) {
return this.projects.remove(id, user);
}
@Post(':id/archive')
@HttpCode(HttpStatus.OK)
archive(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: AuthUser) {
return this.projects.archive(id, user);
}
@Post(':id/members')
@HttpCode(HttpStatus.NO_CONTENT)
addMember(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AddMemberDto,
@CurrentUser() user: AuthUser,
) {
return this.projects.addMember(id, dto.userId, user);
}
}@Param('id', ParseUUIDPipe)会转换并校验参数:/v1/projects/not-a-uuid在进入处理函数前就返回 400(管道详见 J6)。@CurrentUser()是 J8 加的自定义参数装饰器。J2 还没有认证,方法只接收 DTO 和 id。- 控制器里没有任何业务规则:它只负责把 HTTP 请求转成对 service 的调用。
内存版增删改查
J2 里项目存在一个 Map 中。TaskFlow 保留了这个实现:单元测试用它,它也是 J3 抽象出来的接口的一个实现。
// The J2 version: an array in memory. Still useful in unit tests, and the
// service and controller do not know which store they get.
@Injectable()
export class InMemoryProjectsStore implements ProjectsStore {
private readonly projects = new Map<string, Project>();
private readonly members = new Set<string>();
async create(data: CreateProjectDto, ownerId: string): Promise<Project> {
const now = new Date();
const project = Object.assign(new Project(), {
id: randomUUID(),
name: data.name,
description: data.description ?? null,
archived: false,
createdAt: now,
updatedAt: now,
deletedAt: null,
});
this.projects.set(project.id, project);
this.members.add(`${project.id}:${ownerId}`);
return project;
}
async findPage(query: ProjectQueryDto, memberId?: string): Promise<Page<Project>> {
const search = query.search?.toLowerCase();
const matching = [...this.projects.values()]
.filter((p) => !p.deletedAt)
.filter((p) => !memberId || this.members.has(`${p.id}:${memberId}`))
.filter((p) => !search || p.name.toLowerCase().includes(search))
.filter((p) => query.archived === undefined || p.archived === query.archived)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
return {
items: matching.slice(query.skip, query.skip + query.limit),
total: matching.length,
page: query.page,
limit: query.limit,
};
}
async findById(id: string): Promise<Project | null> {
const project = this.projects.get(id);
return project && !project.deletedAt ? project : null;
}
async update(id: string, changes: UpdateProjectDto): Promise<Project | null> {
const project = await this.findById(id);
if (!project) return null;
Object.assign(project, changes, { updatedAt: new Date() });
return project;
}
async softDelete(id: string): Promise<boolean> {
const project = await this.findById(id);
if (!project) return false;
project.deletedAt = new Date();
return true;
}
async archive(id: string): Promise<Project | null> {
const project = await this.findById(id);
if (project) project.archived = true;
return project;
}
async isMember(projectId: string, userId: string): Promise<boolean> {
return this.members.has(`${projectId}:${userId}`);
}
async addMember(projectId: string, userId: string): Promise<void> {
this.members.add(`${projectId}:${userId}`);
}
}用 QueryDto 做分页和过滤
查询参数到达时永远是字符串。用一个查询 DTO 来校验和转换:
// Query strings are always strings: @Type(() => Number) plus the global
// ValidationPipe({ transform: true }) turn "?page=2" into the number 2.
export class PaginationQueryDto {
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit = 20;
get skip(): number {
return (this.page - 1) * this.limit;
}
}
/** What Spring Data calls Page<T>; TypeORM has no built-in equivalent. */
export interface Page<T> {
items: T[];
total: number;
page: number;
limit: number;
}export class ProjectQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
@MaxLength(100)
search?: string;
// "?archived=false" arrives as the string "false", which is truthy.
@IsOptional()
@Transform(({ value }) => (value === 'true' ? true : value === 'false' ? false : value))
@IsBoolean()
archived?: boolean;
}pagination.dto.ts 的 .dto.ts 后缀不能省:J11 的 Swagger 插件只读取 *.dto.ts 和 *.entity.ts 文件,正因为这个后缀, page 和 limit 才会出现在 OpenAPI 文档里(J11)。
GET /v1/projects?search=app&page=1&limit=1{
"items": [
{
"id": "5b0c…",
"name": "Web app",
"description": null,
"archived": false,
"createdAt": "2026-11-10T09:15:02.481Z",
"updatedAt": "2026-11-10T09:15:02.481Z",
"deletedAt": null
}
],
"total": 2,
"page": 1,
"limit": 1
}?page=0 会返回 400(@Min(1))。注意 ?archived=false:字符串 "false" 在 JavaScript 里是真值,所以要显式写 @Transform,这里光靠 @Type(() => Number) 那种写法不够。
场景速查
DELETE要返回 204 →@HttpCode(HttpStatus.NO_CONTENT)(状态码)。- 未知字段要被拒绝 →
ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })(全局管道)。 - 更新 DTO 重复了创建 DTO 的字段 →
PartialType(CreateXDto)(mapped types)。 ?page=2收到的是字符串 →@Type(() => Number)加transform: true(分页)。- 查询参数里的布尔值(
?archived=false)总是真 → 显式@Transform(分页)。 :id参数必须是 UUID →@Param('id', ParseUUIDPipe)(控制器)。- 想让所有路由都在
/v1下 →enableVersioning(API 版本)。 - 想用
@Res()设置一个响应头 → 改用@Header()或拦截器(陷阱)。
陷阱
把 DTO 写成 interface
interface 在运行时不留任何痕迹,没法校验。DTO 必须是类。
没有全局 ValidationPipe,或只在部分地方生效
没有全局管道时,每个控制器都得自己加 @UsePipes(),漏一个就什么都能进来。用 APP_PIPE 声明一次(或者在 main.ts 里 app.useGlobalPipes(),但端到端测试不会执行 main.ts,它就不生效了)。
在控制器里用 @Res()
用 @Res() 注入 Express 的响应对象后,处理函数就进入“手动”模式:Nest 不再发送返回值,响应拦截器和序列化 (ClassSerializerInterceptor)都不再生效。真的需要时(比如流式输出),用 @Res({ passthrough: true })。
- 没开
transform: true时,@Query()里的数字全是字符串:page > 1这类比较会悄悄算错,不报任何错。 @IsOptional()会放行null和undefined:要和类型约束(比如@IsString())一起用,后者只在值存在时生效。
练习
- [ ] 运行
nest g resource projects,再把骨架换成CreateProjectDto/UpdateProjectDto。 - [ ] 完成存在内存数组或
Map里的完整增删改查。 - [ ] 用
@Query()和一个QueryDto实现分页(page、limit)和过滤(search)。 - [ ] 用
curl验证:未知字段 → 400,name为空 → 400,?page=0→ 400,DELETE→ 204。 - [ ] 分别用
PickType和PartialType写UpdateProjectDto,比较两者的区别。 - [ ] 在
LEARNINGS.md里记下和@Valid+ Bean Validation 不同的地方。