Skip to content

.NET 全局异常处理实践 ​

本文关注的是 .NET Web API 场景里最常见的一类问题:如何把散乱的异常处理收束到统一入口,让接口返回一致、日志可追踪、用户看到的错误信息可控,同时又不丢失开发排障所需的细节。

1. 概述 ​

很多项目一开始都有异常处理,但随着业务扩张,常见问题会越来越明显:

  • 控制器里到处 try/catch,风格不统一。
  • 业务异常和系统异常混在一起,返回码和消息全靠手写。
  • 前端拿到的错误格式不一致,调用方很难统一处理。
  • 开发环境和生产环境返回同样的报错细节,既不安全,也不利于排查。
  • 日志里只看到一句“操作失败”,却不知道异常属于哪一类、发生在哪一步。

更稳妥的做法通常是:

  1. 把未处理异常统一交给全局入口。
  2. 用自定义异常承载业务语义。
  3. 统一接口错误响应格式。
  4. 按异常类型和环境做差异化处理。

这样做的目标不是“把所有错误都包装得很漂亮”,而是让异常处理从临时补丁变成一套长期可维护的机制。

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"
}

统一响应模型的好处主要有三个:

  1. 前端更容易统一拦截和展示。
  2. 日志中更容易直接记录结构化结果。
  3. 后续做国际化、追踪 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 --> G

4. 使用示例 ​

异常处理机制好不好用,最终还是要看业务代码写起来顺不顺手。

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. 总结 ​

一套好的异常处理机制,至少要同时满足三件事:调用方能拿到一致的错误响应,开发者能在日志里快速定位问题,生产环境不会把不该暴露的细节直接返回给用户。少了其中任何一项,这套机制都很容易在真实项目里失去价值。

如果只记一句话,可以记成:

业务异常要讲语义,系统异常要讲证据,统一入口负责把两者稳稳接住。

方舟 · Ark