4. 接口异常处理:全局异常处理器设计,业务异常与系统异常分离,友好的错误信息返回

异常处理这件事,说简单也简单,说复杂也复杂。我见过太多项目,异常信息直接堆到前端,用户看到一串莫名其妙的堆栈——"NullPointerException at line 137"。这体验,你自己想想看,能好吗?

这一章,我们就来聊聊怎么把异常处理做得干净、优雅。核心就三件事:全局拦截、异常分类、友好返回。

4.1 为什么需要全局异常处理器?

没有全局异常处理器的时候,每个接口都得自己 try-catch。代码里到处都是重复的异常处理逻辑,看着就头疼。

没有全局处理的痛点:

  • 每个 Controller 方法都要写 try-catch,代码冗余严重
  • 异常返回格式不统一,前端解析困难
  • 系统异常(如 NullPointerException)直接暴露给用户,存在安全风险
  • 日志记录分散,排查问题效率低

我在项目中遇到过这样一个场景:一个团队 8 个人同时开发,每个人返回的异常格式都不一样。有人返回 {code: 500, msg: "错了"},有人返回 {error: "系统异常"}。前端同学每次对接新接口都得重新适配,气得直拍桌子。

全局异常处理器,说白了就是用一个 @ControllerAdvice 注解,把所有的异常拦截集中到一个地方处理。统一格式、统一日志、统一返回。

4.2 业务异常与系统异常分离

为什么要分离?因为这两种异常的性质完全不同。

维度 业务异常 系统异常
触发原因 业务规则不满足(如余额不足) 系统故障(如数据库连接失败)
可预测性 可预测,属于正常业务流程 不可预测,属于意外情况
返回信息 可向用户展示具体原因 只返回"系统繁忙",不暴露细节
HTTP 状态码 4xx(如 400、403) 5xx(如 500、503)
日志级别 WARN ERROR

我建议的做法是:自定义一个 BusinessException,继承 RuntimeException。系统异常则直接使用框架自带的异常,或者自定义一个 SystemException。

// 业务异常基类
public class BusinessException extends RuntimeException {
    private final Integer code;
    private final String message;

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

    public Integer getCode() {
        return code;
    }

    @Override
    public String getMessage() {
        return message;
    }
}

// 系统异常基类
public class SystemException extends RuntimeException {
    private final Integer code;
    private final String message;

    public SystemException(Integer code, String message, Throwable cause) {
        super(message, cause);
        this.code = code;
        this.message = message;
    }

    public Integer getCode() {
        return code;
    }

    @Override
    public String getMessage() {
        return message;
    }
}

我的习惯:业务异常用 4xx 开头(如 40001、40002),系统异常用 5xx 开头(如 50001、50002)。这样一看错误码就知道是哪类问题,排查起来快很多。

4.3 统一返回结果封装

异常处理的结果,必须和正常返回的结果格式一致。前端才能统一处理。

public class ApiResult<T> {
    private Integer code;
    private String message;
    private T data;
    private Long timestamp;

    // 成功
    public static <T> ApiResult<T> success(T data) {
        ApiResult<T> result = new ApiResult<>();
        result.code = 200;
        result.message = "success";
        result.data = data;
        result.timestamp = System.currentTimeMillis();
        return result;
    }

    // 失败
    public static <T> ApiResult<T> error(Integer code, String message) {
        ApiResult<T> result = new ApiResult<>();
        result.code = code;
        result.message = message;
        result.data = null;
        result.timestamp = System.currentTimeMillis();
        return result;
    }

    // getters and setters...
}

4.4 全局异常处理器实现

核心代码来了。用 @ControllerAdvice 配合 @ExceptionHandler,把不同类型的异常分门别类处理。

@ControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    // 处理业务异常
    @ExceptionHandler(BusinessException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ApiResult<Void> handleBusinessException(BusinessException e) {
        log.warn("业务异常: code={}, message={}", e.getCode(), e.getMessage());
        return ApiResult.error(e.getCode(), e.getMessage());
    }

    // 处理系统异常
    @ExceptionHandler(SystemException.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ApiResult<Void> handleSystemException(SystemException e) {
        log.error("系统异常: code={}, message={}", e.getCode(), e.getMessage(), e);
        return ApiResult.error(e.getCode(), "系统繁忙,请稍后重试");
    }

    // 处理参数校验异常
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ApiResult<Void> handleValidationException(MethodArgumentNotValidException e) {
        String message = e.getBindingResult().getFieldErrors().stream()
                .map(error -> error.getField() + ": " + error.getDefaultMessage())
                .collect(Collectors.joining(", "));
        log.warn("参数校验异常: {}", message);
        return ApiResult.error(40001, message);
    }

    // 处理未知异常(兜底)
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ApiResult<Void> handleException(Exception e) {
        log.error("未知异常: ", e);
        return ApiResult.error(50000, "系统繁忙,请稍后重试");
    }
}

注意:兜底的 Exception.class 一定要放在最后。Spring 会按异常类型的继承关系匹配,越具体的异常越优先处理。我曾经见过有人把 Exception 放在最前面,结果所有异常都被它拦截了,业务异常的逻辑根本没走到。

4.5 异常处理流程总览

下面这张图,把整个异常处理的流程串起来了。你一看就明白。

全局异常处理流程 客户端请求 Controller 处理 正常返回 ApiResult 抛出异常 全局异常处理器 业务异常 系统异常 参数校验异常 统一 ApiResult 返回

4.6 友好的错误信息设计

错误信息怎么写,其实有讲究。我总结了几条原则:

  • 对用户友好:不说"参数校验失败",说"请输入正确的手机号格式"
  • 对开发友好:错误码要能快速定位问题,比如 40001 表示参数校验失败
  • 不暴露敏感信息:系统异常只返回"系统繁忙",不暴露数据库表名、IP 地址等
  • 支持国际化:错误信息最好从资源文件读取,方便后续做多语言

我曾经踩过的坑:有个项目把 SQL 异常信息直接返回给前端,结果用户看到了数据库表名和字段名。虽然不是什么机密,但总觉得不专业。从那以后,我所有系统异常都统一返回"系统繁忙,请稍后重试",具体错误只记日志。

4.7 单元测试:验证异常处理

全局异常处理器写好了,怎么保证它正常工作?写单元测试。

@SpringBootTest
@AutoConfigureMockMvc
public class GlobalExceptionHandlerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    public void testBusinessException() throws Exception {
        mockMvc.perform(get("/api/user/100")
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value(40001))
                .andExpect(jsonPath("$.message").value("用户不存在"));
    }

    @Test
    public void testSystemException() throws Exception {
        mockMvc.perform(get("/api/order/list")
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isInternalServerError())
                .andExpect(jsonPath("$.code").value(50001))
                .andExpect(jsonPath("$.message").value("系统繁忙,请稍后重试"));
    }

    @Test
    public void testValidationException() throws Exception {
        mockMvc.perform(post("/api/user")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"email\": \"invalid-email\"}"))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.code").value(40001));
    }
}

测试要点:

  • 每种异常类型至少写一个测试用例
  • 验证 HTTP 状态码是否正确
  • 验证返回的 code 和 message 是否符合预期
  • 系统异常要验证敏感信息没有被泄露

嗯,到这里,全局异常处理的核心内容就讲完了。你想想看,有了这套机制,你的接口是不是干净多了?每个 Controller 只需要关注正常流程,异常处理全部交给全局处理器。代码清爽,维护也方便。