后端需统一拦截的异常(适用于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 来自定义匹配逻辑,可以实现更细粒度的过滤。这为未来的扩展提供了路径。

相关推荐
逸风尊者8 分钟前
开发可掌握的知识:推荐系统
java·后端·算法
Violet_YSWY12 分钟前
阿里巴巴状态码
后端
灵魂猎手17 分钟前
Antrl4 入门 —— 使用Antrl4实现一个表达式计算器
java·后端
moxiaoran575327 分钟前
Go语言的递归函数
开发语言·后端·golang
IT 行者1 小时前
Spring Security 7.0 新特性详解
java·后端·spring
华仔啊1 小时前
Java 的金额计算用 long 还是 BigDecimal?资深程序员这样选
java·后端
12344521 小时前
【MCP入门篇】从0到1教你搭建MCP服务
后端·mcp
okseekw1 小时前
Java多线程开发实战:解锁线程安全与性能优化的关键技术
java·后端
HuangYongbiao1 小时前
NestJS 架构设计系列:应用服务与领域服务的区别
后端·架构