最近接手了一个老项目,打开 Controller 层一看,我整个人都麻了。每个路由函数里都塞满了厚重的 try-catch 块,甚至有的接口里面还套了两三层。写代码的人可能是为了稳妥,害怕服务崩溃,但这种“防御性编程”直接导致业务逻辑被淹没在大量的样板代码中。更致命的是,每个 catch 里的错误处理逻辑完全不统一:有的直接 res.status(500).json(err),有的丢个 error_code,还有的干脆把敏感的数据库报错直接吐给了前端。这种野路子写法不仅给前端联调带来了巨大的心智负担,也让线上链路追踪变成了黑盒,出了 Bug 根本无法排查。
要解决这个痛点,最直接的手段就是将异常捕获逻辑彻底从 Controller 中剥离,统一收拢到框架的错误管道中。对于 NestJS 应用,我们可以借助继承 BaseExceptionFilter 的全局拦截过滤器来实现;而对于 Express,则是通过在路由最后挂载一个带有四个入参的 Error-handling middleware。
以下是我们在 NestJS 中落地的一套生产级全局异常过滤器实现。它不仅统一了向客户端输出的 JSON 结构,还做到了根据错误类型自动降级、自动提取 HTTP 请求的上下文指标(如 Headers、Query、Body 及当前操作用户),并将其结构化上报给 Sentry,同时还对敏感的数据底层异常进行了脱敏。
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
import { Request, Response } from 'express';
import * as Sentry from '@sentry/node';
interface ErrorResponse {
success: boolean;
statusCode: number;
errorCode: string;
message: string;
timestamp: string;
path: string;
}
@Catch()
export class GlobalExceptionsFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let errorCode = 'INTERNAL_SERVER_ERROR';
let message = '服务器内部错误,请稍后再试';
if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'object' && exceptionResponse !== null) {
message = (exceptionResponse as any).message || exception.message;
errorCode = (exceptionResponse as any).errorCode || `HTTP_${status}`;
} else {
message = exception.message;
errorCode = `HTTP_${status}`;
}
} else if (exception instanceof Error) {
// 针对底层的系统错误、数据库连接错误等进行逻辑分支判定
if (exception.name === 'QueryFailedError' || exception.name === 'PrismaClientKnownRequestError') {
status = HttpStatus.BAD_REQUEST;
errorCode = 'DATABASE_QUERY_ERROR';
message = '数据操作异常,请检查输入规范';
} else {
// 未捕获的未知异常,通常为系统性崩溃级 Bug
message = exception.message;
}
}
const errorResponse: ErrorResponse = {
success: false,
statusCode: status,
errorCode,
message,
timestamp: new Date().toISOString(),
path: request.url,
};
// 核心设计:仅对 5xx 级别以及关键的 4xx 业务阻塞错误上报 Sentry,避免垃圾数据刷屏
if (status >= 500 || ['DATABASE_QUERY_ERROR', 'PAYMENT_FAILED'].includes(errorCode)) {
Sentry.withScope((scope) => {
scope.setTag('path', request.url);
scope.setTag('method', request.method);
scope.setExtra('query', request.query);
// 敏感数据注意脱敏,避免将明文密码写入日志
if (request.body && typeof request.body === 'object') {
const bodyCopy = { ...request.body };
delete bodyCopy.password;
delete bodyCopy.token;
scope.setExtra('body', bodyCopy);
}
// 如果存在鉴权中间件写入的用户信息,一同上报
if ((request as any).user) {
scope.setUser({ id: (request as any).user.id, email: (request as any).user.email });
}
Sentry.captureException(exception);
});
}
// 打印结构化日志,便于中间件侧的日志收集工具(如 Filebeat/Loki)捞取
console.error(JSON.stringify({
level: 'error',
...errorResponse,
stack: exception instanceof Error ? exception.stack : String(exception),
}));
response.status(status).json(errorResponse);
}
}
把这个过滤器通过 app.useGlobalFilters(new GlobalExceptionsFilter()) 注入全局后,Controller 里的那些 try-catch 全都可以干脆利落地干掉了。Controller 只需要关注健康的业务流,遇到不符合预期的参数或权限不足时,直接 throw new BadRequestException('无效的订单状态') 即可。
这种方案唯一的踩坑点在于 Sentry 的流量控制。高并发场景下一旦遇到数据库抖动,瞬间爆出的几万条 500 报错会瞬间击穿 Sentry 的限额。针对这种极端情况,我们目前是在网关层或者 Sentry SDK 的 beforeSend 回调中做频率限制和去重。
你们团队目前是如何处理未捕获异常的?是在框架拦截器做兜底,还是宁愿写冗余的 try-catch 来保证代码的可读性?