本节目标:看清 Spring Boot 默认错误响应长什么样,掌握 @ExceptionHandler 的用法、作用范围、异常匹配顺序,以及 @ResponseStatus 与 ResponseEntity 如何控制状态码。
适用版本:Spring Boot 4.1.x(Java 21)
10.1 @ExceptionHandler
第 8、9 章里,图书服务的接口能创建、查询、更新,入参校验也齐了。但只要有一个环节出错,返回给客户端的响应就变得不可控:查不存在的书返回什么?校验失败返回什么?抛出异常时返回什么?本节先把「默认行为」看清楚,再引入第一个可控手段——@ExceptionHandler。
10.1.1 先看默认错误处理长什么样
把第 8 章的 BookController 原样跑起来,请求一本不存在的书:
curl -i http://localhost:8080/api/books/999
8.2 里我们手写了 ResponseEntity.notFound().build(),所以这里得到的是空体的 404。但更多时候异常是从 Service 层抛上来的,我们并没有捕获它。假设把控制器改成直接调用会抛异常的实现:
@GetMapping("/{id}")
public Book get(@PathVariable Long id) {
return bookService.findById(id)
.orElseThrow(() -> new RuntimeException("book not found: " + id));
}
此时再请求 /api/books/999,返回的不是空体,而是 Spring Boot 自动生成的一段 JSON:
{
"timestamp": "2026-09-20T02:15:33.412+00:00",
"status": 500,
"error": "Internal Server Error",
"path": "/api/books/999"
}
这段响应的生产者是 Spring Boot 自动配置的 BasicErrorController,它绑定在 /error 端点。任何未被处理的异常,最终都会由容器转发到 /error,再由它根据请求的 Accept 头渲染 HTML 或 JSON。
几点值得注意:
- 状态码一律是 500,除非异常上带了明确的语义(后面讲的
@ResponseStatus)。RuntimeException("book not found")在业务上是 404,框架却只能返回 500。 - 没有业务码、没有错误消息、没有字段详情。
message默认被隐藏(server.error.include-message=never),因为直接暴露异常信息有安全风险。 error字段是 HTTP 状态码的标准短语,不是给业务用的。
10.1.2 默认处理的三个问题
把上面的现象归纳成三条,后面所有内容都是为了解决它们:
| 问题 | 表现 | 后果 |
|---|---|---|
| 语义丢失 | 业务上「书不存在」被返回成 500 | 客户端无法区分「服务挂了」和「数据没有」 |
| 信息缺失 | 没有业务码、没有可读消息 | 前端只能提示「请求失败」,无法定位 |
| 校验细节丢失 | 9.1 的 @NotBlank 失败也走 /error | 用户看不到具体是哪个字段不合法 |
10.1.3 @ExceptionHandler 基本用法
@ExceptionHandler 标注在一个方法上,声明「当本控制器抛出某类异常时,用这个方法处理」。它把异常的出口从 /error 拉回到控制器自己手里。
package com.example.library.web;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import com.example.library.service.BookNotFoundException;
@RestController
@RequestMapping("/api/books")
public class BookController {
private final BookService bookService;
public BookController(BookService bookService) {
this.bookService = bookService;
}
@GetMapping("/{id}")
public BookResponse get(@PathVariable Long id) {
return bookService.findById(id);
}
@ExceptionHandler(BookNotFoundException.class)
public ResponseEntity<String> handleNotFound(BookNotFoundException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body("book not found: " + ex.getId());
}
}
要点:
- 处理方法的参数是要捕获的异常类型(可加
HttpServletRequest等)。 - 返回值直接作为响应体;返回
ResponseEntity时可以顺带设置状态码和响应头。 - 一个控制器里可以有多个
@ExceptionHandler方法,按异常类型分工。
10.1.4 作用范围:只对所在控制器生效
这是 @ExceptionHandler 最重要的性质,也是最容易被忽略的:
写在某个
@RestController里的@ExceptionHandler,只对该控制器内抛出的异常生效,对其它控制器完全无效。
假设项目里还有 AuthorController、OrderController,它们在处理请求时同样会抛出 BookNotFoundException(比如订单里引用了不存在的书)。上面那个 handleNotFound 对它们毫无作用——AuthorController 抛出的异常依然会走 /error,返回 500。
要验证这一点,可以临时在另一个控制器里也抛同样的异常,观察它仍然返回默认的 500 结构。
| 放置位置 | 生效范围 |
|---|---|
某个 @RestController 内 | 仅该控制器 |
@ControllerAdvice 类内 | 全局(10.2 详述) |
10.1.5 捕获多个异常与继承匹配顺序
一个方法可以捕获多个异常类型:
@ExceptionHandler({BookNotFoundException.class, AuthorNotFoundException.class})
public ResponseEntity<String> handleNotFound(RuntimeException ex) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getMessage());
}
也可以不写数组,靠异常继承关系兜底:
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<String> handleAny(RuntimeException ex) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ex.getMessage());
}
当同一个控制器里同时存在「精确类型」和「父类型」两个处理方法时,Spring 会选择匹配得最具体的那个。匹配规则按优先级如下:
- 异常的实际类型完全相等的处理方法;
- 否则在类继承树中向上查找,离实际类型最近的祖先类型优先;
@ExceptionHandler里若写了多个类型,按声明顺序在同层级里取第一个匹配。
举例,若同时声明了 handleNotFound(BookNotFoundException) 与 handleAny(RuntimeException),抛 BookNotFoundException 时前者胜出。如果只声明了父类型,子类型异常也会被它捕获——这既是兜底手段,也是「不小心把 500 兜住、导致本该 404 的异常返回 500」的常见事故来源。
10.1.6 用 @ResponseStatus 指定状态码
如果处理方法的返回值直接就是响应体(而不是 ResponseEntity),可以用 @ResponseStatus 单独指定状态码:
@ExceptionHandler(BookNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public String handleNotFound(BookNotFoundException ex) {
return "book not found: " + ex.getId();
}
@ResponseStatus 也可以直接标在自定义异常类上,这样连处理方法都能省掉——只要该异常冒泡到框架,状态码就会被采用:
package com.example.library.service;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.NOT_FOUND)
public class BookNotFoundException extends RuntimeException {
private final Long id;
public BookNotFoundException(Long id) {
super("book not found: " + id);
this.id = id;
}
public Long getId() {
return id;
}
}
不过要注意:@ResponseStatus 只能改状态码,改不了响应体结构。返回体依然是默认的 BasicErrorController JSON(timestamp/status/error/path),消息同样被隐藏。想在异常上带业务消息,还是得回到 @ExceptionHandler。
10.1.7 用 ResponseEntity 做精细控制
需要同时控制状态码、响应头和响应体时,返回 ResponseEntity 最直接。下面的例子在 404 响应里带上一个自定义头,并用 9 章定义的统一错误体(10.3 会把它做完整):
@ExceptionHandler(BookNotFoundException.class)
public ResponseEntity<ErrorBody> handleNotFound(BookNotFoundException ex,
HttpServletRequest request) {
ErrorBody body = new ErrorBody(
HttpStatus.NOT_FOUND.value(),
ex.getMessage(),
request.getRequestURI());
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.header("X-Error-Code", "BOOK_NOT_FOUND")
.body(body);
}
其中 ErrorBody 是一个简单记录:
public record ErrorBody(int status, String message, String path) {
}
这样客户端拿到的是结构清晰、字段固定的错误体,而不是框架的默认 JSON。
10.1.8 返回 ModelAndView 渲染错误页(简要)
@ExceptionHandler 的返回值不限于 JSON。对面向浏览器的页面,可以返回 ModelAndView,把异常信息塞进模型,交给模板渲染:
@ExceptionHandler(BookNotFoundException.class)
public ModelAndView handleNotFoundPage(BookNotFoundException ex) {
ModelAndView mav = new ModelAndView("error/book-not-found");
mav.addObject("bookId", ex.getId());
mav.setStatus(HttpStatus.NOT_FOUND);
return mav;
}
此时视图名 error/book-not-found 会由模板引擎(Thymeleaf、FreeMarker 等)解析。本书后续章节聚焦 JSON API,这条路径了解即可,不必深挖。
10.1.9 为什么控制器内处理会重复
到这里,@ExceptionHandler 已经能把异常从 /error 拉回来。但请注意它带来的新问题:
BookController需要处理BookNotFoundException;AuthorController也需要处理同一个异常;OrderController处理它引用的书不存在时,同样需要处理;- 校验失败(9 章的
MethodArgumentNotValidException)在每个接收请求体的控制器上都会出现。
于是同一个 handleNotFound 方法被复制到每一个控制器里,改一处漏一处。更麻烦的是框架抛出的内置异常(校验失败、JSON 解析失败、方法不支持),它们不属于任何业务控制器,却也需要统一改写。
这说明:异常处理不该是「每个控制器各写一份」,而该是「全局集中一份」。这正是下一节 @ControllerAdvice 要解决的问题。
小结
- Spring Boot 默认把未处理异常转发到
/error,由BasicErrorController生成timestamp/status/error/path结构的响应,状态码默认 500,消息默认隐藏。 @ExceptionHandler声明在方法上,按异常类型捕获,返回值作为响应体;写在控制器里时只对该控制器生效。- 多异常可用数组声明;同时存在父子类型处理器时,匹配最具体的类型;只声明父类型会连带捕获所有子类型,容易误兜 500。
@ResponseStatus能改状态码(可标在异常类上省掉处理方法),但改不了响应体结构;ResponseEntity才能同时控制状态码、响应头与响应体。- 面向页面时,
@ExceptionHandler也可返回ModelAndView渲染错误模板。 - 控制器内处理会随控制器数量成倍重复,且管不到框架内置异常,因此需要全局方案——下一节
@ControllerAdvice。
阅读导航:上一节:9.3 分组校验与嵌套校验 · 下一节:10.2 @ControllerAdvice 全局处理 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。