NestJS 拦截器与跳过拦截器
用 NestJS 全局拦截器统一包装成功响应,并通过自定义装饰器为 Webhook、文件下载等接口跳过包装。
写 API 接口时,常见需求是让成功响应保持统一结构。比如业务方法返回用户信息:
{
"user": "xxx",
"imageUrl": "https://example.com/avatar.png"
}
接口返回时,希望统一包一层:
{
"code": 0,
"success": true,
"data": {
"user": "xxx",
"imageUrl": "https://example.com/avatar.png"
}
}
这类逻辑很适合放在 NestJS interceptor 里。拦截器可以在 handler 执行前后介入,也可以用 RxJS operator 改写 handler 的返回值。
English version: NestJS Interceptors and How to Skip Response Wrapping
统一成功响应
一个最基础的成功响应包装拦截器可以这样写:
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
interface ApiResponse<T> {
code: number;
success: true;
data: T;
}
@Injectable()
export class ResponseWrapInterceptor<T>
implements NestInterceptor<T, ApiResponse<T>>
{
intercept(
context: ExecutionContext,
next: CallHandler<T>,
): Observable<ApiResponse<T>> {
return next.handle().pipe(
map((data) => ({
code: 0,
success: true,
data,
})),
);
}
}
这里的关键点是 next.handle() 返回的是一个 Observable。handler 原本返回的数据会进入这个流,map() 可以把它转换成统一结构。
注册成全局拦截器时,推荐使用 APP_INTERCEPTOR,这样拦截器仍然在 Nest 的依赖注入上下文里:
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ResponseWrapInterceptor } from './response-wrap.interceptor';
@Module({
providers: [
{
provide: APP_INTERCEPTOR,
useClass: ResponseWrapInterceptor,
},
],
})
export class AppModule {}
这样大多数接口只需要返回业务数据,不需要每个 controller 都手写 { code, success, data }。
错误响应更适合放在异常过滤器
有些代码会用另一个 interceptor 配合 catchError() 把异常也包装起来。这个做法能工作,但不一定是更清晰的边界。
成功响应包装属于“handler 正常返回后的数据转换”,很适合 interceptor。错误响应则是异常处理,NestJS 里更直接的工具是 exception filter。这样成功和失败两条链路更清楚,也不容易在 interceptor 里误吞异常。
一个简化的 HTTP 异常过滤器可以这样写:
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const message =
exception instanceof HttpException
? exception.message
: 'Internal server error';
response.status(status).json({
code: status,
success: false,
message,
});
}
}
全局注册可以放在 main.ts:
const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new HttpExceptionFilter());
await app.listen(3000);
实际项目里还可以继续补充错误码映射、日志记录、请求 ID、业务异常基类等内容。核心原则是:成功响应用 interceptor 转换,异常响应用 filter 处理。
为什么需要跳过包装
全局拦截器的问题在于它会影响所有接口。但有些接口不能返回统一 JSON:
- 微信、GitHub、Stripe 等 Webhook 可能要求返回固定文本或固定状态码。
- 文件下载接口需要直接返回文件流。
- 图片、二维码、CSV 导出等接口有自己的
Content-Type。 - 代理接口可能需要原样透传上游响应。
如果这些接口也被包成 { code, success, data },调用方就无法按协议识别响应。解决办法是给接口打一个“跳过包装”的标记,让拦截器读这个 metadata。
定义跳过包装装饰器
可以用 SetMetadata 定义一个装饰器:
import { SetMetadata } from '@nestjs/common';
export const SKIP_RESPONSE_WRAP = 'skipResponseWrap';
export const SkipResponseWrap = () => SetMetadata(SKIP_RESPONSE_WRAP, true);
然后在拦截器里注入 Reflector,同时读取 handler 和 controller 上的 metadata:
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { SKIP_RESPONSE_WRAP } from './skip-response-wrap.decorator';
interface ApiResponse<T> {
code: number;
success: true;
data: T;
}
@Injectable()
export class ResponseWrapInterceptor<T>
implements NestInterceptor<T, ApiResponse<T> | T>
{
constructor(private readonly reflector: Reflector) {}
intercept(
context: ExecutionContext,
next: CallHandler<T>,
): Observable<ApiResponse<T> | T> {
const skip = this.reflector.getAllAndOverride<boolean>(
SKIP_RESPONSE_WRAP,
[context.getHandler(), context.getClass()],
);
if (skip) {
return next.handle();
}
return next.handle().pipe(
map((data) => ({
code: 0,
success: true,
data,
})),
);
}
}
context.getHandler() 对应当前路由方法,context.getClass() 对应 controller 类。用 getAllAndOverride() 的好处是装饰器既可以放在方法上,也可以放在整个 controller 上。
使用方式
比如微信消息推送要求服务端返回纯文本 success,就可以给这个接口加上 @SkipResponseWrap():
import { Body, Controller, HttpCode, Post } from '@nestjs/common';
import { SkipResponseWrap } from './skip-response-wrap.decorator';
@Controller('wechat')
export class WechatController {
@Post('push')
@HttpCode(200)
@SkipResponseWrap()
async handleWechatPush(@Body() data: unknown) {
// 校验签名、处理消息、记录日志等
return 'success';
}
}
这样这个接口会返回纯字符串 success,不会被包装成:
{
"code": 0,
"success": true,
"data": "success"
}
如果一个 controller 下所有接口都不需要包装,也可以把装饰器放在类上:
@SkipResponseWrap()
@Controller('files')
export class FilesController {}
需要注意的边界
NestJS 文档里有一个重要提醒:response mapping 不适用于直接使用 library-specific response strategy 的场景,也就是在 handler 里直接使用 @Res() 操作原始响应对象。
所以实践里可以按下面的规则分工:
- 普通 JSON API:直接返回业务对象,让全局 interceptor 包装。
- 固定协议响应:加
@SkipResponseWrap(),直接返回协议需要的内容。 - 文件流或强控制响应头:加
@SkipResponseWrap(),必要时使用@Res()或StreamableFile。 - 错误响应:优先交给 exception filter,而不是在成功响应 interceptor 里混着处理。
这样做的好处是全局规则仍然简单,但特殊接口有明确出口。拦截器负责统一成功响应,装饰器负责声明例外,异常过滤器负责错误结构,三者的职责边界比较清楚。