2.2 Controller:接单窗口 模块把工位画好后,第一个要装的零件是控制器。它处在框架的最外层:解析 HTTP 语义、收集请求参数、把活转给服务层、把结果翻回响应。上一节的例子已经见过它的雏形,本节把路由与参数装饰器的完整用法摆开,并论证那条容易被忽视的纪律——窗口只接单,不加工。 路由与参数装饰器 控制器用 @Controller 声明路由前缀,方法用 HTTP 动词装饰器挂载端点,参数装饰器从请求里取料。三者组合起来覆盖 REST 的全部形态: 参数装饰器各有明确岗位:@Param 取路径参数、@Query 取查询串、@Body 取请求体、@Headers 取请求头、@Req 与 @Res 取原生对象(慎用,理由见下)。
模块把工位画好后,第一个要装的零件是控制器。它处在框架的最外层:解析 HTTP 语义、收集请求参数、把活转给服务层、把结果翻回响应。上一节的例子已经见过它的雏形,本节把路由与参数装饰器的完整用法摆开,并论证那条容易被忽视的纪律——窗口只接单,不加工。
控制器用 @Controller 声明路由前缀,方法用 HTTP 动词装饰器挂载端点,参数装饰器从请求里取料。三者组合起来覆盖 REST 的全部形态:
import { Controller, Get, Post, Patch, Delete, Param, Query, Body, Headers, HttpCode, Res, } from '@nestjs/common'; import { OrdersService } from './orders.service'; import { CreateOrderDto } from './dto/create-order.dto'; @Controller('orders') export class OrdersController { constructor(private readonly ordersService: OrdersService) {} @Get() list( @Query('page') page = 1, @Query('size') size = 20, ) { return this.ordersService.paginate(Number(page), Number(size)); } @Get(':id') detail(@Param('id') id: string) { return this.ordersService.findOne(id); } @Post() @HttpCode(201) create(@Body() dto: CreateOrderDto) { return this.ordersService.create(dto); } @Patch(':id/status') updateStatus( @Param('id') id: string, @Body('status') status: string, @Headers('x-request-id') requestId: string, ) { return this.ordersService.changeStatus(id, status, requestId); } @Delete(':id') remove(@Param('id') id: string) { return this.ordersService.remove(id); } }
参数装饰器各有明确岗位:@Param 取路径参数、@Query 取查询串、@Body 取请求体、@Headers 取请求头、@Req 与 @Res 取原生对象(慎用,理由见下)。状态码默认按动词给——POST 返回 201,其余 200——需要显式控制就用 @HttpCode。
路由匹配有一个容易踩的次序坑:@Get(':id') 这类通配段会吃掉更具体的路径。如果你同时有 @Get('stats') 与 @Get(':id'),必须把 stats 的方法放在前面声明,否则 "stats" 会被当成 id 传进去。框架按声明顺序匹配,这不是缺陷,但依赖它的代码要写清注释。

看图里的分工:左边所有的 HTTP 细节被窗口吸收,右边的服务完全不知道 HTTP 存在。这就是"薄窗口"原则——控制器只做协议转换,业务规则一行都不写。反例长这样:
// 反面教材:加工台长在了窗口上 @Post() async create(@Body() dto: CreateOrderDto) { if (!dto.items?.length) throw new BadRequestException('empty'); // 业务规则 const product = await this.productRepo.findOne(dto.productId); // 直接摸仓库 if (product.stock < dto.quantity) throw new ConflictException(); // 库存判断 const order = this.orderRepo.create({ ...dto, total: product.price * dto.quantity }); await this.orderRepo.save(order); return order; }
这段代码当下能跑,代价在后面:同样的规则无法被别的入口(定时任务、消息消费者)复用;写单元测试必须先伪造一个 HTTP 环境;规则一多,控制器从翻译员变成半个车间主任。正确形态是控制器一行调用 this.ordersService.create(dto),全部规则沉进服务。
@Res 注入原生响应对象是薄窗口的另一个漏洞:一旦你在参数里注入 @Res 并手动调用 res.json(),框架的标准响应管线就绕过了——拦截器(4.4 节)对这条路由失效,返回值映射、异常过滤都会走样。需要设响应头这类少数场景,用 @Header 装饰器或塞一个只读的 @Res({ passthrough: true }),让框架仍然接管返回值。
除动词与参数装饰器,窗口上还有几个低频但关键的开关,一起备齐。@Redirect 声明式跳转,适合网关式接口;@Header 设响应头;@Session 取会话对象(需要先在入口装配会话中间件);@Ip 取客户端地址。另有两个与响应形态相关的:@Res 前文已述,@Next 用于把请求显式传给下一个处理器,配合函数式路由偶尔在迁移期使用——新代码里几乎不该出现它,出现通常意味着该用中间件(4.1 节)。
@Get('docs') @Redirect('https://example.com/docs', 302) docsRedirect() { return; // 返回值可覆盖跳转目标,动态场景用得上 } @Get('report') @Header('Cache-Control', 'no-store') report() { return this.ordersService.dailyReport(); }
这些开关的共同点:都只描述协议层面的事,不碰业务。窗口上能拧的旋钮再多,加工一律在后台完成——记住这条,控制器一节的语法清单就齐了。
窗口收料要有单据。哪怕项目初期,也坚持为每个写操作建 DTO 类——它是后续管道校验(4.2 节 ValidationPipe)的挂载点,也是接口文档的素材来源:
export class CreateOrderDto { productId!: string; quantity!: number; remark?: string; }
现在它只是个类型声明,等到 4.2 节给它加上校验装饰器,同一份单据就会变成车间门口的质检标准。协议、校验、文档三样东西长在同一份 DTO 上,这是 Nest.js 收敛横切逻辑的典型手法。
窗口与单据齐了,下一节进加工台——Provider 才是车间里真正干活的零件,也是依赖注入体系的主角。