一、核心概念

1.1 为什么需要异常统一处理?

在 Web 应用中,异常是不可避免的。如果没有统一处理:

  • 用户体验差:用户看到原始的 500 错误或堆栈信息。
  • 接口不规范:不同异常返回格式不一致。
  • 安全风险:暴露内部实现细节(如数据库错误、类名)。
  • 维护困难:每个 Controller 都需要写 try-catch

异常统一处理的目标是:

  • 统一响应格式:所有异常返回结构化的 JSON。
  • 友好错误信息:向用户展示可理解的错误。
  • 隐藏敏感信息:不暴露堆栈、类名等。
  • 便于监控:记录关键异常日志。
  • 降低代码冗余:避免重复的 try-catch

1.2 Spring Boot 的异常处理机制

Spring Boot 基于 Spring MVC 的异常处理机制,主要通过以下注解实现:

注解 作用范围 说明
@ExceptionHandler 方法级别 处理当前 Controller 内的特定异常。
@ControllerAdvice 类级别 全局异常处理器,作用于所有 @Controller@RestController
@RestControllerAdvice 类级别 @ControllerAdvice + @ResponseBody,简化 REST API 的异常处理。

1.3 常见异常类型

异常类型 来源 示例
业务异常 业务逻辑 UserNotFoundException, OrderAlreadyPaidException
参数校验异常 @Valid MethodArgumentNotValidException, BindException
HTTP 相关异常 请求处理 HttpRequestMethodNotSupportedException, HttpMediaTypeNotSupportedException
系统/运行时异常 框架或代码 NullPointerException, DataAccessException
自定义异常 开发者定义 继承 RuntimeException 或自定义基类

二、详细操作步骤(适合快速实践)

步骤 1:定义统一的响应格式

创建一个通用的 API 响应体类,用于封装成功和失败的响应。

// ApiResponse.java
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ApiResponse<T> {
    private int code;        // 状态码
    private String message;  // 描述信息
    private T data;          // 返回数据(成功时)

    // 静态工厂方法
    public static <T> ApiResponse<T> success(T data) {
        return new ApiResponse<>(200, "OK", data);
    }

    public static <T> ApiResponse<T> error(int code, String message) {
        return new ApiResponse<>(code, message, null);
    }

    // 预定义错误码
    public static final int CODE_BAD_REQUEST = 400;
    public static final int CODE_UNAUTHORIZED = 401;
    public static final int CODE_FORBIDDEN = 403;
    public static final int CODE_NOT_FOUND = 404;
    public static final int CODE_INTERNAL_ERROR = 500;
    public static final int CODE_VALIDATION_ERROR = 422;
}

步骤 2:定义自定义业务异常

创建一个基类和具体的业务异常。

// BusinessException.java - 业务异常基类
public class BusinessException extends RuntimeException {
    private final int code;

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

    public BusinessException(ApiResponse.Code code) {
        super(code.getMessage());
        this.code = code.getCode();
    }

    public int getCode() {
        return code;
    }
}

// 预定义业务异常示例
public class UserNotFoundException extends BusinessException {
    public UserNotFoundException(String userId) {
        super(1001, "用户不存在: " + userId);
    }
}

public class OrderAlreadyPaidException extends BusinessException {
    public OrderAlreadyPaidException(String orderId) {
        super(1002, "订单已支付: " + orderId);
    }
}

步骤 3:创建全局异常处理器

使用 @RestControllerAdvice 创建全局异常处理器。

// GlobalExceptionHandler.java
@RestControllerAdvice
@Slf4j // 使用 Lombok 简化日志
public class GlobalExceptionHandler {

    // ==================== 1. 处理自定义业务异常 ====================
    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException ex) {
        log.warn("业务异常: code={}, message={}", ex.getCode(), ex.getMessage());
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(ApiResponse.error(ex.getCode(), ex.getMessage()));
    }

    // ==================== 2. 处理参数校验异常 ====================
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiResponse<Void>> handleValidationException(MethodArgumentNotValidException ex) {
        // 获取第一个错误信息
        String message = ex.getBindingResult().getFieldError().getDefaultMessage();
        log.warn("参数校验失败: {}", message);
        return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY) // 422
                .body(ApiResponse.error(ApiResponse.CODE_VALIDATION_ERROR, "参数错误: " + message));
    }

    @ExceptionHandler(BindException.class)
    public ResponseEntity<ApiResponse<Void>> handleBindException(BindException ex) {
        String message = ex.getBindingResult().getAllErrors().get(0).getDefaultMessage();
        log.warn("绑定异常: {}", message);
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(ApiResponse.error(ApiResponse.CODE_BAD_REQUEST, "绑定错误: " + message));
    }

    // ==================== 3. 处理 HTTP 相关异常 ====================
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    public ResponseEntity<ApiResponse<Void>> handleMethodNotSupportedException(HttpRequestMethodNotSupportedException ex) {
        String message = "请求方法不支持: " + ex.getMethod();
        log.warn(message);
        return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED)
                .body(ApiResponse.error(ApiResponse.CODE_BAD_REQUEST, message));
    }

    @ExceptionHandler(HttpMediaTypeNotSupportedException.class)
    public ResponseEntity<ApiResponse<Void>> handleMediaTypeNotSupportedException(HttpMediaTypeNotSupportedException ex) {
        String message = "媒体类型不支持: " + ex.getContentType();
        log.warn(message);
        return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE)
                .body(ApiResponse.error(ApiResponse.CODE_BAD_REQUEST, message));
    }

    @ExceptionHandler(NoHandlerFoundException.class)
    public ResponseEntity<ApiResponse<Void>> handleNoHandlerFoundException(NoHandlerFoundException ex) {
        String message = "请求的资源不存在: " + ex.getRequestURL();
        log.warn(message);
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
                .body(ApiResponse.error(ApiResponse.CODE_NOT_FOUND, message));
    }

    // ==================== 4. 处理系统/运行时异常 ====================
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiResponse<Void>> handleGenericException(Exception ex) {
        // 记录完整的堆栈信息,便于排查
        log.error("系统内部错误", ex);
        // 返回通用错误信息,避免暴露细节
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                .body(ApiResponse.error(ApiResponse.CODE_INTERNAL_ERROR, "服务器内部错误,请稍后重试"));
    }

    // ==================== 5. 处理 Spring Security 异常(可选) ====================
    // 如果使用了 Spring Security
    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<ApiResponse<Void>> handleAccessDeniedException(AccessDeniedException ex) {
        log.warn("权限不足: {}", ex.getMessage());
        return ResponseEntity.status(HttpStatus.FORBIDDEN)
                .body(ApiResponse.error(ApiResponse.CODE_FORBIDDEN, "权限不足"));
    }

    @ExceptionHandler(AuthenticationCredentialsNotFoundException.class)
    public ResponseEntity<ApiResponse<Void>> handleAuthException(AuthenticationCredentialsNotFoundException ex) {
        log.warn("未认证: {}", ex.getMessage());
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                .body(ApiResponse.error(ApiResponse.CODE_UNAUTHORIZED, "请先登录"));
    }
}

步骤 4:在 Controller 中抛出异常

在业务逻辑中直接抛出自定义异常,无需 try-catch

// UserController.java
@RestController
@RequestMapping("/api/users")
public class UserController {

    @Autowired
    private UserService userService;

    @GetMapping("/{id}")
    public ApiResponse<User> getUser(@PathVariable Long id) {
        // 业务逻辑中抛出异常
        User user = userService.findById(id)
                .orElseThrow(() -> new UserNotFoundException(id.toString()));
        return ApiResponse.success(user);
    }

    @PostMapping
    public ApiResponse<User> createUser(@Valid @RequestBody CreateUserRequest request) {
        // @Valid 触发校验,失败时抛出 MethodArgumentNotValidException
        User user = userService.create(request);
        return ApiResponse.success(user);
    }
}

步骤 5:配置 Spring Boot(可选)

确保 NoHandlerFoundException 能被捕获(默认 404 不会进入 DispatcherServlet)。

# application.yml
spring:
  mvc:
    throw-exception-if-no-handler-found: true # 当找不到处理器时抛出异常
  web:
    resources:
      add-mappings: false # 可选:禁用静态资源映射以避免干扰,根据需要设置

步骤 6:测试异常处理

启动应用并测试:

  1. 访问不存在的路径

    GET http://localhost:8080/api/invalid
    # 响应: 404 {"code":404,"message":"请求的资源不存在: /api/invalid","data":null}
    
  2. POST 无效数据

    POST /api/users
    {
      "name": "" // name 不能为空
    }
    
    // 响应: 422 {"code":422,"message":"参数错误: 名称不能为空","data":null}
    
  3. 获取不存在的用户

    GET http://localhost:8080/api/users/999
    # 响应: 400 {"code":1001,"message":"用户不存在: 999","data":null}
    
  4. 触发系统异常(如除零):

    GET http://localhost:8080/api/test-error
    # 响应: 500 {"code":500,"message":"服务器内部错误,请稍后重试","data":null}
    # 日志: ERROR ... System internal error [堆栈信息]
    

三、常见错误与解决方案

错误现象 原因分析 解决方案
自定义异常未被捕获 @ExceptionHandler 方法签名错误或异常类型不匹配 确保方法参数类型与要处理的异常完全匹配;检查 @RestControllerAdvice 是否被 Spring 扫描到(通常在主包下即可)
404 错误未进入全局处理器 throw-exception-if-no-handler-found 未开启 application.yml 中设置 spring.mvc.throw-exception-if-no-handler-found=true
响应格式不是 JSON 返回类型未使用 ResponseEntity 或缺少 @ResponseBody 使用 @RestControllerAdvice 或确保方法返回 ResponseEntity
敏感信息泄露 Exception 处理器返回了 ex.getMessage() Exception 处理器应返回通用错误信息,详细日志记录在 log.error()
多个 @ExceptionHandler 冲突 父类异常处理器覆盖了子类 将更具体的异常处理器放在前面;或确保继承关系正确处理

四、注意事项

  1. 异常处理器的优先级
    • 局部 @ExceptionHandler > 全局 @ControllerAdvice
    • 更具体的异常类型 > 更通用的异常类型(如 BusinessException > Exception)。
  2. 日志级别
    • BusinessException 通常用 WARN,因为是预期的业务错误。
    • ExceptionERROR,因为是未预期的系统错误。
  3. 错误码设计
    • 建议使用 4xx 表示客户端错误,5xx 表示服务端错误。
    • 业务错误码可自定义(如 1001, 1002),避免与 HTTP 状态码冲突。
  4. 不要捕获 Error
    • Error(如 OutOfMemoryError)通常无法恢复,不应在应用层处理。
  5. 性能影响
    • 异常处理本身开销很小,但频繁抛出异常会影响性能。避免用异常控制正常流程。

五、使用技巧

5.1 使用 @Validated 和分组校验

// 定义校验分组
public interface CreateValidation {}
public interface UpdateValidation {}

// 在 DTO 中使用分组
public class UserDto {
    @NotBlank(groups = {CreateValidation.class, UpdateValidation.class})
    private String name;

    @NotNull(groups = CreateValidation.class)
    private Integer age;
}

// 在 Controller 中指定分组
@PostMapping
public ApiResponse<User> createUser(@Validated(CreateValidation.class) @RequestBody UserDto dto) {
    // ...
}

5.2 返回多个校验错误

默认只返回第一个错误,可返回所有错误:

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Map<String, String>>> handleValidationException(MethodArgumentNotValidException ex) {
    Map<String, String> errors = new HashMap<>();
    ex.getBindingResult().getFieldErrors().forEach(error -> 
        errors.put(error.getField(), error.getDefaultMessage())
    );
    log.warn("参数校验失败: {}", errors);
    return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
            .body(ApiResponse.error(ApiResponse.CODE_VALIDATION_ERROR, "参数校验失败", errors));
}

5.3 结合 AOP 记录请求日志

@Aspect
@Component
@Slf4j
public class LoggingAspect {

    @Around("@annotation(org.springframework.web.bind.annotation.RequestMapping)")
    public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.currentTimeMillis();
        String methodName = joinPoint.getSignature().getName();
        try {
            Object result = joinPoint.proceed();
            log.info("{} executed in {} ms", methodName, System.currentTimeMillis() - start);
            return result;
        } catch (Exception e) {
            log.error("{} failed: {}", methodName, e.getMessage());
            throw e; // 异常仍会传递给全局处理器
        }
    }
}

5.4 使用 @ControllerAdvicebasePackages 限定范围

@RestControllerAdvice(basePackages = "com.example.api")
// 只处理 com.example.api 包下的 Controller
public class ApiExceptionHandler {
    // ...
}

六、最佳实践

  1. 定义清晰的错误码体系
    • 与前端、客户端约定统一的错误码。
  2. 提供有意义的错误信息
    • 用户看到的信息应友好、可操作。
  3. 详细日志,简洁响应
    • 日志记录完整堆栈和上下文;响应只返回必要信息。
  4. 避免异常泛滥
    • Optional 或返回值处理可预期的“失败”。
  5. 文档化错误响应
    • 在 API 文档(如 Swagger)中说明可能的错误码。
  6. 监控关键异常
    • ERROR 级别日志接入监控系统(如 ELK、Prometheus)。

七、性能优化

  1. 减少异常抛出
    • 异常创建和堆栈填充有开销。用条件判断替代异常控制流。
  2. 异步日志记录
    • 确保日志框架(如 Logback、Log4j2)配置了异步 Appender,避免阻塞主线程。
  3. 避免在异常处理器中做耗时操作
    • 如发送邮件、调用外部服务。应记录日志后立即返回,耗时操作异步处理。
  4. 使用缓存
    • 如果异常处理涉及复杂计算,可考虑缓存结果(但异常通常不频繁)。

总结

Spring Boot 的异常统一处理通过 @RestControllerAdvice + @ExceptionHandler 实现,是构建健壮 REST API 的必备技能。

核心要点

  • 定义统一的响应格式(ApiResponse)。
  • 创建自定义业务异常(BusinessException)。
  • 编写全局异常处理器,覆盖各类异常。
  • 在业务代码中直接抛出异常,由框架统一处理。

推荐流程

  1. 设计错误码和响应结构。
  2. 实现 GlobalExceptionHandler
  3. 在业务中抛出 BusinessException
  4. 测试各种异常场景。

本文基于 Spring Boot 3.x 编写。