全局异常处理
Spring Boot 通过 @ControllerAdvice + @ExceptionHandler 在一处集中捕获所有 Controller 抛出的异常,避免在每个接口中重复 try-catch。
核心机制
HTTP 请求
│
▼
DispatcherServlet
│
▼
HandlerAdapter → Controller 方法
│ 抛出异常
▼
HandlerExceptionResolver 链
├── ExceptionHandlerExceptionResolver ← @ExceptionHandler 处理
├── ResponseStatusExceptionResolver ← @ResponseStatus 处理
└── DefaultHandlerExceptionResolver ← Spring MVC 内置异常
Filter 中抛出的异常在 DispatcherServlet 之外,无法被
@ControllerAdvice捕获,需单独处理(见下文)。
自定义业务异常
先定义层级清晰的业务异常体系:
// 基础业务异常
public class BusinessException extends RuntimeException {
private final String code;
public BusinessException(String code, String message) {
super(message);
this.code = code;
}
public String getCode() { return code; }
}
// 资源不存在(404 语义)
public class ResourceNotFoundException extends BusinessException {
public ResourceNotFoundException(String resource, Object id) {
super("NOT_FOUND", resource + " [" + id + "] 不存在");
}
}
// 权限不足(403 语义)
public class ForbiddenException extends BusinessException {
public ForbiddenException(String message) {
super("FORBIDDEN", message);
}
}统一响应格式
配合统一响应包装,所有接口(含错误)都返回相同结构:
@Data
@AllArgsConstructor
public class ApiResponse<T> {
private String code;
private String message;
private T data;
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>("OK", "success", data);
}
public static ApiResponse<Void> fail(String code, String message) {
return new ApiResponse<>(code, message, null);
}
}统一响应封装详见 统一响应格式。
@RestControllerAdvice 实现
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// ── 业务异常 ─────────────────────────────────────────────
@ExceptionHandler(ResourceNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ApiResponse<Void> handleNotFound(ResourceNotFoundException ex) {
return ApiResponse.fail(ex.getCode(), ex.getMessage());
}
@ExceptionHandler(ForbiddenException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public ApiResponse<Void> handleForbidden(ForbiddenException ex) {
return ApiResponse.fail(ex.getCode(), ex.getMessage());
}
@ExceptionHandler(BusinessException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleBusiness(BusinessException ex) {
log.warn("业务异常: code={}, message={}", ex.getCode(), ex.getMessage());
return ApiResponse.fail(ex.getCode(), ex.getMessage());
}
// ── 参数校验异常 ──────────────────────────────────────────
// @RequestBody + @Valid 校验失败
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Map<String, String>> handleValidation(
MethodArgumentNotValidException ex) {
Map<String, String> errors = new LinkedHashMap<>();
ex.getBindingResult().getFieldErrors()
.forEach(e -> errors.put(e.getField(), e.getDefaultMessage()));
return new ApiResponse<>("VALIDATION_ERROR", "参数校验失败", errors);
}
// @RequestParam / @PathVariable + @Validated 校验失败
@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Map<String, String>> handleConstraintViolation(
ConstraintViolationException ex) {
Map<String, String> errors = new LinkedHashMap<>();
ex.getConstraintViolations()
.forEach(v -> {
String field = v.getPropertyPath().toString();
errors.put(field.substring(field.lastIndexOf('.') + 1),
v.getMessage());
});
return new ApiResponse<>("VALIDATION_ERROR", "参数校验失败", errors);
}
// 表单绑定失败(@ModelAttribute)
@ExceptionHandler(BindException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Map<String, String>> handleBind(BindException ex) {
Map<String, String> errors = new LinkedHashMap<>();
ex.getFieldErrors()
.forEach(e -> errors.put(e.getField(), e.getDefaultMessage()));
return new ApiResponse<>("BIND_ERROR", "参数绑定失败", errors);
}
// ── Spring MVC 内置异常 ───────────────────────────────────
// 请求方法不支持(405)
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
@ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
public ApiResponse<Void> handleMethodNotAllowed(
HttpRequestMethodNotSupportedException ex) {
return ApiResponse.fail("METHOD_NOT_ALLOWED",
"不支持 " + ex.getMethod() + " 请求方式");
}
// Content-Type 不支持(415)
@ExceptionHandler(HttpMediaTypeNotSupportedException.class)
@ResponseStatus(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
public ApiResponse<Void> handleMediaType(
HttpMediaTypeNotSupportedException ex) {
return ApiResponse.fail("UNSUPPORTED_MEDIA_TYPE",
"不支持的媒体类型: " + ex.getContentType());
}
// 路径参数类型转换失败(如 /users/abc,id 期望 Long)
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleTypeMismatch(
MethodArgumentTypeMismatchException ex) {
return ApiResponse.fail("TYPE_MISMATCH",
"参数 [" + ex.getName() + "] 类型错误,期望: "
+ ex.getRequiredType().getSimpleName());
}
// ── 兜底异常 ─────────────────────────────────────────────
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<Void> handleUnknown(Exception ex,
HttpServletRequest request) {
// 未知异常必须打完整堆栈,方便排查
log.error("未知异常 - URI: {} {}", request.getMethod(),
request.getRequestURI(), ex);
return ApiResponse.fail("INTERNAL_ERROR", "服务器内部错误,请稍后重试");
}
}参数校验注解详见 参数校验。
继承 ResponseEntityExceptionHandler
Spring MVC 的 ResponseEntityExceptionHandler 已处理了所有内置异常(见其源码中 handleException 方法),可以继承它来统一 HTTP 状态码和响应体格式:
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
// 覆盖父类方法,将内置异常也包装为 ApiResponse
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,
HttpHeaders headers, HttpStatusCode status,
WebRequest request) {
Map<String, String> errors = new LinkedHashMap<>();
ex.getFieldErrors().forEach(e ->
errors.put(e.getField(), e.getDefaultMessage()));
return ResponseEntity.badRequest()
.body(new ApiResponse<>("VALIDATION_ERROR", "参数校验失败", errors));
}
// 其余业务异常在此添加 @ExceptionHandler ...
}Filter 中的异常处理
Filter 在 DispatcherServlet 之前执行,其中抛出的异常不会被 @ControllerAdvice 捕获,需在 Filter 内自行写响应:
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class AuthFilter extends OncePerRequestFilter {
private final ObjectMapper objectMapper;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
try {
chain.doFilter(request, response);
} catch (TokenExpiredException e) {
writeError(response, HttpStatus.UNAUTHORIZED, "TOKEN_EXPIRED", "Token 已过期");
} catch (TokenInvalidException e) {
writeError(response, HttpStatus.UNAUTHORIZED, "TOKEN_INVALID", "Token 无效");
}
}
private void writeError(HttpServletResponse response,
HttpStatus status, String code, String msg)
throws IOException {
response.setStatus(status.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
objectMapper.writeValue(response.getWriter(),
ApiResponse.fail(code, msg));
}
}过滤器详见 过滤器与拦截器。
国际化错误消息
结合 MessageSource 将错误码翻译为当前语言的消息:
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {
private final MessageSource messageSource;
@ExceptionHandler(BusinessException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleBusiness(BusinessException ex, Locale locale) {
// 尝试从消息文件翻译,找不到则使用原始消息
String message = messageSource.getMessage(
ex.getCode(), ex.getArgs(), ex.getMessage(), locale);
return ApiResponse.fail(ex.getCode(), message);
}
}国际化配置详见 国际化。
记录异常日志的原则
| 异常类型 | 日志级别 | 是否打堆栈 |
|---|---|---|
| 业务异常(预期内,如参数错误) | WARN | 否(消息足够) |
| 系统异常(预期外,如 NPE) | ERROR | 是(完整堆栈) |
| 404 / 405 等 HTTP 异常 | WARN 或不打 | 否 |
| 安全异常(认证失败) | WARN | 否(避免日志爆炸) |
// 区分打印策略
if (ex instanceof BusinessException) {
log.warn("[业务异常] {}: {}", ex.getCode(), ex.getMessage());
} else {
log.error("[系统异常] URI={}", request.getRequestURI(), ex);
}常见问题
Q:@ExceptionHandler 的匹配顺序?
优先匹配最精确的类型(子类优先于父类)。同一 @ControllerAdvice 中存在多个 handler 时,精确类型先生效。
Q:多个 @ControllerAdvice 的执行顺序?
通过 @Order 或实现 Ordered 控制,数值越小优先级越高。
Q:@ResponseStatus 和 ResponseEntity 哪个优先?
返回 ResponseEntity 时,其 HTTP 状态码优先于方法上的 @ResponseStatus。