《TypeScript编程实战》6.2 管道、守卫与拦截器

本节讲解 NestJS 请求处理流水线里的三类横切组件:管道负责校验与转换入参,守卫决定请求能否放行,拦截器在方法前后织入日志、响应包装与超时控制。我们给出它们的执行顺序、手写实现与全局注册方式,并对比各自的适用边界。读完本节,你能把校验、鉴权与日志从控制器里抽干净,写出无重复样板的后端接口。

本节目标:理解 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 配置与生命周期 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「typescript」更多文章

  1. 《TypeScript高级编程》11.3 类型驱动架构与团队规范
  2. 《TypeScript高级编程》11.2 渐进式迁移与严格化路径
  3. 《TypeScript高级编程》11.1 TS 版本演进与 breaking changes