《Spring Boot 入门》10.2 @ControllerAdvice 全局处理

把异常处理从各控制器集中一处,用 @ControllerAdvice 与 @RestControllerAdvice 做全局处理,并用 basePackages、assignableTypes 等限定范围。还会改写框架内置异常(校验、JSON 解析、405、404),用 @Order 排优先级,并借 ResponseEntityExceptionHandler 统一内置异常响应。

本节目标:用 @ControllerAdvice 把异常处理集中到全局,掌握作用范围限定、框架内置异常的改写、多 advice 的优先级,以及 ResponseEntityExceptionHandler 这一扩展点。
适用版本:Spring Boot 4.1.x(Java 21)

10.2 @ControllerAdvice 全局处理

10.1 的结论很清楚:@ExceptionHandler 写在控制器里只能管住自己,业务控制器一多就重复。本节把它搬到一个专门类里,一次写好,全站生效。

10.2.1 最小可用的全局处理器

package com.example.library.web.advice;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import com.example.library.service.BookNotFoundException;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BookNotFoundException.class)
    public ResponseEntity<ErrorBody> handleNotFound(BookNotFoundException ex) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(new ErrorBody(404, ex.getMessage(), "/api/books/" + ex.getId()));
    }
}

@RestControllerAdvice 与 @ControllerAdvice 的关系,等价于 @RestController 与 @Controller 的关系:

注解等价于处理方法的返回值
@ControllerAdvice只有 @Component默认按视图名解析(要 JSON 得自己加 @ResponseBody)
@RestControllerAdvice@ControllerAdvice + @ResponseBody直接序列化为响应体

做 JSON API 一律用 @RestControllerAdvice;只有需要返回错误页视图时才用 @ControllerAdvice。写错注解的症状很好认:@ControllerAdvice 里返回一个对象,客户端收到的却是一个 500,提示找不到名为该对象字符串的视图。

10.2.2 与 10.1 的优先级

当同一个异常既被某个控制器内的 @ExceptionHandler 捕获,又被全局 advice 捕获时,控制器内的处理方法优先。这给了你一个「局部覆盖全局」的口子:绝大多数异常走全局,个别控制器有特殊需求时,在自己的 @ExceptionHandler 里覆盖即可。

10.2.3 限定作用范围

默认情况下,一个 advice 对所有控制器生效。用注解属性可以把范围收窄:

属性含义例子
basePackages / basePackageClasses只对指定包下的控制器生效@RestControllerAdvice(basePackages = "com.example.library.web")
assignableTypes只对指定类型(或其子类)的控制器生效@RestControllerAdvice(assignableTypes = BookController.class)
annotations只对带指定注解的控制器生效@RestControllerAdvice(annotations = RestController.class)

一个常见用法是区分前台 API 与后台管理 API:两者错误体结构不同,用两个 advice,各自用 basePackages 圈定,互不干扰。

@RestControllerAdvice(basePackages = "com.example.library.admin")
public class AdminExceptionHandler {
    // 后台返回带 debug 字段的错误体
}

10.2.4 覆盖框架内置异常

这是 @ControllerAdvice 最有价值的地方。第 9 章的校验失败、JSON 解析失败、方法不支持、路径不存在,都是框架抛出的异常,业务控制器根本管不到。用全局 advice 把它们统一改写。

10.2.4.1 校验失败:MethodArgumentNotValidException

9.1 里 @Valid 失败抛出的就是它。默认响应只有一个笼统的 400,不含字段详情。改写:

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorBody> handleValidation(MethodArgumentNotValidException ex) {
    String detail = ex.getBindingResult().getFieldErrors().stream()
            .map(e -> e.getField() + ": " + e.getDefaultMessage())
            .collect(Collectors.joining("; "));
    return ResponseEntity.badRequest()
            .body(new ErrorBody(400, "参数校验失败", detail));
}

这里 ex.getBindingResult().getFieldErrors() 正是 9.1 提到的字段错误集合,本节终于把它暴露给了客户端。

10.2.4.2 JSON 解析失败:HttpMessageNotReadableException

请求体不是合法 JSON、或字段类型对不上时抛出:

@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ErrorBody> handleUnreadable(HttpMessageNotReadableException ex) {
    return ResponseEntity.badRequest()
            .body(new ErrorBody(400, "请求体不是合法 JSON", ex.getMostSpecificCause().getMessage()));
}

典型触发场景:客户端漏了 Content-Type: application/json,或把 publishedYear 写成 "abc"。

10.2.4.3 方法不支持:HttpRequestMethodNotSupportedException

用 POST 请求一个只声明了 GET 的路径时抛出,语义是 405:

@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ErrorBody> handleMethod(HttpRequestMethodNotSupportedException ex) {
    return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED)
            .body(new ErrorBody(405, "不支持的请求方法: " + ex.getMethod(), null));
}

10.2.4.4 路径不存在:NoHandlerFoundException

默认情况下,请求一个不存在的路径不会抛异常,而是被静态资源处理器接住并返回 404。要让框架抛 NoHandlerFoundException 从而进入你的 advice,需要两步配置:

spring.mvc.throw-exception-if-no-handler-found=true
spring.web.resources.add-mappings=false
spring:
  mvc:
    throw-exception-if-no-handler-found: true
  web:
    resources:
      add-mappings: false

然后捕获它:

@ExceptionHandler(NoHandlerFoundException.class)
public ResponseEntity<ErrorBody> handleNoHandler(NoHandlerFoundException ex) {
    return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(new ErrorBody(404, "接口不存在: " + ex.getRequestURL(), ex.getRequestURL()));
}

注意:add-mappings=false 会关掉默认静态资源映射,如果你同时要提供 static/ 下的文件,就要自己重新配置资源处理器,不能无脑打开。

10.2.5 用 @Order 控制多个 advice 的优先级

当项目里存在多个 @ControllerAdvice 时,同一个异常可能被多个类声明。用 @Order 指定优先级,数值越小越先被处理:

@Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice
public class ValidationExceptionHandler {
}
@Order(Ordered.LOWEST_PRECEDENCE)
@RestControllerAdvice
public class FallbackExceptionHandler {
    // 兜底:处理所有未匹配的 Exception
}

一个实用模式是「专用 advice 高优先级 + 兜底 advice 低优先级」:前者处理具体异常,后者只留一个 @ExceptionHandler(Exception.class) 兜住漏网之鱼并记日志。

需要注意,@Order 影响的是多个 advice 之间的先后;在同一个 advice 内部,方法的选择仍遵循 10.1.5 的「最具体类型优先」规则。

10.2.6 兜底处理器

无论怎么覆盖,总会有意料之外的异常。加一个兜底方法,避免异常信息直接泄露给客户端:

@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorBody> handleAny(Exception ex) {
    log.error("unhandled exception", ex);
    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(new ErrorBody(500, "服务器内部错误", null));
}

对外只回一句通用文案,把堆栈写进日志——这是生产环境的底线。

10.2.7 ResponseEntityExceptionHandler:改写 Spring MVC 内置异常

上面一个个手写 @ExceptionHandler 能解决问题,但 Spring MVC 内置的异常有十几种(MethodArgumentNotValidException、HttpMessageNotReadableException、NoHandlerFoundException、HttpMediaTypeNotSupportedException……),逐个覆盖很啰嗦。

ResponseEntityExceptionHandler 是 Spring MVC 提供的基类,它已经为这些内置异常写好了处理方法,每个方法都调用一个 handleXxx 钩子并返回 ResponseEntity<Object>。你只要继承它、覆写关心的钩子即可:

package com.example.library.web.advice;

import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

@RestControllerAdvice
public class RestExceptionHandler extends ResponseEntityExceptionHandler {

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex,
            HttpHeaders headers,
            HttpStatusCode status,
            WebRequest request) {

        String detail = ex.getBindingResult().getFieldErrors().stream()
                .map(e -> e.getField() + ": " + e.getDefaultMessage())
                .collect(Collectors.joining("; "));
        ErrorBody body = new ErrorBody(400, "参数校验失败", detail);
        return ResponseEntity.status(status).headers(headers).body(body);
    }
}

要点:

  • 继承 ResponseEntityExceptionHandler 的类仍需标注 @RestControllerAdvice,否则不会被注册。
  • 覆写 handleMethodArgumentNotValid 等方法即可;未覆写的仍走框架默认行为。
  • 这是把「框架内置异常」与「业务自定义异常」纳入同一响应结构的干净做法。业务异常照旧用普通的 @ExceptionHandler 方法写在同一个类里。

10.2.8 与 ErrorController 的关系与分工

前面反复出现 BasicErrorController,这里把两者的边界讲清:

维度@ControllerAdviceBasicErrorController(/error)
触发时机DispatcherServlet 处理请求时抛出的异常异常最终冒泡到容器、被转发到 /error
能否拿到业务上下文能(异常对象、请求、绑定结果)不能,只有错误属性
职责主动改写异常为业务响应最后兜底,渲染 HTML 或 JSON
可否替换不替换,是补充可实现 ErrorController 自定义

一句话:advice 是「第一道出口」,/error 是「最后一道兜底」。理想状态下,所有可预期的异常都被 advice 处理掉,/error 只处理真正漏网的意外。当你在日志里频繁看到请求落到 /error,通常意味着有异常没被覆盖。

小结

  • @RestControllerAdvice = @ControllerAdvice + @ResponseBody,做 JSON API 用它;返回视图时用 @ControllerAdvice。
  • 作用范围可用 basePackages / assignableTypes / annotations 收窄,常用于区分前台与后台。
  • 全局 advice 是改写框架内置异常的正确位置:MethodArgumentNotValidException(校验)、HttpMessageNotReadableException(JSON)、HttpRequestMethodNotSupportedException(405)、NoHandlerFoundException(404,需开 spring.mvc.throw-exception-if-no-handler-found 并关 spring.web.resources.add-mappings)。
  • 多个 advice 用 @Order 排优先级(越小越先);同一 advice 内仍按「最具体类型优先」选方法。
  • 始终留一个 Exception 兜底方法,对外回通用文案、对内记日志。
  • 继承 ResponseEntityExceptionHandler 可一次覆盖大量 Spring MVC 内置异常,但仍要标注 @RestControllerAdvice。
  • advice 是主动出口,/error 是最后兜底;落到 /error 的请求越多,说明覆盖越不完整。

到这里,异常已经被集中处理,但每个处理方法仍各自拼装响应体。下一节我们把响应结构统一起来,让成功与失败走同一套 ApiResponse 外形。

阅读导航:上一节:10.1 @ExceptionHandler · 下一节:10.3 统一响应结构 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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