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, "服务器繁忙,请稍后重试"));
}
}
两个要点:
- 异常处理有优先级 :Spring 会匹配最具体的那个。
BusinessException走第一个方法,其他一切走Exception兜底。所以要为已知异常写专门的 handler,再用Exception.class做最后防线。 - 兜底方法别把原始异常消息返回给前端 。
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 兜底。 把异常处理收拢到一处,代码干净了,前端也终于能拿到能用的错误信息。