拦截器
继往开来:深入 NestJS 拦截器
这篇文章将带你全面了解拦截器,让你明白它是什么、如何使用,以及它与我们之前讨论过的中间件有何不同。
1. 什么是拦截器?给你的函数“套上”一个“魔法外壳”
拦截器是一个实现了 NestInterceptor
接口并使用 @Injectable()
装饰的类。
它的核心思想来源于一个非常重要的编程概念:面向切面编程 (AOP)。
前置知识:什么是面向切面编程 (AOP)?
想象一下,你的应用里有很多个功能函数,比如“创建用户”、“更新文章”、“获取商品列表”。现在,你希望在每一个函数执行前后都记录日志,或者统计它们的执行时间。
最笨的办法是在每个函数开头和结尾都加上重复的日志代码。但这显然很糟糕,代码冗余且难以维护。
AOP 就是为了解决这类问题而生的。 它允许我们将这些“横切”多个功能的通用逻辑(如日志、性能监控、事务管理)抽离出来,形成一个独立的模块,这个模块就被称为“切面 (Aspect)”。 然后,AOP 会在不修改原有业务代码的情况下,将这些“切面”逻辑“织入”到需要的地方。
NestJS 的拦截器就是 AOP 思想的完美体现。 它可以让你:
- 在目标方法执行之前和之后绑定额外的逻辑。
- 转换从函数返回的结果。
- 转换从函数抛出的异常。
- 扩展函数的基本行为。
- 在特定条件下完全重写一个函数(例如,为了实现缓存)。
把它想象成一个“魔法外壳”,你可以用它包裹任何你想增强的 Controller
方法。当请求来临时,会先进入这个“外壳”的前半部分,然后执行你真正的业务逻辑,最后再穿过“外壳”的后半部分返回给用户。
2. 创建你的第一个拦截器
每个拦截器都必须实现一个 intercept()
方法。这个方法接收两个参数:ExecutionContext
和 CallHandler
。
让我们创建一个简单的日志拦截器,它会记录请求处理的耗时。
src/logging.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
console.log('在方法执行之前...'); // [1]
const now = Date.now();
return next
.handle() // [2]
.pipe(
tap(() => console.log(`在方法执行之后... 耗时 ${Date.now() - now}ms`)), // [3]
);
}
}
我们来分解一下这段代码:
在方法执行之前...
: 这部分代码位于next.handle()
调用之前,所以它会在你的路由处理函数(例如CatsController
里的方法)执行前运行。next.handle()
: 这是最关键的部分。调用handle()
会执行你真正的路由处理函数。如果你不调用它,你的Controller
方法将永远不会被执行!handle()
返回一个Observable
。pipe(tap(...))
: 这是利用了RxJS
的强大功能。Observable
就像一个数据流,你可以使用各种操作符 (operators) 来处理这个流。tap()
操作符允许你在流正常结束时执行一个副作用(这里是打印日志),但它不会改变流本身。这部分代码会在路由处理函数执行完毕并返回结果后运行。
核心概念解释
ExecutionContext
(执行上下文): 和守卫 (Guard) 中的一样,它提供了关于当前执行过程的丰富信息。 你可以用它获取到将要被执行的控制器类 (context.getClass()
) 和处理函数 (context.getHandler()
)。 这使得编写可重用的通用拦截器成为可能。CallHandler
: 它封装了路由处理函数的调用。它的handle()
方法返回一个RxJS
的Observable
对象,这个对象“包裹”着你路由处理函数的最终返回值。RxJS
和Observable
: 这是 NestJS 异步处理的核心。你可以把它简单理解为一个“未来的值”或“事件流”。即使你的路由处理函数是同步的 (async/await
),NestJS 在内部也会将其转换为Observable
以便拦截器可以使用。
3. 应用拦截器
和守卫、管道一样,拦截器可以应用在不同的层级:方法级、控制器级、或全局级。 我们使用 @UseInterceptors()
装饰器来应用它。
应用在单个方法上
// src/cats/cats.controller.ts
import { Controller, Get, UseInterceptors } from '@nestjs/common';
import { LoggingInterceptor } from '../logging.interceptor';
@Controller('cats')
export class CatsController {
@Get()
@UseInterceptors(LoggingInterceptor) // 只对这个路由生效
findAll(): string {
console.log('正在执行 findAll 业务逻辑...');
return 'This action returns all cats';
}
}
当你访问 GET /cats
时,控制台会依次输出:
在方法执行之前...
正在执行 findAll 业务逻辑...
在方法执行之后... 耗时 1ms
应用在整个控制器上
// src/cats/cats.controller.ts
import { Controller, Get, Post, UseInterceptors } from '@nestjs/common';
import { LoggingInterceptor } from '../logging.interceptor';
@Controller('cats')
@UseInterceptors(LoggingInterceptor) // 对这个控制器下的所有路由生效
export class CatsController {
@Get()
findAll(): string {
return 'This action returns all cats';
}
@Post()
create(): string {
return 'This action adds a new cat';
}
}
应用为全局拦截器
在 main.ts
文件中注册全局拦截器。
// src/main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { LoggingInterceptor } from './logging.interceptor';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new LoggingInterceptor()); // 注册为全局拦截器
await app.listen(3000);
}
bootstrap();
注意:这种方式无法为拦截器注入依赖。如果需要依赖注入,你应该在根模块 (AppModule
) 中通过 providers
来注册。
4. 强大的实际用例
拦截器的强大之处在于它能处理各种横切关注点。
用例一:统一响应格式 (Response Mapping)
这是一个非常常见的需求:无论后端返回什么数据,都用一个固定的结构(如 { code, data, message }
)包裹起来再发给前端。
src/transform.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
export interface Response<T> {
data: T;
}
@Injectable()
export class TransformInterceptor<T> implements NestInterceptor<T, Response<T>> {
intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
return next.handle().pipe(
map(data => ({
statusCode: context.switchToHttp().getResponse().statusCode,
message: 'Success',
data,
})),
);
}
}
- 这里我们用了
map
操作符,它会获取Observable
流中的数据(也就是你路由处理函数的返回值data
),然后将其转换为一个新的格式。
现在,如果你有一个路由返回 ['cat1', 'cat2']
,经过这个拦截器后,前端实际收到的会是:
{
"statusCode": 200,
"message": "Success",
"data": ["cat1", "cat2"]
}
用例二:实现缓存 (Overriding the stream)
对于那些不经常变化但查询成本高的数据,我们可以用拦截器实现一个简单的缓存。
src/cache.interceptor.ts
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable, of } from 'rxjs';
// 假设我们有一个全局的缓存对象
const cache = new Map<string, any>();
@Injectable()
export class CacheInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const key = context.switchToHttp().getRequest().url; // 使用 URL 作为缓存键
if (cache.has(key)) {
console.log('从缓存中返回...');
return of(cache.get(key)); // 直接返回缓存的数据,不调用 handle()
}
console.log('首次查询,存入缓存...');
return next.handle().pipe(
tap(response => {
cache.set(key, response);
}),
);
}
}
```* 这个例子展示了拦截器的另一个强大能力:如果满足特定条件(缓存命中),我们可以通过返回一个全新的 `Observable` (`of(cache.get(key))`) 来**完全阻止**路由处理函数的执行。
### 5. 拦截器 vs. 中间件,到底该用谁?
这是初学者最容易困惑的地方。
| 特性 | 中间件 (Middleware) | 拦截器 (Interceptor) |
| :--- | :--- | :--- |
| **核心职责** | 请求预处理,做一些与框架耦合度低的操作。 | **AOP**,在方法执行前后添加逻辑,转换结果/异常。 |
| **访问能力** | 可以访问原始的 `req` 和 `res` 对象,但**无法**直接访问 `ExecutionContext`。 | 可以访问 `ExecutionContext`,能获取到要执行的类和方法。 |
| **执行时机** | 在**守卫 (Guard)** 之前执行。 | 在**守卫 (Guard)** 之后,**管道 (Pipe)** 之前和之后执行。 |
| **响应处理** | 理论上可以,但比较笨拙。主要关注请求处理。 | **核心能力**。可以轻松地通过 `RxJS` 操作符修改响应。 |
| **典型用例** | CORS、Helmet 安全头、Cookie 解析、原始请求日志。 | 统一响应格式、缓存、性能监控、转换响应数据。 |
**简单总结一下选择标准**:
* 当你需要处理最原始的 `request` 和 `response` 对象,或者需要引入 Express/Fastify 的生态中的一些中间件时,使用**中间件**。
* 当你需要的功能与某个具体的 `Controller` 或 `Method` 的执行前后逻辑紧密相关,尤其是需要**修改返回结果**时,**拦截器**是你的不二之选。
### 总结
拦截器为 NestJS 应用提供了一种强大而优雅的方式来处理横切关注点。通过利用 AOP 和 RxJS 的能力,你可以编写出高度可复用且功能强大的模块,用于日志记录、数据转换、缓存等多种场景。
理解拦截器与中间件、守卫、管道之间的区别和执行顺序,是掌握 NestJS 请求生命周期的关键。希望这篇文章能帮助你扫清障碍,在 NestJS 的学习道路上更进一步!