首页 / 文章 / 每个 NestJS 路由一个 JSON 封装:拦截器与全局过滤器

每个 NestJS 路由一个 JSON 封装:拦截器与全局过滤器

通过通用拦截器、消息装饰器和异常过滤器,将每个 NestJS 响应封装在类型化的对象中,同时让控制器无需编写冗余代码。

1288 词

当每个接口端点返回的格式略有不同时,API客户端就必须采用防御性编程。统一的响应结构能让使用者只需编写一个响应处理函数而非多个。NestJS无需修改每个控制器即可实现这一点:拦截器负责转换成功的响应结果,装饰器提供针对不同路由的提示信息,异常过滤器则让错误也遵循相同的格式。本指南将详细介绍这三种机制的实现方式。

为何在控制器中封装响应无法扩展

简单的做法是在每个处理函数中手动构建统一的响应结构:

@Get()
findAll() {
  const users = await this.userService.findAll();
  return {
    code: 200,
    status: true,
    message: 'Success retrieve users',
    data: users
  };
}

这种重复代码出现在数十个路由中,随着时间推移问题会愈发严重。控制器应负责接收输入并返回领域数据;对输出数据的格式化属于贯穿整个请求生命周期的共性问题。(另外请注意,该代码片段在未标记为async的方法中使用了await,这将导致代码无法编译;这又是将此类处理逻辑从控制器中分离出来的一个理由。)

定义响应契约

应从类型开始设计。IResponseEntity<T>是对负载数据进行泛型处理的接口,而可选的meta对象则用于存储分页相关信息:

// src/common/interfaces/response.interface.ts

export interface ImetaPagination {
  page: number;
  limit: number;
  totalItems: number;
  totalPages: number;
  hasNextPage: boolean;
  hasPrevPage: boolean;
}

export interface IResponseEntity<T> {
  code: number;
  status: boolean;
  message: string;
  data?: T;
  meta?: ImetaPagination;
}

使用元数据装饰器实现路由级消息

创建接口和列表接口应使用不同的成功响应消息。SetMetadata方法会将某个值附加到路由处理程序上,再通过一个简单的包装器为该值赋予易于理解的名称:

// src/common/decorators/response-message.decorator.ts
import { SetMetadata } from '@nestjs/common';

export const RESPONSE_MESSAGE_METADATA = 'response_message';
export const ResponseMessage = (message: string) =>
  SetMetadata(RESPONSE_MESSAGE_METADATA, message);

转换拦截器

该拦截器实现了NestInterceptor>,表明它会将类型为T的处理器结果转换为封装对象。在调用处理器之前,它会从底层响应中读取当前状态码,并通过Reflector获取自定义消息,默认值为'Success'。随后,它会利用RxJS的map方法处理处理器的可观察对象。如果结果是一个同时包含data和meta字段的对象,则会被视为分页结果并拆分为相应的封装字段;其他类型的结果则直接作为data使用。

// src/common/interceptors/transform-response.interceptor.ts
import {
  Injectable,
  NestInterceptor,
  ExecutionContext,
  CallHandler,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { IResponseEntity } from '../interfaces/response.interface';
import { RESPONSE_MESSAGE_METADATA } from '../decorators/response-message.decorator';

@Injectable()
export class TransformResponseInterceptor<T>
  implements NestInterceptor<T, IResponseEntity<T>>
{
  constructor(private readonly reflector: Reflector) {}

  intercept(
    context: ExecutionContext,
    next: CallHandler,
  ): Observable<IResponseEntity<T>> {
    const http = context.switchToHttp();
    const response = http.getResponse();
    const statusCode = response.statusCode;

    // Extract custom message if set; default to 'Success'
    const customMessage =
      this.reflector.get<string>(
        RESPONSE_MESSAGE_METADATA,
        context.getHandler(),
      ) || 'Success';

    return next.handle().pipe(
      map((res) => {
        // Handle cases where service returns { data, meta } for pagination
        const hasMeta = res && typeof res === 'object' && 'meta' in res && 'data' in res;

        return {
          code: statusCode,
          status: true,
          message: customMessage,
          data: hasMeta ? res.data : res,
          meta: hasMeta ? res.meta : undefined,
        };
      }),
    );
  }
}

一些值得了解的细节:

  • reflector.get()仅从处理程序中读取元数据。如果还需要类级别的默认消息,可以使用同时包含context.getHandler()和context.getClass()的getAllAndOverride()来实现。
  • 状态会在处理程序运行之前被读取,这样就能获取默认值和@HttpCode()指定的值;但如果处理程序动态更改了状态,那么在map内部读取response.statusCode会更安全。
  • 分页检查采用鸭子类型机制,这可能导致恰好具有data和meta属性的域对象被错误地解包;使用专用类并进行instanceof检查则更为可靠。

为整个应用注册拦截器

在 main.ts 中进行全局注册后,无需在每个类上添加 @UseInterceptors() 即可将拦截器应用到所有控制器。由于该拦截器是在模块系统之外创建的,因此必须从应用程序中获取 Reflector 并手动传入:

// src/main.ts
import { NestFactory, Reflector } from '@nestjs/core';
import { AppModule } from './app.module';
import { TransformResponseInterceptor } from './common/interceptors/transform-response.interceptor';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  const reflector = app.get(Reflector);
  app.useGlobalInterceptors(new TransformResponseInterceptor(reflector));

  await app.listen(3000);
}
bootstrap();

另一种方法是在根模块的 APP_INTERCEPTOR 标识下将其注册为提供者,这样 Nest 就能通过完整的依赖注入来构建它。

重构后的控制器

现在的控制器会返回普通的实体或 { data, meta } 对象,并通过装饰器声明其处理的消息:

// src/users/users.controller.ts
import { Controller, Get, Post, Body } from '@nestjs/common';
import { UsersService } from './users.service';
import { ResponseMessage } from '../common/decorators/response-message.decorator';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  @ResponseMessage('User successfully created')
  create(@Body() dto: CreateUserDto) {
    return this.usersService.create(dto);
  }

  @Get()
  @ResponseMessage('Users retrieved successfully')
  findAll() {
    // Returns { data: [...], meta: { page: 1, limit: 10, ... } }
    return this.usersService.findAllPaginated();
  }
}

向列表端点发送请求时会得到这样的响应结构:

{
  "code": 200,
  "status": true,
  "message": "Users retrieved successfully",
  "data": [
    {
      "id": "usr_99",
      "name": "Alex Mercer",
      "email": "alex@example.com"
    }
  ],
  "meta": {
    "page": 1,
    "limit": 10,
    "totalItems": 1,
    "totalPages": 1,
    "hasNextPage": false,
    "hasPrevPage": false
  }
}

让错误具有统一的格式

拦截器的 map 只会对处理程序成功返回的值执行操作。抛出的 HttpException 实例、验证失败以及意外错误会绕过它,以 Nest 的默认错误格式直接发送给客户端。

异常过滤器可以填补这一空白。下面的示例使用了简单的 @Catch(),因此能够处理所有异常。它会从 HttpException 实例中获取状态码,否则则使用 500。同时它会提取错误信息,将数组(如 ValidationPipe 生成的列表)合并为单个字符串,最终以 status: false 和 data: null 的格式响应。

// src/common/filters/http-exception.filter.ts
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
import { Response } from 'express';

@Catch()
export class AllExceptionsFilter 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 exceptionResponse =
      exception instanceof HttpException ? exception.getResponse() : null;

    const message =
      typeof exceptionResponse === 'object' && exceptionResponse !== null
        ? (exceptionResponse as any).message || exception.toString()
        : exception instanceof Error
        ? exception.message
        : 'Internal server error';

    response.status(status).json({
      code: status,
      status: false,
      message: Array.isArray(message) ? message.join(', ') : message,
      data: null,
    });
  }
}

可通过 app.useGlobalFilters() 或 APP_FILTER 标记将其全局注册。需注意一点:对于非 HTTP 错误,该过滤器会将原始的 exception.message 返回给客户端,这可能导致内部细节泄露。在生产环境中,应记录原始错误,并为 500 错误响应发送通用消息。

如果希望采用标准错误格式而非自定义格式,可参阅RFC 9457 中的错误详情。

关键要点

  • 保持控制器结构简洁,让生命周期处理相关逻辑。
  • 使用 RxJS 的 map 方法实现通用拦截器以处理成功情况;通过元数据装饰器为不同路由定制消息。
  • 拦截器永远无法看到抛出的错误,因此应将其与全局异常过滤器配合使用,以确保两种处理路径保持相同格式。
  • 绝不要暴露原始的内部错误信息。