本节目标:理解 NestJS 请求生命周期中管道(Pipe)、守卫(Guard)、拦截器(Interceptor)各自的职责与执行顺序;能用
class-validator/ Zod 做入参校验,用守卫实现鉴权与角色控制,用拦截器统一日志、响应包装与超时;并知道什么逻辑该放哪一层。读完本节,你能把横切关注点从控制器里彻底抽离,写出干净、可复用、可全局统一的后端接口。
6.2 管道、守卫与拦截器
上一节我们把「谁能进、谁先跑」讲清楚了——模块与 DI 决定了对象的装配。但请求真正抵达控制器方法之前,还有一条处理流水线:先校验参数、再决定放行、最后包装响应。NestJS 把这条流水线拆成三个可插拔的组件。它们的存在意义是把横切关注点(校验、鉴权、日志)从业务代码里剥离出来。先看它们的执行顺序,这是理解全局的钥匙:
| 顺序 | 组件 | 时机 | 典型用途 |
|---|---|---|---|
| 1 | 中间件 Middleware | 路由匹配前 | 原始请求处理、CORS |
| 2 | 守卫 Guard | 进入控制器前 | 鉴权、角色校验 |
| 3 | 拦截器(前) | 调用处理器前 | 计时、缓存读取 |
| 4 | 管道 Pipe | 参数注入前 | 校验、类型转换 |
| 5 | 控制器方法 | 业务逻辑 | — |
| 6 | 拦截器(后) | 处理器返回后 | 响应包装、日志 |
| 7 | 异常过滤器 | 抛错时 | 统一错误响应 |
记忆口诀:守卫在前、管道在中、拦截器包住两头。下面逐一展开。
6.2.1 管道:校验与转换
管道接收原始参数,做两件事:转换(transform)与校验(validate)。校验不过就抛 BadRequestException,请求根本进不了控制器。
最省事的是内置的三个管道:
import { Controller, Get, Param, ParseIntPipe, Query } from "@nestjs/common";
@Controller("users")
export class UserController {
@Get(":id")
findOne(@Param("id", ParseIntPipe) id: number) {
return { id }; // id 已是 number,非法输入返回 400
}
}
ParseIntPipe 会把 "42" 转成 42;传入 "abc" 时自动返回:
{
"statusCode": 400,
"message": "Validation failed (numeric string is expected)",
"error": "Bad Request"
}
对复杂对象(DTO),用 ValidationPipe 配合装饰器声明校验规则:
import { IsEmail, IsInt, Min, IsOptional } from "class-validator";
export class CreateUserDto {
@IsEmail()
email!: string;
@IsInt()
@Min(18)
age!: number;
@IsOptional()
nickname?: string;
}
然后在控制器参数上挂 ValidationPipe:
// 放入已有控制器类,沿用本节的导入、DTO 与服务依赖
class ExampleController {
@Post()
create(@Body(new ValidationPipe({ whitelist: true, transform: true })) dto: CreateUserDto) {
return dto;
}
}
whitelist: true 会剥掉 DTO 未声明的多余字段(防脏数据),transform: true 会把纯对象转成 DTO 实例。注意 class-validator 的装饰器依赖 emitDecoratorMetadata,上一节提到的编译选项这里同样适用。
如果你更偏好 Zod 的推导风格(本书 React Hook Form + Zod 用的就是它),可以自己写一个 Zod 管道:
import { PipeTransform, Injectable, BadRequestException } from "@nestjs/common";
import { ZodSchema } from "zod";
@Injectable()
export class ZodValidationPipe implements PipeTransform {
constructor(private readonly schema: ZodSchema) {}
transform(value: unknown) {
const result = this.schema.safeParse(value);
if (!result.success) {
throw new BadRequestException(result.error.flatten());
}
return result.data;
}
}
Zod 的优势是类型自动从 schema 推导,DTO 类型与运行时校验不会脱节,避免了「类型写对了、校验漏了」的经典裂缝。两种方案都成熟,团队统一即可,延伸阅读可见 Zod 运行时校验 。
6.2.2 守卫:决定请求能否放行
守卫实现 CanActivate 接口,返回 boolean 或 Promise<boolean>;返回 false 时 NestJS 抛出 403。它最典型的用途是鉴权:
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private readonly authService: AuthService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = request.headers.authorization?.replace("Bearer ", "");
if (!token) return false;
request.user = await this.authService.verify(token);
return true;
}
}
守卫能拿到 ExecutionContext,因此可以读取当前请求、处理器元数据,甚至切换 HTTP / WebSocket / RPC 上下文。基于元数据的角色守卫是常见套路:
import { SetMetadata } from "@nestjs/common";
export const Roles = (...roles: string[]) => SetMetadata("roles", roles);
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.getAllAndOverride<string[]>("roles", [
context.getHandler(),
context.getClass(),
]);
if (!required) return true;
const { user } = context.switchToHttp().getRequest();
return required.some((role) => user?.roles?.includes(role));
}
}
用法一目了然:
// 放入已有控制器类,沿用本节的导入、DTO 与服务依赖
class ExampleController {
@Roles("admin")
@UseGuards(AuthGuard, RolesGuard)
@Delete(":id")
remove(@Param("id") id: string) {
return this.userService.remove(id);
}
}
守卫 vs 中间件的边界要分清:中间件不知道「即将执行的是哪个处理器」,而守卫能读到 @Roles 这类元数据,所以凡是需要感知路由语义的鉴权,一律用守卫。JWT 校验的完整实践可参考 JWT 鉴权
。
6.2.3 拦截器:包住两头的横切逻辑
拦截器实现 NestInterceptor,拿到一个 ExecutionContext 和下一个处理器的调用句柄 CallHandler。它的独特之处在于用 Observable 包住整个调用,能在方法执行前后各插一段逻辑:
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
import { Observable, tap } from "rxjs";
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger(LoggingInterceptor.name);
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const req = context.switchToHttp().getRequest();
const start = Date.now();
this.logger.log(`--> ${req.method} ${req.url}`);
return next.handle().pipe(
tap(() => {
this.logger.log(`<-- ${req.method} ${req.url} ${Date.now() - start}ms`);
}),
);
}
}
三个高频场景:
统一响应包装——把 { data, code, message } 外壳从每个控制器里抽掉:
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, { data: T }> {
intercept(_: ExecutionContext, next: CallHandler<T>): Observable<{ data: T }> {
return next.handle().pipe(map((data) => ({ data })));
}
}
超时控制——给慢接口兜底,避免请求挂死:
return next.handle().pipe(
timeout(5000),
catchError((err) => {
if (err instanceof TimeoutError) throw new RequestTimeoutException();
throw err;
}),
);
缓存——命中缓存则跳过控制器(注意 next.handle() 是惰性的,只有订阅时才真正执行):
const cached = this.cache.get(key);
if (cached) return of(cached);
return next.handle().pipe(tap((data) => this.cache.set(key, data)));
拦截器返回的必须是 Observable,所以 RxJS 操作符(map、tap、timeout、catchError)是它的日常工具。日志的字段设计请遵循结构化日志的思路,与 结构化日志与脱敏
保持一致。
6.2.4 全局注册与作用范围
三类组件都能按「方法 → 控制器 → 全局」三级挂载,粒度越粗越省事。全局注册有两种方式。推荐用 APP_* 令牌,因为它能享受 DI(可注入 ConfigService、Logger 等):
import { APP_GUARD, APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core";
@Module({
providers: [
{ provide: APP_PIPE, useClass: ValidationPipe },
{ provide: APP_GUARD, useClass: AuthGuard },
{ provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
],
})
export class AppModule {}
另一种是在 main.ts 里 app.useGlobalPipes(...),但它无法注入依赖,只适合无依赖的简单组件。工程中优先选 APP_*。
顺序上还有一条容易踩的坑:全局守卫先于控制器守卫执行,控制器守卫先于方法守卫执行。若鉴权逻辑放在全局守卫,而角色守卫挂在方法上,二者的先后关系要提前想清楚。
6.2.5 异常过滤器:流水线的兜底
流水线最后还有一层——异常过滤器(Exception Filter),捕获任何未处理的异常并转成统一响应:
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const res = host.switchToHttp().getResponse();
const status = exception.getStatus();
res.status(status).json({
code: status,
message: exception.message,
timestamp: new Date().toISOString(),
});
}
}
它和 全局错误边界与未捕获异常
讲的是同一件事在不同层面的实现:应用层用 Result 承载可预期错误,框架层用过滤器兜住漏网之鱼。NestJS 后端的分层错误处理,可延伸阅读 错误处理与日志
。
6.2.6 该放哪一层:一张决策表
横切组件多了容易滥用。下面这张表帮你快速决策:
| 需求 | 该用 | 不该用 |
|---|---|---|
| 参数格式校验、类型转换 | 管道 | 控制器里手写 if |
| 鉴权、角色/权限 | 守卫 | 中间件(读不到元数据) |
| 请求耗时统计、响应包装 | 拦截器 | 每个方法里重复写 |
| 原始请求预处理(CORS、body) | 中间件 | 拦截器(时机太晚) |
| 统一错误响应 | 异常过滤器 | 到处 try/catch |
| 业务规则判断 | 服务层 | 守卫(守卫只做准入) |
一句话总结:守卫管「能不能进」,管道管「进来的东西对不对」,拦截器管「进出前后做什么」,过滤器管「出错了怎么办」。业务逻辑本身,永远留在服务层。
小结
- 请求处理流水线的顺序是 中间件 → 守卫 → 拦截器(前)→ 管道 → 控制器 → 拦截器(后)→ 异常过滤器,记住「守卫在前、管道在中、拦截器包两头」即可。
- 管道负责校验与转换:内置
ParseIntPipe等够用,复杂 DTO 用class-validator+ValidationPipe,偏好类型推导则用 Zod 自写管道。 - 守卫负责准入,能读取路由元数据,因此鉴权、角色控制应放这里而非中间件。
- 拦截器用
Observable包住调用,天然适合日志、响应包装、超时与缓存;注意next.handle()是惰性的。 - 全局注册优先用
APP_*令牌,因为它可注入依赖;useGlobalXxx无法享受 DI,只适合无依赖场景。 - 异常过滤器是最后一道兜底,与第 3 章的
Result/ 错误边界是分层互补关系,而不是二选一。 - 组件越多越要克制:横切逻辑进流水线,业务规则进服务层,边界一旦模糊,代码就会退化成另一种面条。
- 流水线组件本身往往需要读配置(超时毫秒数、白名单路径、开关)。这些值从哪来、怎么在启动时校验?下一节 配置与生命周期 就来回答这个问题。
阅读导航:上一节:6.1 模块、提供者与依赖注入 · 下一节:6.3 配置与生命周期 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。