后端需统一拦截的异常(适用于nestjs)

后端异常分类

本章节目标:为后端系统必须应对的不同类型的错误建立一个清晰、基础的理解。这种分类至关重要,因为不同类型的错误需要不同的处理策略、日志级别和响应结构。

客户端引发的错误(4xx 系列):客户端责任原则

核心概念: 这类错误表明请求本身存在缺陷,客户端应在重试前修正请求 。服务器正确理解了请求,但在当前状态下无法或不会处理它。

详细分解:

  • 400 Bad Request (错误请求): 这是最通用的客户端错误,适用于语法格式错误、无效的消息帧或欺骗性的请求路由 。在 NestJS 中,当 DTO (数据传输对象) 结合class-validator 使用 ValidationPipe 时,框架会自动处理这类错误,这是实现健壮输入验证的关键特性 。
  • 401 Unauthorized (未授权): 客户端必须进行身份验证才能获得所请求的响应。这通常意味着凭据缺失或无效(例如,过期的 JWT)。它与403 Forbidden 有着本质区别。
  • 403 Forbidden (禁止访问): 客户端已经过身份验证,但缺乏访问目标资源的必要权限。服务器理解请求但拒绝授权 。这对于实现基于角色的访问控制 (RBAC) 是一个至关重要的区分。
  • 404 Not Found (未找到): 服务器无法找到请求的资源。这是任何 RESTful API 的基本错误类型 。
  • 409 Conflict (冲突): 由于与资源的当前状态存在冲突,请求无法完成。例如,尝试创建一个已存在的资源(如一个拥有重复电子邮件地址的用户)。
  • 其他值得注意的 4xx 状态码: 简要涵盖 405 Method Not Allowed (方法不允许)、408 Request Timeout (请求超时) 和 429 Too Many Requests (请求过多),以提供一个完整的视角 。

服务器端故障(5xx 系列):服务器问责原则

核心概念: 这类错误表明服务器未能完成一个看似有效的请求。故障在于服务器端,客户端通常可以在稍后重试相同的请求 。

详细分解:

  • 500 Internal Server Error (内部服务器错误): 这是用于意外情况的"全捕获"错误。它绝不应该被有意地抛出;它标志着代码中的 bug、未处理的异常或灾难性故障 。NestJS 的默认异常过滤器会对任何无法识别的异常生成此响应 。
  • 502 Bad Gateway (错误网关): 服务器在作为网关或代理时,从上游服务器收到了无效的响应 。这在微服务架构中很常见。
  • 503 Service Unavailable (服务不可用): 服务器由于过载或维护而暂时无法处理请求 。此状态码可以与速率限制或断路器模式结合使用,作为一种策略性响应。
  • 504 Gateway Timeout (网关超时): 服务器作为网关,未能及时从上游服务器获得响应 。

业务逻辑与领域特定异常

核心概念: 这些是应用程序中最细致、最独特的错误。它们代表了对业务规则的违反,是应用程序领域模型的关键组成部分。它们本质上不是 HTTP 错误,但必须被转换成 HTTP 错误。

  • 示例: InsufficientStockException (库存不足异常)、InvalidCouponCodeException (无效优惠券代码异常)、UserAccountLockedException (用户账户锁定异常)、DuplicateEmailException (重复邮箱异常) 。
  • 架构意义: 这些异常应该是纯粹的、与框架无关的类。它们应包含有关被违反的业务规则的丰富上下文信息。这是整洁架构 (Clean Architecture) 的基石,它将领域逻辑与基础设施关注点分离开来 。

基础设施与第三方依赖故障

核心概念: 源于应用程序所依赖的外部系统的错误。这些错误通常是不可预测的,需要健壮的处理策略,如重试、回退或断路器。

详细分解:

  • 数据库异常: 连接失败、查询超时、唯一约束冲突 (如 Prisma 中的 P2002)、外键约束失败 (如 P2003) 或记录未找到 (如 P2025) 。
  • 网络与 DNS 错误: 在与其他服务或 API 通信时发生的超时、连接重置和 DNS 解析失败 。
  • 第三方 API 错误: 外部 API 返回其自身的 4xx/5xx 错误,应用程序必须捕获并优雅地处理这些错误,可能会将它们转换为对客户端有意义的错误 。

NestJS 异常处理核心机制

目标: 提供对 NestJS 异常处理专用工具的精通级别理解,超越官方文档,解释其设计背后的"为什么"以及如何以最佳方式使用它们。

内置异常层与 HttpException

  • NestJS 的默认行为: NestJS 开箱即用地提供了一个内置的全局异常过滤器 。该过滤器负责捕获应用程序中所有未被处理的异常。
  • HttpException 的角色: 这是 NestJS 中所有标准 HTTP 异常的基类 。内置过滤器专门查找instanceof HttpException 的异常。如果一个异常不属于此类型,它将默认返回一个通用的 500 Internal Server Error 响应 。这种行为是为什么将自定义异常转换为HttpException 如此重要的根本原因。
  • 自定义 HttpException: HttpException 的构造函数不仅可以接受字符串消息和状态码,还可以接受一个复杂的对象有效负载以及一个包含 cause (原因) 和 description (描述) 的选项对象。这使得即使不使用自定义过滤器,也能创建更丰富的错误响应 。

精通异常过滤器 (ExceptionFilter<T>)

核心机制:

  • 创建一个自定义异常过滤器的"契约"或"蓝图"。任何想要成为异常过滤器的类,都必须实现 ExceptionFilter 这个接口,并提供一个名为 catch 的方法 。catch 方法就是编写具体错误处理逻辑的地方,比如记录日志、格式化返回给客户端的错误信息等。
  • @Catch(ExceptionType) 装饰器像一个"标签",贴在过滤器类上,用来告诉 NestJS:"这个过滤器专门用来捕获 HttpException 这种类型的异常(或者指定的任何其他异常类型)" 。
  • 一个空的 @Catch() 会作为"全捕获"过滤器,它会捕获所有未被其他更具体的过滤器处理的异常。这对于建立一个全局的、统一的错误处理策略至关重要,确保应用中没有任何意料之外的错误被遗漏。
  • ArgumentsHost 是一个强大的工具,用于检索底层的请求上下文,无论协议是 HTTP、WebSockets 还是 RPC。通过使用 host.switchToHttp()host.switchToWs()host.switchToRpc(),可以获取特定于协议的请求和响应对象 。这是编写通用过滤器的关键。

绑定策略及其影响:

  • 方法作用域 (@UseFilters(...) 应用于路由处理程序): 用于高度特定、一次性的错误处理。通常不推荐,倾向于更集中的方法 。
  • 控制器作用域 (@UseFilters(...) 应用于控制器类): 适用于将特定过滤器应用于单个领域控制器内的所有路由 。
  • 全局作用域 (app.useGlobalFilters(...)APP_FILTER 提供者): 这是生产级应用推荐的标准。它确保了整个 API 在错误处理和响应格式上的一致性 。

ExceptionFilterBaseExceptionFilter: implements ExceptionFilter 要求从头编写全部响应逻辑。而 extends BaseExceptionFilter 允许添加自定义逻辑(如日志记录),然后通过调用 super.catch() 将处理权交还给 Nest 的默认响应机制 。这对于增强而非完全替换默认行为非常有用。

上下文异常(超越 HTTP)

抽象化的必要性: 需要严格遵循解耦的架构原则。如果服务层正确地抛出领域异常,那么可以为不同的上下文(Context)编写不同的过滤器,将同一个领域异常转换为不同协议特定的错误消息。

WebSockets (WsException): 对于 WebSocket 网关,应抛出 WsException。Nest 的内置处理程序会将其格式化为标准的 { status: 'error', message: '...' } 事件负载 。

微服务 (RpcException): 同样,对于微服务(例如,使用 Kafka、gRPC),RpcException 是正确的选择。关键区别在于,微服务异常过滤器的 catch 方法必须返回一个 Observable

混合应用: 警告:useGlobalFilters() 默认不适用于网关或混合应用,需要单独配置 。

生产级系统的架构标准

本章节目标:提出一套规范性的、理由充分的架构原则,用于构建一个健壮、可扩展且可伸缩的异常处理系统。每条原则都将通过研究证据加以论证,并从成熟的软件工程最佳实践角度进行解释。

集中化与一致性原则

  • 论点: 所有与错误处理相关的横切关注点------响应格式化、日志记录和监控钩子------必须集中在一个单一的、全局的异常过滤器中。
  • 论证: 这种方法可以防止代码重复,并确保为 API 消费者提供一致的用户体验。对错误格式的任何更改都只需在一个地方进行 。它使系统更易于理解和维护。
  • 实现: 提倡在根 AppModule 中使用 APP_FILTER 提供者令牌。这种方式优于 app.useGlobalFilters(new...),因为它允许过滤器参与 Nest 的依赖注入系统,从而能够注入像 LoggerService 这样的服务 。

领域完整性原则(解耦业务逻辑)

  • 论点: 服务/领域层必须保持纯净,并与传输层(HTTP)解耦。因此,服务层绝不 应该抛出 HttpException。它们必须抛出自定义的、领域特定的异常。

  • 论证:

    • 可重用性: 服务中的业务逻辑可以在不同上下文中(REST API、GraphQL、WebSockets、CLI 工具、微服务)重用,无需修改 。
    • 可测试性: 单元测试一个抛出 UserNotFoundException 的服务方法,比测试一个抛出带有特定状态码和消息的 HttpException 的方法要容易得多。前者(测试代码只关心业务结果,完全不涉及 HTTP 协议的实现细节。)测试业务逻辑;后者(测试代码被迫与 HTTP 协议的细节(状态码、消息格式)紧密耦合)测试特定协议的实现细节 。
    • 关注点分离: 控制器(或过滤器)的职责是处理传输层。将领域异常转换为 HTTP 响应是传输层的关注点。
  • "转换"模式: 全局异常过滤器成为"转换层"。它捕获来自服务层的 UserNotFoundException,并将其转换为一个 new NotFoundException('User not found') 或一个标准化的 404 响应。

可扩展性与可维护性原则

  • 论点: 创建一个自定义的、分层的异常类体系,以结构化和类型安全的方式对领域错误进行建模。

  • 实现策略:

    • 创建基础 AppException: 这个类应该继承自 Nest 的内置 HttpException。它可以为所有业务错误强制执行一个通用结构,例如包含一个强制性的内部 errorCode 和可选的 details 负载 。
    • 创建具体的业务异常: 继承 AppException 基类来定义具体的业务错误。例如,
    scala 复制代码
    class ResourceNotFoundException extends AppException {
        constructor(resource: string, id: string) {
            super(`Resource 'resource′withid′{id}' not found`, 404, 'E_RESOURCE_NOT_FOUND');
        }
    }
    • 优势: 这种方法使全局过滤器变得异常简单而强大。过滤器可以仅用一个 if (exception instanceof AppException) 块来一致地处理所有自定义业务异常,而无需为每种可能的错误类型编写一个庞大的 switch 语句。添加一个新的业务错误不需要修改过滤器,这遵循了开闭原则。

信息丰富且安全的响应原则

  • 论点: 采用一个标准化的、行业认可的 JSON 错误响应模式,为 API 消费者提供清晰、可操作且一致的错误详情。推荐的标准是 RFC 9457 (Problem Details for HTTP APIs) 。

  • 为什么选择 RFC 9457? 这是一个正式标准,解决了在错误格式设计上"吹毛求疵"的问题 。它提供了一个客户端可以可靠解析的、可预测的结构。

  • 模式定义: 定义关键字段:

    • type (string, URI): 特定错误类型的唯一标识符,可以链接到相关文档。示例:"example.com/probs/out-o..."
    • title (string): 对问题的简短、人类可读的摘要。示例:"You do not have enough credit."
    • status (number): HTTP 状态码。示例:403
    • detail (string): 针对此特定错误事件的人类可读的解释。示例:"Your current balance is 30, but that costs 50."
    • instance (string, URI): 标识此问题具体发生位置的 URI(例如,请求路径)。示例:"/account/12345/msgs/abc"
  • 安全性: 强调详细的错误消息 (detail) 必须经过精心设计。在生产环境中,绝不能在响应体中泄露内部实现细节,如堆栈跟踪或原始数据库错误消息 。这些信息应该被记录下来,而不是暴露给外部。

全面可观测性原则

  • 论点: 没有健壮的日志记录,异常处理就是不完整的。全局过滤器捕获的每一个异常都必须被记录下来,并附带足够的上下文,以便能够快速调试和监控。

  • 记录内容:

    • 时间戳: 错误发生的时间。
    • 请求上下文: 请求 ID (对于链路追踪至关重要)、HTTP 方法、URL、客户端 IP 地址。
    • 用户上下文 (如果可用): 用户 ID、角色。
    • 错误详情: 异常消息、来自自定义异常层次结构的唯一 errorCode,以及对于意外错误,完整的堆栈跟踪。
    • 请求负载: 请求体和查询参数(注意要清除密码或令牌等敏感数据)。
  • 实现: 这可以通过创建一个专用的 LoggerService (可以包装像 Pino 或 Winston 这样的库) 并将其注入到全局异常过滤器中来实现 。APP_FILTER 的强大之处在此处可以展现。

实施蓝图

1. 构建全局异常过滤器 (AllExceptionsFilter)

  1. 文件结构示例:将过滤器放置在共享模块中,例如 src/common/filters/all-exceptions.filter.ts

  2. 核心类与依赖注入:创建 AllExceptionsFilter 类,它实现 ExceptionFilter 接口,并使用空的 @Catch() 装饰器来捕获所有类型的异常。通过构造函数注入 HttpAdapterHost 和自定义的 LoggerService,以实现框架无关的响应发送和结构化日志记录。

    typescript 复制代码
    // src/common/filters/all-exceptions.filter.ts
    import {
      ExceptionFilter,
      Catch,
      ArgumentsHost,
      HttpException,
      HttpStatus,
    } from '@nestjs/common';
    import { HttpAdapterHost } from '@nestjs/core';
    import { LoggerService } from '../logger/logger.service'; // 假设的日志服务
    import { AppException } from '../exceptions/app.exception'; // 自定义基础异常
    
    @Catch()
    export class AllExceptionsFilter implements ExceptionFilter {
      constructor(
        private readonly httpAdapterHost: HttpAdapterHost,
        private readonly logger: LoggerService,
      ) {}
    
      catch(exception: unknown, host: ArgumentsHost): void {
        const { httpAdapter } = this.httpAdapterHost;
        const ctx = host.switchToHttp();
        const request = ctx.getRequest();
        const response = ctx.getResponse();
    
    const httpStatus =
      exception instanceof HttpException
       ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;
    
    let responseBody: any;
    
    if (exception instanceof AppException) {
      // 处理我们自定义的业务异常
      const errorResponse = exception.getResponse();
      responseBody = {
        type: `https://your-api.com/errors/${exception.getErrorCode()}`,
        title: typeof errorResponse === 'string'? errorResponse : errorResponse['message'],
        status: httpStatus,
        detail: exception.getDetail(),
        instance: httpAdapter.getRequestUrl(request),
      };
    } else if (exception instanceof HttpException) {
      // 处理标准的 NestJS HTTP 异常
      const errorResponse = exception.getResponse();
      responseBody = {
        type: 'about:blank',
        title: typeof errorResponse === 'string'? errorResponse : errorResponse['error'] |  'Http Exception', status: httpStatus,
        detail: typeof errorResponse === 'string'? errorResponse : errorResponse['message'],
        instance: httpAdapter.getRequestUrl(request),
        };
        } else {
        // 处理未知的、意外的异常
        responseBody = {
        type: 'about:blank',
        title: 'Internal Server Error',
        status: HttpStatus.INTERNAL_SERVER_ERROR,
        detail: 'An unexpected error occurred. Please try again later.',
        instance: httpAdapter.getRequestUrl(request),
        };
        }  // 记录日志
        this.logger.error(
          `[Exception] ${request.method} ${request.url}`,
          {
            request: {
              headers: request.headers,
              body: request.body,
              query: request.query,
              params: request.params,
            },
            response: responseBody,
            stack: exception instanceof Error? exception.stack : JSON.stringify(exception),
          }
        );
    
        httpAdapter.reply(response, responseBody, httpStatus);
      }
    }
  3. 注册过滤器:在 app.module.ts 中,使用 APP_FILTER 令牌将过滤器注册为全局提供者。这确保了它能够利用依赖注入。

    typescript 复制代码
    // src/app.module.ts
    import { Module } from '@nestjs/common';
    import { APP_FILTER } from '@nestjs/core';
    import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
    import { LoggerModule } from './common/logger/logger.module'; // 假设的日志模块
    
    @Module({
      imports: [LoggerModule],
      providers:,
    })
    export class AppModule {}

2. 设计自定义异常层次结构

遵循可扩展性原则,我们创建一个分层的异常类体系。

  • 基础异常类 (app.exception.ts): 这个基类继承自 HttpException,并为所有业务异常定义了一个共同的结构。

    typescript 复制代码
    // src/common/exceptions/app.exception.ts
    import { HttpException, HttpStatus } from '@nestjs/common';
    
    export class AppException extends HttpException {
      private readonly errorCode: string;
      private readonly detail?: any;
    
      constructor(message: string, status: HttpStatus, errorCode: string, detail?: any) {
        super(message, status);
        this.errorCode = errorCode;
        this.detail = detail;
      }
    
      getErrorCode(): string {
        return this.errorCode;
      }
    
      getDetail(): any {
        return this.detail;
      }
    }
  • 具体实现: 现在,可以创建具体的业务异常,它们继承自 AppException

    typescript 复制代码
    // src/common/exceptions/resource-not-found.exception.ts
    import { HttpStatus } from '@nestjs/common';
    import { AppException } from './app.exception';
    
    export class ResourceNotFoundException extends AppException {
      constructor(resource: string, identifier: string | number) {
        super(
          `${resource} with identifier '${identifier}' not found.`,
          HttpStatus.NOT_FOUND,
          'E_RESOURCE_NOT_FOUND',
          { resource, identifier }
        );
      }
    }
    // src/common/exceptions/duplicate-resource.exception.ts
    import { HttpStatus } from '@nestjs/common';
    import { AppException } from './app.exception';
    
    export class DuplicateResourceException extends AppException {
      constructor(resource: string, field: string, value: any) {
        super(
          `${resource} with ${field} '${value}' already exists.`,
          HttpStatus.CONFLICT,
          'E_DUPLICATE_RESOURCE',
          { resource, field, value }
        );
      }
    }
  • 在服务中使用: 在服务层中,可以直接抛出这些具有丰富语义的自定义异常。

    typescript 复制代码
    // src/users/users.service.ts
    import { Injectable } from '@nestjs/common';
    import { ResourceNotFoundException } from '../common/exceptions/resource-not-found.exception';
    
    @Injectable()
    export class UsersService {
      async findOne(id: string): Promise<User> {
        const user = await this.userRepository.findOne(id);
        if (!user) {
          throw new ResourceNotFoundException('User', id);
        }
        return user;
      }
    }

3. 处理特定的第三方异常

全局过滤器还必须能够识别并转换来自第三方库(如 ORM)的特定错误。

  • Prisma 示例: 在全局过滤器中,可以添加逻辑来捕获 Prisma.PrismaClientKnownRequestError 并根据其错误代码进行转换。

    typescript 复制代码
    // 在 AllExceptionsFilter 的 catch 方法中
    import { Prisma } from '@prisma/client';
    import { DuplicateResourceException } from '../common/exceptions/duplicate-resource.exception';
    import { ResourceNotFoundException } from '../common/exceptions/resource-not-found.exception';
    
    //...
    if (exception instanceof Prisma.PrismaClientKnownRequestError) {
      let appException: AppException;
      switch (exception.code) {
        case 'P2002': // Unique constraint failed
          const target = exception.meta?.target as string;
          appException = new DuplicateResourceException(
            'Resource', // 可以从 meta 中解析出更具体的模型名
            target? target.join(', ') : 'field',
            'value' // 同样可以解析
          );
          break;
        case 'P2025': // Record not found
          appException = new ResourceNotFoundException(
            'Resource',
            'identifier'
          );
          break;
        default:
          // 对于其他 Prisma 错误,可以映射为通用的数据库错误
          appException = new AppException(
            'A database error occurred.',
            HttpStatus.INTERNAL_SERVER_ERROR,
            'E_DATABASE_ERROR',
            exception.message
          );
      }
      // 现在,将原始异常替换为我们自己的 AppEcception,让后续逻辑处理
      exception = appException; 
    }
    //... 后续的 if (exception instanceof AppException) 逻辑将处理它
  • class-validator 示例:ValidationPipe 失败时,它会抛出一个 BadRequestException,其响应体包含一个详细的 message 数组。过滤器可以识别这一点并将其格式化为 invalid-params

    typescript 复制代码
    // 在 AllExceptionsFilter 的 catch 方法中
    //...
    } else if (exception instanceof HttpException) {
      const errorResponse = exception.getResponse();
      const isValidationError = 
        exception.getStatus() === HttpStatus.BAD_REQUEST && 
        typeof errorResponse === 'object' && 
        Array.isArray(errorResponse['message']);
    
      if (isValidationError) {
        responseBody = {
          type: 'https://your-api.com/errors/validation-error',
          title: 'Validation Failed',
          status: HttpStatus.BAD_REQUEST,
          detail: 'One or more fields failed validation.',
          instance: httpAdapter.getRequestUrl(request),
          'invalid-params': errorResponse['message'].map(err => ({
            name: err.property,
            reason: Object.values(err.constraints).join(', '),
          })),
        };
      } else {
        //... 处理其他 HttpException
      }
    }
    //...
  • 条件化过滤: 对于更复杂的场景,当单个第三方错误类代表多个逻辑错误时,可以探索更高级的技术。例如,nestjs-conditional-exception-filter 包或利用 JavaScript 的 Symbol.hasInstance 来自定义匹配逻辑,可以实现更细粒度的过滤。这为未来的扩展提供了路径。

相关推荐
IT_陈寒5 小时前
Vue 3.4性能优化实战:这5个技巧让我的应用加载速度提升了300%!🚀
前端·人工智能·后端
QYR行业咨询5 小时前
2025-2031全球与中国隧道照明灯具市场现状及未来发展趋势
前端·后端
Java微观世界3 天前
别再混淆!Java抽象类与接口的终极对比(语法+设计+Java 8+新特性)
后端
沉默王二3 天前
金山还是小米,谁才是雷军的亲儿子?附小米线下一面面经(八股盛宴)
后端·面试
Aurora_NeAr3 天前
Kubernetes权威指南-原理篇
后端·云原生
Java水解3 天前
MySQL 新增字段但 Java 实体未更新:潜在问题与解决方案
后端·mysql
Java水解3 天前
Kafka事务:构建可靠的分布式消息处理系统
后端·kafka
京茶吉鹿3 天前
三步构建完美树节点,从此告别数据结构焦虑!
java·后端
ZZHHWW3 天前
高可用架构实战指南:告别半夜被叫醒的噩梦
后端·架构