后端异常分类
本章节目标:为后端系统必须应对的不同类型的错误建立一个清晰、基础的理解。这种分类至关重要,因为不同类型的错误需要不同的处理策略、日志级别和响应结构。
客户端引发的错误(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 在错误处理和响应格式上的一致性 。
ExceptionFilter
与 BaseExceptionFilter
: 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
基类来定义具体的业务错误。例如,
scalaclass 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 状态码。示例:403detail
(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
)
-
文件结构示例:将过滤器放置在共享模块中,例如
src/common/filters/all-exceptions.filter.ts
。 -
核心类与依赖注入:创建
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); } }
-
注册过滤器:在
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
来自定义匹配逻辑,可以实现更细粒度的过滤。这为未来的扩展提供了路径。