前言
在 Web 开发中,异常处理是不可避免的一环。初学者往往喜欢在 Service 或 Controller 层写大量的 try-catch 代码,最后返回一个 Result 对象。这种做法虽然直观,但会导致业务代码与错误处理逻辑严重耦合,代码极其臃肿。
Spring Boot (基于 Spring MVC) 提供了一套优雅的、解耦的异常处理机制。本文将带你深入底层,探究当一个异常被抛出后,究竟经历了怎样的奇幻漂流,又是如何根据前端需求自动变成 JSON 或 XML 的。
没问题,为了让你的博客内容足够硬核且具有实战参考价值,我将这个异常处理流程进行了大幅度的扩充。
这次我们不再只停留在表面,而是结合"源码级"的执行步骤 和完整的代码示例,把整个过程拆解得清清楚楚。
你可以直接使用以下内容作为博客的核心章节。
Spring Boot 异常处理全链路深度解析
很多同学只会用 @ControllerAdvice,却不知道当一个异常抛出后,Spring Boot 内部到底发生了什么。下面我们通过一个真实的业务场景,配合源码视角,还原异常的"一生"。
1. 场景准备:案发现场
首先,我们需要构建一个标准的异常抛出场景。
1.1 定义标准响应体 (Result)
这是企业级开发的标配,前后端统一契约。
java
@Data
public class Result<T> {
private Integer code;
private String msg;
private T data;
public static <T> Result<T> fail(Integer code, String msg) {
Result<T> r = new Result<>();
r.code = code;
r.msg = msg;
return r;
}
}
1.2 定义自定义异常 (MyException)
java
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(String message) {
super(message);
}
}
1.3 编写 Controller (肇事者)
java
@RestController
public class OrderController {
@GetMapping("/order/{id}")
public Result getOrder(@PathVariable Integer id) {
if (id < 0) {
// 【关键点】:这里抛出了异常,Controller 方法立即终止!
throw new OrderNotFoundException("订单ID不能为负数");
}
return new Result(); // 正常逻辑
}
}
1.4 编写全局异常处理器 (救援队)
java
@RestControllerAdvice // 相当于 @ControllerAdvice + @ResponseBody
public class GlobalExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public Result handleOrderException(OrderNotFoundException e) {
// 捕获异常,并"捏造"一个优雅的 Result 返回
return Result.fail(404, e.getMessage());
}
}
2. 深度解析:异常处理的七步"奇幻漂流"
当用户请求 GET /order/-1 时,后台发生了如下精密的操作:
第一步:异常冒泡 (JVM 层面)
Controller 的 getOrder 方法执行到 throw 语句。此时,当前方法栈帧被销毁,Controller 彻底"挂了"。异常对象开始沿着调用栈向上冒泡。
第二步:DispatcherServlet 捕获 (总指挥接管)
异常冒泡到了 Spring MVC 的最外层------DispatcherServlet.doDispatch() 方法。这里有一个巨大的 try-catch 块(源码简化版):
java
// DispatcherServlet.java
try {
// 尝试执行 Controller
mv = ha.handle(processedRequest, response, mappedHandler.getHandler());
} catch (Exception dispatchException) {
// 【捕获点】Controller 抛出的 OrderNotFoundException 在这里被捕获!
// 进入异常处理流程
processDispatchResult(processedRequest, response, mappedHandler, dispatchException, mv);
}
第三步:寻找解析器 (HandlerExceptionResolver)
在 processDispatchResult 内部,Spring 会遍历所有注册的异常解析器链 ,问:"谁能处理 OrderNotFoundException?"
Spring Boot 默认配置了 ExceptionHandlerExceptionResolver,它举手说:"我能!我在 GlobalExceptionHandler 类里看到了一个 @ExceptionHandler 注解匹配这个异常。"
第四步:反射调用 (执行救援逻辑)
ExceptionHandlerExceptionResolver 通过 Java 反射机制,调用我们写的 handleOrderException(e) 方法。
- 输入 :刚才捕获的异常对象
e。 - 执行 :运行我们的代码
return Result.fail(404, ...)。 - 输出 :拿到一个
Result对象。
第五步:处理返回值 (HandlerMethodReturnValueHandler)
框架拿到 Result 对象后,并不会直接发给前端。它发现异常处理类上标记了 @RestControllerAdvice (含 @ResponseBody) 。
于是,它将任务移交给 RequestResponseBodyMethodProcessor。
- 这个组件既负责处理
@RequestBody(读),也负责处理@ResponseBody(写)。
第六步:内容协商 (Content Negotiation)
RequestResponseBodyMethodProcessor 开始决定用什么格式返回数据。
- 查看数据 :返回值是
Result对象。 - 查看需求 :检查 HTTP 请求头
Accept。- 如果是浏览器默认请求,通常包含
*/*。 - 如果是 Postman/Ajax,可能是
application/json。 - 如果是旧系统调用,可能是
application/xml。
- 如果是浏览器默认请求,通常包含
- 匹配转换器 :遍历
HttpMessageConverter列表。- Jackson 说:"我是处理 JSON 的,我可以把
Result对象转成 JSON 字符串。"
- Jackson 说:"我是处理 JSON 的,我可以把
第七步:序列化与写入 (Write Response)
Jackson 转换器开始工作:
- 将
Result对象序列化为 JSON 字符串:{"code":404, "msg":"订单ID不能为负数", "data":null}。 - 获取
HttpServletResponse输出流。 - 设置
Content-Type: application/json。 - 将字符串写入流中,发送给客户端。
3. 总结图
text
[Controller 抛出异常]
⬇
[JVM 冒泡]
⬇
[DispatcherServlet 捕获 (catch)]
⬇
[寻找异常解析器 (Resolver)]
⬇
[反射调用 @ExceptionHandler 方法] --> 生成 Result 对象
⬇
[检测 @ResponseBody 注解]
⬇
[内容协商 (检查 Accept 头)]
⬇
[匹配 HttpMessageConverter (如 Jackson)]
⬇
[序列化 (Result -> JSON/XML)]
⬇
[写入 HttpServletResponse]
⬇
[前端收到报错]
深度解密:异常处理中的"内容协商"
很多开发者认为内容协商(Content Negotiation)只在正常的 Controller 请求中生效,其实不然。异常处理返回的结果,同样完美支持内容协商。
1. 原理分析
无论是 Controller 的正常返回,还是 @ExceptionHandler 的异常返回,只要涉及 "对象转 HTTP Body" ,Spring MVC 底层都交给同一个处理器:RequestResponseBodyMethodProcessor。
它会执行标准的"谈判"流程:
- 看货 :拿到返回值对象(
Result)。 - 看客户需求 :读取 HTTP 请求头中的
Accept字段(例如application/json或application/xml)。 - 找翻译官 :遍历容器中所有的
HttpMessageConverter。 - 执行转换:找到能同时匹配"对象类型"和"客户需求"的转换器,执行序列化。
2. 场景演示
假设我们引入了 jackson-dataformat-xml 依赖,Spring Boot 会自动注册 XML 转换器。
-
场景 A:前端是 Vue/React (默认)
-
请求头:
Accept: application/json -
响应:
json{ "code": 500, "msg": "系统繁忙", "data": null }
-
-
场景 B:前端是旧系统 (指定 XML)
-
请求头:
Accept: application/xml -
响应:
xml<Result> <code>500</code> <msg>系统繁忙</msg> <data/> </Result>
-
结论:我们不需要修改一行 Java 代码,异常信息就能自动适应前端需要的格式。
Spring MVC vs Spring Boot:内容协商谁在干活?
在这个过程中,我们需要理清两者的分工:
-
Spring MVC(机制提供者):
- 提供了
DispatcherServlet捕获异常的机制。 - 提供了
@ControllerAdvice和@ExceptionHandler注解。 - 提供了内容协商管理器 (
ContentNegotiationManager) 和消息转换器接口 (HttpMessageConverter)。 - 它是"发动机"。
- 提供了
-
Spring Boot(自动化配置):
- 自动配置了
ErrorMvcAutoConfiguration(提供兜底的 /error 路径)。 - 自动识别 Classpath 下的 Jackson 包,并注册了 JSON 和 XML 的转换器。
- 它是"装配工",让你开箱即用。
- 自动配置了
SpringBoot的默认异常处理方案
Spring Boot 的错误处理方案,核心就是一个词:"自动兜底"。
它的官方学名叫做 "默认全局错误处理机制"。即使你一行异常处理代码都不写,Spring Boot 也能保证你的应用在报错时,不会直接把服务器炸了,或者给用户看一堆乱码,而是返回一个"虽然丑但结构清晰"的错误响应。
这个方案的核心由 1 个 Controller 、2 种响应模式 和 1 个页面 组成。
1. 核心组件:BasicErrorController
这是 Spring Boot 自动配置 (ErrorMvcAutoConfiguration) 帮你创建的一个特殊的 Controller。
- 它的地位 :和你的
OrderController、UserController平级,都是处理 HTTP 请求的。 - 它的地盘 :默认监听
/error路径。 - 工作原理 :
- 当应用中发生异常(且没被 Spring MVC 拦截),或者访问了不存在的路径(404)。
- Servlet 容器(Tomcat)会捕捉到错误。
- Tomcat 发现你没有配置专门的错误页,于是根据 Spring Boot 的约定,把请求转发 (Forward) 到
/error路径。 BasicErrorController收到请求,开始干活。
2. 智能响应:看人下菜碟(内容协商)
BasicErrorController 非常智能,它会根据**"谁在访问"**(检查 HTTP 请求头 Accept),决定返回什么格式的数据。它内部定义了两个处理方法:
模式 A:浏览器访问 (返回 HTML)
- 判断依据 :请求头包含
text/html。 - 对应方法 :
errorHtml() - 结果 :
- 它会去查找有没有定义好的错误页面(比如
error/404.html)。 - 如果没找到,就返回那个著名的 "Whitelabel Error Page"(白标错误页)。
- 样子你肯定见过:白底黑字,写着 "This application has no explicit mapping for /error..."
- 它会去查找有没有定义好的错误页面(比如
模式 B:客户端访问 (返回 JSON)
-
判断依据 :请求头不包含
text/html(比如 Postman, Ajax, 安卓 App)。 -
对应方法 :
error() -
结果 :返回一个标准的 JSON 对象。
json{ "timestamp": "2023-12-04T12:00:00.000+00:00", "status": 500, "error": "Internal Server Error", "message": "/ by zero", "path": "/api/demo" }
3. 数据来源:DefaultErrorAttributes
你可能会问:"返回的 JSON 里那些 timestamp, status, message 字段是从哪来的?"
这是由另一个组件 DefaultErrorAttributes 负责收集的。它会从 Request 中提取所有的错误信息,封装成一个 Map 给 BasicErrorController 使用。
如果你想在这个默认的 JSON 里增加 一个字段(比如 version: "v1.0"),或者隐藏异常堆栈,你可以继承这个类并重写相关方法。
4. 如何自定义?(给兜底方案换个皮肤)
虽然 Spring Boot 有兜底,但那个"白标页面"太丑了,JSON 格式可能也不符合你们公司的规范。你可以通过以下方式定制:
方式一:自定义错误页面(最常用)
你只需要在 src/main/resources/templates/ 或 static/ 下创建一个 error 文件夹,然后放入对应状态码的 HTML 文件:
error/404.html:专门展示 404 错误。error/500.html:专门展示 500 错误。error/4xx.html:展示所有 4 开头的错误。
Spring Boot 扫到这些文件,就会自动用它们替换掉那个丑陋的白页。
方式二:完全替换兜底逻辑(高阶)
如果你觉得 BasicErrorController 逻辑不够用,你可以实现 ErrorController 接口,重写 /error 的映射逻辑。但这种情况很少见,因为通常我们用 Spring MVC 的 @ControllerAdvice 就够了。