《Spring Boot 入门》10.1 @ExceptionHandler

Spring Boot 默认的错误响应由 BasicErrorController 生成,字段有限且不携带业务语义。本节先看清默认 JSON 长什么样,再讲 @ExceptionHandler 的用法与作用范围(只对所在控制器生效)、多异常与继承关系的匹配顺序、@ResponseStatus 与 ResponseEntity 的状态码控制,并说明为什么在每个控制器里重复写处理方法是不可持续的。

本节目标:看清 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 会选择匹配得最具体的那个。匹配规则按优先级如下:

  1. 异常的实际类型完全相等的处理方法;
  2. 否则在类继承树中向上查找,离实际类型最近的祖先类型优先;
  3. @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 全局处理 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Spring Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计