4. 实现全局异常处理器统一API错误格式
为什么需要全局异常处理器
在 Spring MVC 中,如果 Controller 或 Service 抛出异常而没有统一处理,Spring Boot 默认会返回 Whitelabel Error Page 或默认的 JSON 错误结构,字段名、错误码、HTTP 状态码都不受业务控制。前端拿到不同接口的异常响应格式不一致,很难做统一提示。本节的目标是:自定义业务异常 + @RestControllerAdvice 全局拦截 + 统一 JSON 错误响应体,让所有异常出口都收敛到同一个结构。
统一错误响应结构设计
先约定一个稳定的响应体,字段固定为 code(业务错误码)、message(可读消息)、path(出错请求路径)、timestamp(发生时间)。这样前端只需判断 code 即可,不必解析 HTTP 状态码文本。
public class ApiError {
private int code; // 业务错误码,如 40001
private String message; // 面向调用方的错误描述
private String path; // 触发异常的请求路径
private long timestamp; // 毫秒时间戳
public ApiError(int code, String message, String path) {
this.code = code;
this.message = message;
this.path = path;
this.timestamp = System.currentTimeMillis();
}
// getter / setter 省略
}
定义业务异常类
业务异常需要携带错误码,并继承 RuntimeException,避免在方法签名上强制 throws。
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;
}
}
再定义一个错误码常量类,避免魔法数字散落各处:
public final class ErrorCode {
public static final int USER_NOT_FOUND = 40001;
public static final int USER_ALREADY_EXISTS = 40002;
public static final int PARAM_INVALID = 40003;
public static final int INTERNAL_ERROR = 50000;
private ErrorCode() {}
}
编写全局异常处理器
使用 @RestControllerAdvice 拦截所有 Controller 抛出的异常,并通过 @ExceptionHandler 分别处理业务异常、参数校验异常和兜底异常。注意:@RestControllerAdvice 等价于 @ControllerAdvice + @ResponseBody,返回值会自动序列化为 JSON。
@RestControllerAdvice
public class GlobalExceptionHandler {
// 1. 业务异常:使用异常自带的错误码
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiError> handleBusiness(BusinessException ex,
HttpServletRequest request) {
ApiError body = new ApiError(ex.getCode(), ex.getMessage(), request.getRequestURI());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
}
// 2. 参数校验异常:收集字段错误信息
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleValidation(MethodArgumentNotValidException ex,
HttpServletRequest request) {
String msg = ex.getBindingResult().getFieldErrors().stream()
.map(f -> f.getField() + ": " + f.getDefaultMessage())
.collect(Collectors.joining("; "));
ApiError body = new ApiError(ErrorCode.PARAM_INVALID, msg, request.getRequestURI());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
}
// 3. 兜底异常:不暴露堆栈细节
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleOther(Exception ex,
HttpServletRequest request) {
ApiError body = new ApiError(ErrorCode.INTERNAL_ERROR,
"服务器内部错误", request.getRequestURI());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
}
}
在业务代码中抛出异常
Service 层只需抛出 BusinessException,无需关心 HTTP 状态码和响应格式:
@Service
public class UserService {
public User findById(Long id) {
User user = userRepository.findById(id).orElse(null);
if (user == null) {
throw new BusinessException(ErrorCode.USER_NOT_FOUND, "用户不存在: id=" + id);
}
return user;
}
}
异常处理链路
下图展示了请求进入 Controller 后,异常如何被逐层拦截并最终转换为统一 JSON 响应:
验证与结果判断
启动应用后,用 curl 或 Postman 请求一个不存在的用户,观察返回的 HTTP 状态码和 JSON 体:
curl -i http://localhost:8080/api/users/99
预期响应(示例):
HTTP/1.1 400 Bad Request
Content-Type: application/json
{
"code": 40001,
"message": "用户不存在: id=99",
"path": "/api/users/99",
"timestamp": 1710000000000
}
验证要点:
- HTTP 状态码为 400(业务异常)或 500(兜底异常),而非默认的 500 Whitelabel。
- 响应体字段固定为 code / message / path / timestamp,前端可统一解析。
- 参数校验失败时,message 中包含具体字段名和校验提示,便于定位问题。
- 兜底异常不返回堆栈信息,避免泄露内部实现细节。
常见坑:
- 如果
@ExceptionHandler方法返回ApiError而不是ResponseEntity<ApiError>,HTTP 状态码会固定为 200,前端无法通过状态码区分成功与失败。 - 多个
@ExceptionHandler存在继承关系时,Spring 会选择最具体的异常类型,兜底Exception.class必须放在最后逻辑上,但方法声明顺序不影响匹配。 - 如果同时存在
@ControllerAdvice和@RestControllerAdvice,注意@ResponseBody是否生效,否则返回的是视图名而非 JSON。