全局异常处理

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:@ResponseStatusResponseEntity 哪个优先? 返回 ResponseEntity 时,其 HTTP 状态码优先于方法上的 @ResponseStatus


相关链接

  • 参数校验@Valid / @Validated 注解与 MethodArgumentNotValidException
  • 统一响应格式 — ApiResponse 包装器与 ResponseBodyAdvice
  • 过滤器与拦截器 — Filter 中异常不经过 DispatcherServlet 的原因
  • 国际化 — 错误码翻译为多语言消息
  • 安全 — 401 / 403 的 AuthenticationEntryPoint 与 AccessDeniedHandler
  • 日志 — 异常日志级别与输出格式配置