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 业务异常
例如:
- 用户余额不足
- 订单已关闭
- 当前账号不能重复注册
这类通常更适合映射为:
400409- 或者其他明确的业务错误返回
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,建议至少做到:
- 能设计一个统一响应结构
- 会区分常见 HTTP 状态码
- 会用中间件做统一异常处理
- 知道业务异常和系统异常要分开处理
- 能把异常和日志记录串起来
延伸阅读
- 如果你还没掌握请求处理主线,先阅读
ASP.NET Core 基础 - 如果你想把日志与异常治理一起做完整,继续阅读
.NET 配置、选项绑定与日志实践 - 如果你想继续补齐认证失败和授权失败的错误处理,继续阅读
.NET 认证与权限基础
本文小结
统一异常处理和统一返回,不是“为了看起来规范”,而是 Web API 工程化的基础能力。把错误处理集中起来之后,你的项目会在三个方面明显提升:
- 前后端协作更稳定
- 控制器代码更清爽
- 线上排障更高效
这也是一个 .NET 接口项目从“能用”走向“好维护”的关键一步。