Spring Boot 全局异常处理:@ControllerAdvice 实战

Spring Boot 全局异常处理:@ControllerAdvice 实战

接口报错时返回一大坨 Whitelabel Error Page 或者 500 堆栈,前端拿到根本没法处理;每个 Controller 里都写一堆 try-catch,同样的错误处理逻辑复制粘贴十几遍......这些都是没做好全局异常处理的表现。Spring Boot 提供了 @ControllerAdvice,能把散落各处的异常处理收拢到一个地方,统一成规范的响应格式。这篇手把手把它讲透。

先看没有全局处理时有多乱

假设有个查用户的接口,朴素写法往往是这样:

java 复制代码
@RestController
@RequestMapping("/users")
public class UserController {

    @GetMapping("/{id}")
    public User getUser(@PathVariable Long id) {
        User user = userService.findById(id);
        if (user == null) {
            // 每个接口都要手写这种判断和返回,重复且不统一
            throw new RuntimeException("用户不存在");
        }
        return user;
    }
}

抛个 RuntimeException,Spring 默认返回 500,响应体是一坨内部堆栈。前端看到 500,不知道是「用户不存在」还是「服务器炸了」,更没法给用户友好提示。而且这段判断逻辑,几十个接口就要抄几十遍。

第一步:定义统一响应格式和业务异常

先约定一个统一的返回结构,成功失败都用它:

java 复制代码
public class ApiResponse<T> {
    private int code;       // 业务状态码,不是 HTTP 状态码
    private String message; // 给前端/用户看的提示
    private T data;         // 成功时的数据

    public static <T> ApiResponse<T> success(T data) {
        ApiResponse<T> r = new ApiResponse<>();
        r.code = 0;
        r.message = "ok";
        r.data = data;
        return r;
    }

    public static <T> ApiResponse<T> error(int code, String message) {
        ApiResponse<T> r = new ApiResponse<>();
        r.code = code;
        r.message = message;
        return r;
    }
    // getter/setter 省略
}

再定义一个自定义业务异常,携带业务码和消息:

java 复制代码
public class BusinessException extends RuntimeException {
    private final int code;

    public BusinessException(int code, String message) {
        super(message);
        this.code = code;
    }

    public int getCode() {
        return code;
    }
}

这样 Controller/Service 里遇到业务错误,直接抛这个异常就行:

java 复制代码
@GetMapping("/{id}")
public ApiResponse<User> getUser(@PathVariable Long id) {
    User user = userService.findById(id);
    if (user == null) {
        // 只管抛,怎么转成响应交给全局处理器
        throw new BusinessException(40401, "用户不存在");
    }
    return ApiResponse.success(user);
}

Controller 干净了,不用再关心异常怎么变成 HTTP 响应。

第二步:@RestControllerAdvice 统一接住

核心来了。写一个全局异常处理类,用 @RestControllerAdvice 标注(它等于 @ControllerAdvice + @ResponseBody,专门给返回 JSON 的场景):

java 复制代码
@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    // 处理自定义业务异常
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ApiResponse<Void>> handleBusiness(BusinessException e) {
        // 业务异常是预期内的,用 warn 记一条即可,不必打堆栈
        log.warn("业务异常: code={}, msg={}", e.getCode(), e.getMessage());
        return ResponseEntity
                .status(HttpStatus.BAD_REQUEST) // HTTP 400,业务细节放 body
                .body(ApiResponse.error(e.getCode(), e.getMessage()));
    }

    // 兜底:所有没被上面接住的异常都落到这里
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiResponse<Void>> handleUnknown(Exception e) {
        // 未预期的异常是真 bug,必须打完整堆栈方便排查
        log.error("系统异常", e);
        return ResponseEntity
                .status(HttpStatus.INTERNAL_SERVER_ERROR)
                // 注意:不要把 e.getMessage() 直接暴露给前端,可能泄漏内部细节
                .body(ApiResponse.error(50000, "服务器繁忙,请稍后重试"));
    }
}

两个要点:

  1. 异常处理有优先级 :Spring 会匹配最具体的那个。BusinessException 走第一个方法,其他一切走 Exception 兜底。所以要为已知异常写专门的 handler,再用 Exception.class 做最后防线。
  2. 兜底方法别把原始异常消息返回给前端e.getMessage() 可能包含 SQL、内部路径等敏感信息,统一返回一句「服务器繁忙」,真实堆栈打到日志里自己看。

第三步:处理参数校验异常

实际项目里最常见的异常之一是参数校验失败。配合 @Valid 使用:

java 复制代码
public class CreateUserRequest {
    @NotBlank(message = "用户名不能为空")
    private String username;

    @Email(message = "邮箱格式不正确")
    private String email;
    // getter/setter 省略
}

@PostMapping
public ApiResponse<Void> create(@Valid @RequestBody CreateUserRequest req) {
    userService.create(req);
    return ApiResponse.success(null);
}

校验不通过,Spring 会抛 MethodArgumentNotValidException。在全局处理器里把字段错误提取出来:

java 复制代码
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException e) {
    // 取第一条字段错误信息返回,前端能直接展示
    String msg = e.getBindingResult().getFieldErrors().stream()
            .findFirst()
            .map(err -> err.getField() + ": " + err.getDefaultMessage())
            .orElse("参数校验失败");
    return ResponseEntity
            .status(HttpStatus.BAD_REQUEST)
            .body(ApiResponse.error(40000, msg));
}

现在传个空用户名,接口会返回 {"code":40000,"message":"username: 用户名不能为空","data":null},前端拿到直接弹给用户即可。

一个常见坑:@ControllerAdvice 拦不住的异常

@ControllerAdvice 只能捕获进入 Controller 之后抛出的异常。有两类它管不到:

  • Filter 里抛的异常 :Filter 在 DispatcherServlet 之前执行,还没到 Controller 层,@ControllerAdvice 够不着。这类要靠自定义 Filter 或 ErrorController 处理。
  • 404 找不到路径 :默认情况下也不会走到你的 handler。需要配置 spring.mvc.throw-exception-if-no-handler-found=true 并关掉静态资源默认映射,才能用 NoHandlerFoundException 接住。

知道这个边界,排查「为什么我的全局处理器没生效」时就不会抓瞎。

小结

  • 统一响应结构 ApiResponse + 自定义业务异常 BusinessException:Controller 只管抛,不管怎么转响应。
  • @RestControllerAdvice + @ExceptionHandler :集中接异常。为已知异常写专门 handler,用 Exception.class 兜底。
  • 日志分级 :业务异常 warn、未知异常 error 打全堆栈;兜底响应别把原始异常消息暴露给前端
  • 参数校验 :@Valid + 捕获 MethodArgumentNotValidException,提取字段错误返回。
  • 边界 :@ControllerAdvice 拦不住 Filter 异常和默认的 404,别在这上面浪费排查时间。

一句话记忆点:Controller 只负责抛,@RestControllerAdvice 负责统一翻译成规范响应;越具体的异常匹配越优先,Exception 兜底。 把异常处理收拢到一处,代码干净了,前端也终于能拿到能用的错误信息。

相关推荐
折哥的程序人生 · 物流技术专研1 小时前
第2篇:策略模式三大角色拆解与UML
java·设计模式·策略模式·uml·编程入门·行为型模式·扩充系列
李少兄1 小时前
Java CompletableFuture 解析(含常见面试题)
java·开发语言
能摆一天是一天1 小时前
Spring ai vectorstore 使用本地模型导致只能匹配可行度为1.0的内容的解决方法笔记
java·笔记·spring
还是鼠鼠1 小时前
AI掘金头条新闻系统 (Toutiao News)-缓存相关推荐新闻
后端·python·mysql·fastapi·web
我命由我123451 小时前
方差(实例实操、与标准差的区别)
java·数据结构·算法·数据分析·java-ee·intellij-idea·idea
Java内核笔记1 小时前
Spring Security 源码解析(八)方法安全授权与自定义扩展:@EnableMethodSecurity、AOP、SpEL 全链路
java·后端
自由的裙子1 小时前
How-To: Using the N* Stack, part 4
java·jvm·oracle
逝水无殇2 小时前
C# 枚举(Enum)详解
开发语言·后端·c#
什巳2 小时前
JAVA练习275-乘积最大子数组
java·开发语言·数据结构·算法