拦截器与异常过滤器
NestInterceptor 响应变换与耗时日志、超时拦截器、ExceptionFilter 全局兜底、拦截器常见坑与遮代码自检,验证管线执行顺序。
0. 环绕逻辑与错误兜底(先读这里)
学习目标:用 NestInterceptor 实现耗时日志、统一响应壳与超时保护,掌握 RxJS 的 map、tap、timeout、catchError 在拦截器中的分工;定义业务异常并编写全量异常过滤器;用实验验证整条管线的执行顺序,并能识别拦截器的四类典型坑。
上一篇的管线表里,守卫负责”能不能进”,而”进出来什么”(拦截器)与”出错怎么办”(异常过滤器)是本章主角。拦截器横跨处理前后两半,异常过滤器兜底所有未捕获异常,两者共同决定接口的最终输出形态。
先建立流的心智模型:next.handle() 返回的不是响应值,而是一条响应流(RxJS Observable,惰性——Nest 负责订阅)。于是拦截器的全部工作可以概括为一句话:在前半段做请求侧的事,用管道操作符加工响应流做后半段的事。tap 旁路观察不改数据、map 改数据、timeout 上闹钟、catchError 在流内接错——四个操作符分工清晰后,网上五花八门的拦截器你都能一眼看懂。
1. 拦截器:日志与耗时统计
// src/common/interceptors/logging.interceptor.ts
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor
} from "@nestjs/common"
import { Observable, tap } from "rxjs"
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const req = context.switchToHttp().getRequest()
const start = Date.now()
console.log(`--> ${req.method} ${req.url}`)
return next.handle().pipe(
// tap 只做旁路观察不改数据;处理器成功返回后才打印耗时
tap(() => console.log(`<-- ${req.method} ${req.url} ${Date.now() - start}ms`))
)
}
}讲解:
intercept返回什么流,Nest 就以什么作为响应:不调用next.handle()相当于短路(配合缓存、熔断场景)。tap用于副作用(日志、埋点),map用于改数据,混用会让”谁改了返回值”难以追踪。- 拦截器与守卫一样支持方法级
@UseInterceptors(X)、控制器级与全局三种绑定,粒度记忆可以完全复用。
2. 拦截器:统一响应壳与错误转换
// src/common/interceptors/transform.interceptor.ts
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor
} from "@nestjs/common"
import { map, Observable } from "rxjs"
export interface ApiResponse<T> {
code: number
data: T
timestamp: string
}
@Injectable()
export class TransformInterceptor<T>
implements NestInterceptor<T, ApiResponse<T>>
{
intercept(
context: ExecutionContext,
next: CallHandler<T>
): Observable<ApiResponse<T>> {
return next.handle().pipe(
// map 把每个处理器的返回值包上统一外壳
map((data) => ({ code: 0, data, timestamp: new Date().toISOString() }))
)
}
}错误分支同样在流上处理,catchError 可以在异常进入过滤器之前先做一次转换:
// 追加到 transform.interceptor.ts 的 pipe 中
import { catchError, throwError } from "rxjs"
import { HttpException, InternalServerErrorException } from "@nestjs/common"
return next.handle().pipe(
catchError((err) => {
// HttpException 原样放行给过滤器;未知异常统一转成 500
if (err instanceof HttpException) return throwError(() => err)
return throwError(() => new InternalServerErrorException("服务内部错误"))
})
)讲解:
- 统一响应壳与管道校验与异常处理的统一错误结构是一对:成功走
TransformInterceptor,失败走ExceptionFilter,前端只需要解析两种固定格式。 catchError不是必须的:不处理时异常沿管线继续向后传,最终由异常过滤器接收;在这里先转换,适合”把第三方库的私有错误类型翻译成 HTTP 语义”。- RxJS 的
Observable是惰性流,不订阅就不执行,Nest 负责订阅,你只管在管道里加工。 - 全局注册方式与守卫一致:
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor },先注册的先执行;这一写法让拦截器留在容器里,内部依赖注入照常生效(原理见依赖注入与 Provider 体系第 7 节)。
3. 业务异常与全量异常过滤器
管道校验与异常处理写过只处理 HttpException 的局部过滤器;进阶做法是定义业务异常 + 一个全量兜底过滤器:
// src/common/exceptions/business.exception.ts
import { HttpException, HttpStatus } from "@nestjs/common"
// 业务异常:携带业务错误码,HTTP 状态固定 422
export class BusinessException extends HttpException {
constructor(
message: string,
private readonly bizCode: number
) {
super(message, HttpStatus.UNPROCESSABLE_ENTITY)
}
getBizCode(): number {
return this.bizCode
}
}// src/common/filters/all-exceptions.filter.ts
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus
} from "@nestjs/common"
import { Response } from "express"
@Catch() // 不带参数 = 捕获所有类型的异常
export class AllExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const res = host.switchToHttp().getResponse<Response>()
if (exception instanceof HttpException) {
const status = exception.getStatus()
const body = exception.getResponse()
return res.status(status).json({
code: status,
message:
typeof body === "string" ? body : ((body as any).message ?? "请求失败")
})
}
// 未知异常:完整堆栈只留在服务端,不泄露给客户端
console.error(exception)
return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
code: 500,
message: "服务内部错误"
})
}
}全局注册推荐走模块 Provider,这样过滤器内部也能依赖注入:
// src/app.module.ts(节选)
import { APP_FILTER } from "@nestjs/core"
providers: [{ provide: APP_FILTER, useClass: AllExceptionsFilter }]讲解:
exception: unknown的类型签名是刻意设计:强制你显式处理”不是 HttpException 的情况”,而不是假设一切皆 Http。@Catch(HttpException)的局部过滤器与@Catch()全量过滤器可以共存,Nest 会挑最匹配的过滤器执行,全量兜底只负责”漏网之鱼”。- 服务类里
throw new BusinessException("余额不足", 40001),客户端拿到 422 与业务码,比直接抛 Error 更有契约感。 - 未知异常日志务必打在服务端(含堆栈),客户端只收到笼统的 500——这是安全与排障的平衡点。
4. 超时拦截器:给慢请求上闹钟
下游一次抖动就能拖死整条线程池——“每个请求最多等多久”是环绕逻辑的本职工作。timeout 操作符给流上闹钟,catchError 把到时的流转成 HTTP 语义:
// src/common/interceptors/timeout.interceptor.ts
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
RequestTimeoutException
} from "@nestjs/common"
import { catchError, Observable, throwError, timeout } from "rxjs"
@Injectable()
export class TimeoutInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
timeout(3000), // 3 秒内没有产出就算超时
catchError((err) => {
// TimeoutError 只能按名字辨认:rxjs 不导出它的类
if (err && err.name === "TimeoutError") {
return throwError(() => new RequestTimeoutException("请求超时"))
}
return throwError(() => err) // 其余异常原样放行给过滤器
})
)
}
}
// 挂载在明知可能慢的路由上(报表、外部数据源),或全局注册
@UseInterceptors(TimeoutInterceptor)
@Get("report")
report() {
return this.slowService.compute()
}讲解:
timeout到点后中断的是等待,不是下游任务——慢查询仍会在后台跑完(数据库侧需要自己的 statement timeout 才能真正止血)。超时保护的完整方案是”外层掐流 + 下游自限”,缺一半就只是自欺;- 判断超时用
err.name === "TimeoutError":rxjs 的 TimeoutError 随版本导出方式不稳定,按名字辨认最抗变动; RequestTimeoutException是 Nest 内置异常(HTTP 408),进过滤器后自然转成标准错误结构——拦截器只管”识别 + 翻译”,输出形态统一交给过滤器,职责不越界;- 阈值别一刀切:导出报表类路由给宽阈值,普通 CRUD 全局给紧阈值——全局注册一个、路由级覆盖一个,两者并存时路由级优先。
5. 执行顺序验证实验
给同一接口挂上全部组件,每个组件打一行日志:
// 临时实验:probe.guard.ts / probe.interceptor.ts / probe.filter.ts
// 各自 console.log 标识自身,全部注册后请求 POST /todos 观察输出# 请求 POST /todos(body 合法),控制台输出顺序:
# [guard] AuthGuard
# [guard] RolesGuard
# [interceptor] 前半
# [pipe] ValidationPipe 校验通过
# [handler] TodosController.create
# [interceptor] 后半
# 把 body 改成非法数据再请求:
# [guard]、[interceptor] 前半、[pipe] 校验失败、[filter] 兜底
# —— handler 与拦截器后半消失,管道失败直接跳到过滤器讲解:
- 成功路径按上一篇的七层表严格递进;异常路径中,守卫抛异常时拦截器与管道都不会执行,处理器抛异常时拦截器的
catchError先收到、过滤器最后兜底。 - 把这套实验写进学习笔记:将来排查”过滤器没生效""拦截器跑了两次”类问题时,按时序逐层断点即可定位。
6. 拦截器常见坑
坑一:忘了返回 next.handle()。 intercept 里只写了前半段日志就结束,返回 undefined——客户端永远等不到响应。拦截器的返回值就是响应流,函数结尾必然是 return next.handle().pipe(...)。
坑二:catchError 里”接住就吞”。 捕获后既不 rethrow 也不返回兜底值,流归于完结,过滤器收不到异常,客户端拿到 200 空响应。catchError 的纪律:要么翻译后 throwError(() => ...) 继续抛,要么返回一个合法的回退值(如缓存数据)——不许静默吞。
坑三:tap 里改数据。 tap((data) => data.items = []) 一样能改对象(引用语义),于是”只观察”的约定被悄悄破坏。改数据用 map,tap 里只打日志、埋点。
坑四:全局 + 路由级重复挂载。 同一个拦截器既在 APP_INTERCEPTOR 全局注册,又在路由上 @UseInterceptors 挂了一次——跑两遍,日志翻倍、缓存写两次。排查口诀:先查全局注册表,再查装饰器;全局已覆盖的路由级挂载全部删掉。
坑五:在拦截器里 console.log 上生产。 高 QPS 下每请求两行日志先拖垮磁盘。用 nestjs-pino 等结构化日志库(或按日志级别判断),把第 1 节的 LoggingInterceptor 当教学版。
7. 动手实践:遮代码自检
先只读任务与提示,自己写完再对照参考实现。
任务:写一个 SlowRequestInterceptor,要求——
- 处理耗时超过阈值(构造器可配,默认 1000ms)的请求时,打印
[SLOW] POST /todos 耗时 2340ms(走Logger.warn而不是 console.log); - 未超时的请求不打印任何内容(别让正常流量刷屏);
- 在 TodosController 上以带阈值的方式挂载(如 500ms),并说明它与全局 LoggingInterceptor 同时存在时的执行顺序。
提示:耗时判断天然属于响应流的”后半”——tap 的回调在处理器成功返回后才执行,那里正是 Date.now() - start 出结果的地方;阈值经构造器传入(第 165 篇 useFactory 的思路),挂载时用 @UseInterceptors(new SlowRequestInterceptor(500)) 传实例。
参考实现(先自己写完再看):
// src/common/interceptors/slow-request.interceptor.ts
import {
CallHandler,
ExecutionContext,
Injectable,
Logger,
NestInterceptor
} from "@nestjs/common"
import { Observable, tap } from "rxjs"
@Injectable()
export class SlowRequestInterceptor implements NestInterceptor {
private readonly logger = new Logger(SlowRequestInterceptor.name)
constructor(private readonly thresholdMs = 1000) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const req = context.switchToHttp().getRequest()
const start = Date.now()
return next.handle().pipe(
tap(() => {
const elapsed = Date.now() - start
if (elapsed > this.thresholdMs) {
// 只在超阈值时发声:异常才值得日志
this.logger.warn(`[SLOW] ${req.method} ${req.url} 耗时 ${elapsed}ms`)
}
})
)
}
}// todos.controller.ts —— 挂载
@UseInterceptors(new SlowRequestInterceptor(500))
@Controller("todos")
export class TodosController {}要点自查:
- 判断与打印都在
tap回调里,“后半段”由流驱动,不需要定时器; Logger.warn带上下文名(Interceptor 类名),比裸 console.log 多了可过滤性(坑五的最低成本解法);- 与全局 LoggingInterceptor 并存时顺序固定:请求进入先跑全局前半、再跑路由级前半,返回时后挂载的后半先执行(洋葱模型,先进后出)——所以
[SLOW]出现在访问日志<--行之前; - 若这个拦截器将来需要注入配置服务(如从 ConfigService 读阈值),就不能再
new——改回类挂载并用模块 providers + useFactory 组装(第 165 篇第 2.3 节)。
8. 小结与延伸
- 拦截器答”进出来什么”:
tap做副作用、map做响应变换、timeout上闹钟、catchError做错误转换;响应是流,前半段做请求侧、管道做响应侧。 - 异常过滤器答”出错怎么办”:
BusinessException携带业务码,@Catch()全量兜底统一错误结构。 - 全局组件用
APP_GUARD/APP_INTERCEPTOR/APP_FILTER注册,支持 DI 且顺序可控;注册机制原理见依赖注入与 Provider 体系。 - 拦截器四坑:忘返回流、catchError 吞错、tap 改数据、全局路由双重挂载。
- 延伸:
@nestjs/terminus健康检查见官方 Interceptors 章节;RxJS 学习重点先掌握 tap、map、timeout、catchError 四个操作符即可覆盖九成场景。