ASP.NET Core 统一异常处理与统一返回

系统讲解 ASP.NET Core 中的统一异常处理、状态码设计、统一响应结构与常见接口错误治理方式。

ASP.NET Core 统一异常处理与统一返回

很多初学者刚写 Web API 时,只要接口能返回 JSON 就觉得差不多了。但一进入真实项目,很快就会发现两个问题必须统一处理:

  • 出错时到底返回什么格式?
  • 异常应该在哪里拦,怎么记录,怎么给前端看?

如果这两件事没有统一规范,项目很快就会出现:

  • 有的接口返回字符串
  • 有的接口返回匿名对象
  • 有的接口抛原始异常
  • 有的接口吞异常后返回 200

这会让前后端协作和线上排障都非常痛苦。


第一章 为什么必须统一异常处理

统一异常处理的目标不是“把所有错误都包起来”,而是为了:

  • 统一接口错误响应格式
  • 正确返回状态码
  • 集中记录日志
  • 降低控制器重复代码

最常见的错误写法是把每个控制器方法都写成这样:

try
{
    // 业务逻辑
}
catch (Exception ex)
{
    return BadRequest(ex.Message);
}

这种做法的问题包括:

  • 大量重复
  • 状态码容易乱
  • 日志不统一
  • 控制器变得臃肿

第二章 Web API 中常见的错误类型

一个接口的错误,通常来自以下几类:

2.1 参数错误

例如:

  • 必填参数缺失
  • 参数格式不合法
  • 请求体字段校验失败

2.2 业务错误

例如:

  • 用户不存在
  • 库存不足
  • 订单状态不允许取消

2.3 权限错误

例如:

  • 未登录
  • Token 失效
  • 当前用户无权限

2.4 系统异常

例如:

  • 数据库异常
  • 第三方接口异常
  • 未处理空引用

不同类型的错误,不应该一律返回同一种状态码和同一种处理方式。


第三章 统一返回结构为什么有价值

很多团队会统一接口响应格式,例如:

{
  "code": 0,
  "message": "success",
  "data": {}
}

错误时:

{
  "code": 40001,
  "message": "参数校验失败",
  "data": null
}

这种结构的价值在于:

  • 前端处理更稳定
  • 错误码可以标准化
  • 可扩展性更强

第四章 一个简单的统一响应模型

4.1 定义返回对象

public class ApiResponse<T>
{
    public int Code { get; set; }
    public string Message { get; set; } = string.Empty;
    public T? Data { get; set; }

    public static ApiResponse<T> Success(T data, string message = "success")
    {
        return new ApiResponse<T>
        {
            Code = 0,
            Message = message,
            Data = data
        };
    }

    public static ApiResponse<T> Fail(int code, string message)
    {
        return new ApiResponse<T>
        {
            Code = code,
            Message = message,
            Data = default
        };
    }
}

4.2 控制器使用示例

[HttpGet("{id}")]
public IActionResult GetById(int id)
{
    var user = new { Id = id, Name = "Tom" };
    return Ok(ApiResponse<object>.Success(user));
}

第五章 状态码应该怎么选

统一返回结构并不意味着可以忽略 HTTP 状态码。

常见理解如下:

  • 200 OK:请求成功
  • 400 BadRequest:请求格式或参数有问题
  • 401 Unauthorized:认证失败
  • 403 Forbidden:认证成功但没有权限
  • 404 NotFound:资源不存在
  • 500 InternalServerError:服务端未处理异常

5.1 一个常见误区

有些团队喜欢所有接口都返回 200,然后只靠响应体里的 code 区分错误。这样虽然前端处理统一了,但会削弱 HTTP 协议本身的语义。

更好的方式通常是:

  • 保留合理的 HTTP 状态码
  • 同时返回统一结构的错误体

第六章 参数校验和模型校验

如果你使用 [ApiController],那么模型校验会更顺手。

6.1 请求对象

using System.ComponentModel.DataAnnotations;

public class CreateUserRequest
{
    [Required]
    [MaxLength(50)]
    public string UserName { get; set; } = string.Empty;

    [Required]
    [EmailAddress]
    public string Email { get; set; } = string.Empty;
}

6.2 控制器

[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    [HttpPost]
    public IActionResult Create(CreateUserRequest request)
    {
        return Ok(ApiResponse<object>.Success(new { request.UserName, request.Email }));
    }
}

如果模型校验失败,框架会自动给出校验错误响应。很多团队还会继续把这部分输出改造成统一格式。


第七章 最推荐的统一异常处理方式:中间件

相比在每个控制器 try-catch,更推荐在中间件里统一捕获未处理异常。

7.1 一个基础异常处理中间件

using System.Text.Json;

public class ExceptionHandlingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<ExceptionHandlingMiddleware> _logger;

    public ExceptionHandlingMiddleware(
        RequestDelegate next,
        ILogger<ExceptionHandlingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "请求处理发生未捕获异常");

            context.Response.StatusCode = StatusCodes.Status500InternalServerError;
            context.Response.ContentType = "application/json";

            var response = ApiResponse<object>.Fail(50000, "服务器内部错误");
            var json = JsonSerializer.Serialize(response);
            await context.Response.WriteAsync(json);
        }
    }
}

7.2 注册中间件

app.UseMiddleware<ExceptionHandlingMiddleware>();

通常它应该放得比较靠前,这样后面的异常尽量都能被接住。


第八章 业务异常和系统异常要分开看

不是所有异常都应该统一当作 500

8.1 系统异常

例如:

  • 空引用
  • 数据库连接失败
  • 第三方接口崩溃

这类通常确实属于 500

8.2 业务异常

例如:

  • 用户余额不足
  • 订单已关闭
  • 当前账号不能重复注册

这类通常更适合映射为:

  • 400
  • 409
  • 或者其他明确的业务错误返回

8.3 一个简单的业务异常类

public class BusinessException : Exception
{
    public int Code { get; }

    public BusinessException(int code, string message) : base(message)
    {
        Code = code;
    }
}

中间件中区分处理:

catch (BusinessException ex)
{
    context.Response.StatusCode = StatusCodes.Status400BadRequest;
    context.Response.ContentType = "application/json";

    var response = ApiResponse<object>.Fail(ex.Code, ex.Message);
    var json = JsonSerializer.Serialize(response);
    await context.Response.WriteAsync(json);
}

第九章 控制器里什么时候还需要 try-catch

统一中间件并不意味着控制器里永远不能写 try-catch

仍然可能需要的场景:

  • 你要对某类已知异常做特殊兜底
  • 你想补充上下文信息再抛出
  • 你要做失败后的降级逻辑

但大原则仍然是:

  • 不要把通用异常处理分散到每个接口里

第十章 常见错误码设计思路

错误码不一定非要非常复杂,但最好有基本分层。

例如:

  • 0:成功
  • 40001:参数错误
  • 40002:业务校验失败
  • 40101:未登录或 Token 无效
  • 40301:无权限访问
  • 40401:资源不存在
  • 50000:服务器内部错误

核心目标不是“编码多漂亮”,而是团队能长期稳定维护。


第十一章 日志与异常要怎么配合

统一异常处理中,日志不是附属品,而是关键部分。

11.1 建议记录什么

  • 请求路径
  • 请求方法
  • 关键业务标识
  • 异常消息
  • 异常堆栈

11.2 不建议记录什么

  • 明文密码
  • 完整敏感身份信息
  • 大量无关原始请求体

日志的目标是“能排错”,不是“把所有数据一股脑记下来”。


第十二章 一个更接近真实项目的返回策略

通常可以这样约定:

成功响应

  • 200 + code = 0

参数和业务错误

  • 400 / 409 + 明确业务 code

认证授权错误

  • 401 / 403 + 对应错误 code

未处理异常

  • 500 + 统一兜底错误信息

这样前端既能使用 HTTP 状态码,也能使用业务错误码。


第十三章 初学者最容易踩的坑

13.1 把所有异常都转成 200

这会让调用方误判接口成功。

13.2 在每个控制器里复制一堆 try-catch

维护成本很高。

13.3 直接把原始异常返回给前端

容易暴露内部实现信息。

13.4 吞异常不记录日志

后面排查几乎无从下手。

13.5 业务错误和系统异常不区分

这会让状态码和错误码体系越来越混乱。


第十四章 入门阶段建议掌握到什么程度

如果你正在做 .NET Web API,建议至少做到:

  1. 能设计一个统一响应结构
  2. 会区分常见 HTTP 状态码
  3. 会用中间件做统一异常处理
  4. 知道业务异常和系统异常要分开处理
  5. 能把异常和日志记录串起来

延伸阅读

  • 如果你还没掌握请求处理主线,先阅读 ASP.NET Core 基础
  • 如果你想把日志与异常治理一起做完整,继续阅读 .NET 配置、选项绑定与日志实践
  • 如果你想继续补齐认证失败和授权失败的错误处理,继续阅读 .NET 认证与权限基础

本文小结

统一异常处理和统一返回,不是“为了看起来规范”,而是 Web API 工程化的基础能力。把错误处理集中起来之后,你的项目会在三个方面明显提升:

  • 前后端协作更稳定
  • 控制器代码更清爽
  • 线上排障更高效

这也是一个 .NET 接口项目从“能用”走向“好维护”的关键一步。