一、核心概念
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:测试异常处理
启动应用并测试:
访问不存在的路径:
GET http://localhost:8080/api/invalid # 响应: 404 {"code":404,"message":"请求的资源不存在: /api/invalid","data":null}
POST 无效数据:
POST /api/users { "name": "" // name 不能为空 }
// 响应: 422 {"code":422,"message":"参数错误: 名称不能为空","data":null}
获取不存在的用户:
GET http://localhost:8080/api/users/999 # 响应: 400 {"code":1001,"message":"用户不存在: 999","data":null}
触发系统异常(如除零):
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 冲突 |
父类异常处理器覆盖了子类 | 将更具体的异常处理器放在前面;或确保继承关系正确处理 |
四、注意事项
- 异常处理器的优先级:
- 局部
@ExceptionHandler
> 全局@ControllerAdvice
。 - 更具体的异常类型 > 更通用的异常类型(如
BusinessException
>Exception
)。
- 局部
- 日志级别:
BusinessException
通常用WARN
,因为是预期的业务错误。Exception
用ERROR
,因为是未预期的系统错误。
- 错误码设计:
- 建议使用
4xx
表示客户端错误,5xx
表示服务端错误。 - 业务错误码可自定义(如
1001
,1002
),避免与 HTTP 状态码冲突。
- 建议使用
- 不要捕获
Error
:Error
(如OutOfMemoryError
)通常无法恢复,不应在应用层处理。
- 性能影响:
- 异常处理本身开销很小,但频繁抛出异常会影响性能。避免用异常控制正常流程。
五、使用技巧
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 使用 @ControllerAdvice
的 basePackages
限定范围
@RestControllerAdvice(basePackages = "com.example.api")
// 只处理 com.example.api 包下的 Controller
public class ApiExceptionHandler {
// ...
}
六、最佳实践
- 定义清晰的错误码体系:
- 与前端、客户端约定统一的错误码。
- 提供有意义的错误信息:
- 用户看到的信息应友好、可操作。
- 详细日志,简洁响应:
- 日志记录完整堆栈和上下文;响应只返回必要信息。
- 避免异常泛滥:
- 用
Optional
或返回值处理可预期的“失败”。
- 用
- 文档化错误响应:
- 在 API 文档(如 Swagger)中说明可能的错误码。
- 监控关键异常:
- 将
ERROR
级别日志接入监控系统(如 ELK、Prometheus)。
- 将
七、性能优化
- 减少异常抛出:
- 异常创建和堆栈填充有开销。用条件判断替代异常控制流。
- 异步日志记录:
- 确保日志框架(如 Logback、Log4j2)配置了异步 Appender,避免阻塞主线程。
- 避免在异常处理器中做耗时操作:
- 如发送邮件、调用外部服务。应记录日志后立即返回,耗时操作异步处理。
- 使用缓存:
- 如果异常处理涉及复杂计算,可考虑缓存结果(但异常通常不频繁)。
总结
Spring Boot 的异常统一处理通过 @RestControllerAdvice
+ @ExceptionHandler
实现,是构建健壮 REST API 的必备技能。
核心要点:
- 定义统一的响应格式(
ApiResponse
)。 - 创建自定义业务异常(
BusinessException
)。 - 编写全局异常处理器,覆盖各类异常。
- 在业务代码中直接抛出异常,由框架统一处理。
推荐流程:
- 设计错误码和响应结构。
- 实现
GlobalExceptionHandler
。 - 在业务中抛出
BusinessException
。 - 测试各种异常场景。
本文基于 Spring Boot 3.x 编写。