4. 实现全局异常处理器统一API错误格式

实现全局异常处理器统一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 响应:

HTTP 请求 GET /api/users/99 UserController 调用 Service UserService findById(99) throw BusinessException code=40001 异常传播 @RestControllerAdvice GlobalExceptionHandler BusinessException → 40001 / 400 MethodArgumentNotValid → 40003 / 400 Exception(兜底) → 50000 / 500 统一 JSON 响应体 ApiError { "code": 40001, "message": "用户不存在: id=99", "path": "/api/users/99", "timestamp": 1710000000000 } 返回客户端 HTTP 400 + 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。