4. 控制器与路由工程化
控制器是 NestJS 请求生命周期的第一站,也是对外契约的落点。一个可维护的 API 入口层,需要同时解决六件事:REST 语义是否准确、版本如何演进、前缀如何统一、参数从哪里来、DTO 如何校验、响应如何被统一包装。它们不是六个独立技巧,而是一条从"请求进入"到"响应返回"的完整链路。本节按这条链路展开,并给出可直接落地的工程写法。
一、REST 语义:用资源与方法来表达意图
REST 的核心不是"用 GET 还是 POST",而是把业务对象抽象为资源,用 HTTP 方法表达对资源的动作。约定如下:GET 查询(幂等、无副作用)、POST 创建、PUT 全量替换、PATCH 局部更新、DELETE 删除。路径用名词复数,动作交给方法,例如 GET /articles、POST /articles、PATCH /articles/:id。避免出现 /getArticleList、/deleteArticleById 这类动词路径,它们会让路由层退化成 RPC 风格,后续做缓存、权限、审计时都难以按资源维度统一处理。
状态码同样属于语义的一部分:创建成功返回 201,删除成功返回 204,参数错误返回 400,未认证 401,无权限 403,资源不存在 404,冲突 409。NestJS 默认 POST 返回 201、其余返回 200,需要时用 @HttpCode() 显式覆盖,例如删除接口标注 @HttpCode(204)。
二、版本控制与路由前缀
版本控制解决的是"接口已上线、契约不能随意破坏"的问题。NestJS 提供四种策略:URI(/v1/articles)、Header、Media Type、Custom。企业项目最常用 URI 版本,因为它对网关、日志、缓存和前端调试都最直观。在 main.ts 中开启:
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api'); // 统一前缀
app.enableVersioning({
type: VersioningType.URI,
defaultVersion: '1',
});
await app.listen(3000);
控制器上可用 @Controller({ path: 'articles', version: '1' }) 指定版本,也可用 @Version('2') 标注单个方法,实现同一控制器内多版本共存。最终路径形如 /api/v1/articles。前缀与版本分离的好处是:网关按 /api 做统一鉴权与限流,版本号则留给业务演进。
三、参数装饰器:把请求拆成明确的输入
NestJS 通过装饰器把请求的不同部位映射为方法参数,常用组合如下表。工程上应优先使用 @Param、@Query、@Body 这类"精确取值",少用 @Req 直接操作原始请求对象,这样参数来源清晰、可测试、可校验。
| 装饰器 | 取值来源 | 典型用途 |
|---|---|---|
| @Param('id') | 路径参数 | 资源主键 |
| @Query() | 查询字符串 | 分页、筛选、排序 |
| @Body() | 请求体 | 创建/更新数据 |
| @Headers('x-trace-id') | 请求头 | 链路追踪 |
| @Ip() / @Session() | 连接/会话 | 审计、登录态 |
四、DTO 绑定与校验:让非法输入止步于入口
DTO(Data Transfer Object)是控制器与外部世界之间的契约。用 class-validator 声明规则,配合全局 ValidationPipe 自动拦截非法请求:
export class CreateArticleDto {
@IsString() @Length(2, 80)
title: string;
@IsString() @MinLength(10)
content: string;
@IsOptional() @IsArray() @IsString({ each: true })
tags?: string[];
}
// main.ts
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // 剥离未声明字段
forbidNonWhitelisted: true, // 出现多余字段直接报错
transform: true, // 自动类型转换
transformOptions: { enableImplicitConversion: true },
}));
注意 transform: true 的必要性:URL 中的 :id 与 query 都是字符串,若 DTO 声明为 number,开启隐式转换后才会被正确解析,否则校验会因类型不符而失败。查询参数建议单独定义 QueryArticleDto,把 page、pageSize 用 @Type(() => Number) 与 @Min(1) 约束,避免分页参数被注入异常值。
五、响应序列化与拦截器统一包装
响应侧要解决两个问题:一是"哪些字段能出去",二是"出去的格式是否统一"。前者用 class-transformer 的 @Exclude() / @Expose() 配合 ClassSerializerInterceptor,把实体中的密码、内部标记等敏感字段自动剔除;后者用自定义拦截器把成功响应包装成统一结构:
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
intercept(ctx: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> {
return next.handle().pipe(map((data) => ({
code: 0,
message: 'ok',
data,
timestamp: Date.now(),
})));
}
}
异常侧则由全局异常过滤器兜底,把 HttpException 与未知错误统一转换为 { code, message, data: null },保证前端只需处理一种响应外壳。拦截器与过滤器分工明确:拦截器管成功路径,过滤器管失败路径,两者共同构成"响应契约"。
六、工程化落地要点
把上述能力组合起来时,有几个容易踩的坑值得提前规避。第一,ValidationPipe 与 ClassSerializerInterceptor 都依赖 class-transformer,务必保证 DTO 与实体使用真实类而非接口,否则元数据丢失、校验静默失效。第二,拦截器注册顺序会影响包装结果,全局拦截器按注册顺序由外向内执行,若同时存在日志拦截器与包装拦截器,应让日志在外层,先记录原始响应再包装。第三,版本升级时不要直接修改 v1 的 DTO,而应新增 v2 控制器并复用 Service,让版本差异只停留在入口层。第四,统一响应外壳一旦确定,就要在 OpenAPI 文档中同步声明,避免前端按裸数据解析。
setGlobalPrefix、enableVersioning、ValidationPipe、全局拦截器与过滤器集中放在 bootstrap() 中,形成一份可复制的入口层初始化清单,新项目直接复用,减少环境差异。至此,一个请求从进入 /api/v1/articles 到返回 { code, message, data, timestamp } 的完整路径已经闭合:前缀与版本负责定位,路由与装饰器负责取值,DTO 与管道负责校验,序列化与拦截器负责出参,过滤器负责兜底。控制器因此不再是散落的接口集合,而是一层语义清晰、契约稳定、可随业务版本平滑演进的 API 入口层。