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

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

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

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

@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,不知道是「用户不存在」还是「服务器炸了」,更没法给用户友好提示。而且这段判断逻辑,几十个接口就要抄几十遍。

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

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

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 省略
}

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

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 里遇到业务错误,直接抛这个异常就行:

@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 的场景):

@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 使用:

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。在全局处理器里把字段错误提取出来:

@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 兜底。 把异常处理收拢到一处,代码干净了,前端也终于能拿到能用的错误信息。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐