每个 NestJS 路由一个 JSON 封装:拦截器与全局过滤器
通过通用拦截器、消息装饰器和异常过滤器,将每个 NestJS 响应封装在类型化的对象中,同时让控制器无需编写冗余代码。
当每个接口端点返回的格式略有不同时,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方法实现通用拦截器以处理成功情况;通过元数据装饰器为不同路由定制消息。