Appearance
.NET 全局异常处理实践
本文关注的是 .NET Web API 场景里最常见的一类问题:如何把散乱的异常处理收束到统一入口,让接口返回一致、日志可追踪、用户看到的错误信息可控,同时又不丢失开发排障所需的细节。
1. 概述
很多项目一开始都有异常处理,但随着业务扩张,常见问题会越来越明显:
- 控制器里到处
try/catch,风格不统一。 - 业务异常和系统异常混在一起,返回码和消息全靠手写。
- 前端拿到的错误格式不一致,调用方很难统一处理。
- 开发环境和生产环境返回同样的报错细节,既不安全,也不利于排查。
- 日志里只看到一句“操作失败”,却不知道异常属于哪一类、发生在哪一步。
更稳妥的做法通常是:
- 把未处理异常统一交给全局入口。
- 用自定义异常承载业务语义。
- 统一接口错误响应格式。
- 按异常类型和环境做差异化处理。
这样做的目标不是“把所有错误都包装得很漂亮”,而是让异常处理从临时补丁变成一套长期可维护的机制。
2. 核心组件
一个较完整的异常处理体系,通常至少包括下面四部分。
2.1 全局异常过滤器或中间件
这是整套机制的总入口,负责兜底捕获没有被业务代码处理掉的异常。
在 ASP.NET Core 中,常见做法有两种:
- 过滤器:适合 MVC / Web API 风格项目。
- 中间件:适合统一拦截整个请求管线。
它承担的职责通常包括:
- 捕获未处理异常
- 区分业务异常与系统异常
- 记录日志
- 组装统一响应
- 根据环境控制错误细节的暴露范围
一个典型的处理骨架如下:
csharp
public void OnException(ExceptionContext context)
{
// 1. 先识别业务异常
if (context.Exception is ResponseException businessException)
{
// 记录业务异常日志
// 返回统一格式的业务失败响应
return;
}
// 2. 再处理系统异常
// 记录详细异常日志
// 根据环境决定返回详细信息还是友好提示
}2.2 自定义业务异常类
如果所有错误都直接抛 Exception,全局层很难判断哪些是:
- 业务规则失败
- 参数校验失败
- 权限问题
- 资源不存在
- 真正的系统异常
所以更推荐定义一个业务异常类,例如 ResponseException,让业务层在抛错时就明确说明:
- 错误消息
- 业务错误码
- HTTP 状态码
这样全局处理层就能更稳定地识别“这是一种预期内失败”,而不是把所有失败都当成 500。
一个常见形态如下:
csharp
public class ResponseException : ApplicationException
{
public ErrorCode ErrorCode { get; }
public int HttpStatusCode { get; }
public ResponseException(string message, ErrorCode errorCode, int httpStatusCode)
: base(message)
{
ErrorCode = errorCode;
HttpStatusCode = httpStatusCode;
}
}2.3 统一错误码枚举
统一错误码的价值在于:
- 前后端对错误类型有稳定共识
- 前端可以根据错误码做分支处理
- 日志统计和监控更容易归类
一组常见的错误码通常会覆盖这些场景:
csharp
public enum ErrorCode
{
Success = 200,
Fail = 400,
AuthFailed = 401,
TokenExpired = 403,
NotFound = 404,
Inoperable = 405,
NoPermission = 407,
RepeatField = 416,
ParamError = 422,
Error = 500,
ManyRequests = 503
}是否采用这组值本身不是重点,重点是:
- 错误码要统一管理
- 语义要稳定
- 不要在控制器里到处硬编码魔法数字
2.4 统一响应模型
接口报错时,最好保证返回结构稳定,而不是有时返回字符串,有时返回匿名对象,有时直接抛出 HTML 错误页。
一种简单实用的结构通常类似:
json
{
"code": 404,
"message": "资源不存在",
"request": "GET /api/users/999"
}统一响应模型的好处主要有三个:
- 前端更容易统一拦截和展示。
- 日志中更容易直接记录结构化结果。
- 后续做国际化、追踪 ID、诊断信息扩展时更容易演进。
3. 异常处理流程
在实践里,异常处理至少要区分两条主流程:业务异常和系统异常。
3.1 业务异常处理流程
业务异常是“预期内失败”,常见于:
- 参数不合法
- 数据不存在
- 权限不足
- 状态不允许
- 字段重复
这种异常的重点不是堆栈,而是清晰告诉调用方“为什么失败”。
mermaid
graph TD
A[业务代码抛出自定义异常] --> B[全局异常入口捕获]
B --> C[提取业务错误码和消息]
C --> D[记录 Warning 或 Information 日志]
D --> E[返回统一 JSON 响应]
E --> F[设置对应 HTTP 状态码]3.2 系统异常处理流程
系统异常是“非预期失败”,例如:
- 空引用
- 数据库连接失败
- 外部服务超时
- 文件读写失败
这类异常的重点是:
- 对内要保留足够的排障信息
- 对外不要暴露敏感实现细节
mermaid
graph TD
A[系统抛出未处理异常] --> B[全局异常入口捕获]
B --> C[记录完整异常堆栈]
C --> D{当前环境}
D -->|开发环境| E[返回较详细的错误信息]
D -->|生产环境| F[返回友好提示]
E --> G[统一 JSON 响应]
F --> G4. 使用示例
异常处理机制好不好用,最终还是要看业务代码写起来顺不顺手。
4.1 抛出业务异常
csharp
// 资源不存在
throw new ResponseException("用户不存在", ErrorCode.NotFound, 404);
// 参数错误
throw new ResponseException("参数格式错误", ErrorCode.ParamError, 422);
// 业务失败
throw new ResponseException("操作失败", ErrorCode.Fail, 400);这种写法的好处是,控制器和服务层不用再重复组装错误响应,只需要明确表达“失败的业务语义”。
4.2 控制器中的典型写法
csharp
[HttpGet("{id}")]
public async Task<ResponseMessage> GetUser(int id)
{
var user = await _userService.GetByIdAsync(id);
if (user == null)
{
throw new ResponseException("用户不存在", ErrorCode.NotFound, 404);
}
return ResponseMessage.Success(user);
}4.3 服务层中的典型写法
csharp
public async Task<User> CreateUserAsync(CreateUserDto dto)
{
if (await _repository.ExistsAsync(x => x.UserName == dto.UserName))
{
throw new ResponseException("用户名已存在", ErrorCode.RepeatField, 416);
}
// 其他业务逻辑
return user;
}服务层这样抛异常后,控制器层可以少写很多重复判断,异常语义也更集中。
5. 日志记录策略
异常处理机制如果只负责“返回错误”,却不关心“怎么记录”,最终还是很难排障。
5.1 业务异常日志
业务异常通常更适合记录为 Warning,或者记录为团队约定的业务失败级别。它的重点不是保存完整堆栈,而是把“哪个请求失败了、触发了哪类业务规则、最终返回了什么业务错误码”讲清楚。换句话说,业务异常日志更偏向业务可读性,方便研发、测试和前端快速判断这次失败是不是预期内结果。
5.2 系统异常日志
系统异常通常应记录为 Error 或 Critical。这类日志的重点不是“给用户看懂”,而是给开发和运维留下足够的排障证据,因此应尽量保留异常消息、堆栈、请求上下文、TraceId / RequestId 以及关键业务标识。只要这些信息缺一块,后续定位问题时就很容易从“能排查”退回到“只能猜”。
5.3 一条重要边界
不要把“记录异常日志”理解为“把所有异常都写成同一种格式”。
更合理的做法是:
- 业务异常:偏业务可读
- 系统异常:偏排障可读
这样日志平台里才能清楚区分“可预期失败”和“系统真的出问题了”。
6. 配置与注册
6.1 注册全局异常处理
过滤器方式的一个常见写法如下:
csharp
builder.Services.AddControllers(options =>
{
options.Filters.Add(typeof(GlobalExceptionFilter));
});如果使用中间件,则通常会在请求管线里尽早注册,确保后续异常都能统一进入该中间件。
6.2 模型验证异常统一处理
模型绑定失败如果不统一收口,前端也会看到不一致的错误结构。
典型处理方式如下:
csharp
builder.Services.Configure<ApiBehaviorOptions>(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var problemDetails = new ValidationProblemDetails(context.ModelState);
var result = new ResponseMessage(ErrorCode.ParamError, problemDetails.Errors.Values, context.HttpContext);
return new BadRequestObjectResult(result);
};
});这样可以把框架层的模型验证错误,也统一到同一套响应模型中。
7. 设计上的优点与边界
7.1 这套机制的优点
如果设计得当,这种异常处理机制的优点会比较集中。首先,接口返回结构能够长期保持统一,前端和调用方更容易做统一拦截;其次,业务异常与系统异常的职责边界更清楚,日志分级也更稳定,不会把所有失败都混成一类。再往后看,环境差异化控制、追踪 ID 扩展、国际化支持以及异常统计接入,都会比“每层各写一套 try/catch”更顺畅。
7.2 这套机制也有边界
异常处理机制不是万能层,权限认证逻辑、参数验证规则本身、业务补偿与重试策略、分布式事务、外部依赖熔断这些问题,都不应该一股脑压到全局异常处理里解决。它更适合做“统一收口”,把已经发生的失败用一致方式接住,而不是代替业务设计本身。
8. 常见改进方向
一套已经能用的异常处理机制,后续通常会继续往下面几个方向演进。
8.1 增加异常分类
csharp
public enum ExceptionCategory
{
Business,
Validation,
System,
External
}这样可以让日志、监控和统计更细分。
8.2 增加追踪标识
csharp
public class ResponseMessage
{
public string TraceId { get; set; } = Guid.NewGuid().ToString();
}如果已经有请求链路追踪体系,最好直接复用请求级 TraceId / RequestId,而不是每次重新生成无关 ID。
8.3 增加异常统计能力
csharp
public class ExceptionMetrics
{
public void RecordException(ErrorCode errorCode, string controller, string action) { }
public ExceptionStatistics GetStatistics() => new ExceptionStatistics();
}当项目规模增长后,异常处理往往会和监控系统结合起来,重点不是“多记几条日志”,而是回答更具体的问题,比如哪类错误最多、哪个接口最容易失败、哪个时间段异常激增。只有把异常从单次报错提升到统计视角,异常治理这件事才真正开始有管理价值。
9. 总结
一套好的异常处理机制,至少要同时满足三件事:调用方能拿到一致的错误响应,开发者能在日志里快速定位问题,生产环境不会把不该暴露的细节直接返回给用户。少了其中任何一项,这套机制都很容易在真实项目里失去价值。
如果只记一句话,可以记成:
业务异常要讲语义,系统异常要讲证据,统一入口负责把两者稳稳接住。
