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 异常处理流程总览
下面这张图,把整个异常处理的流程串起来了。你一看就明白。
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 只需要关注正常流程,异常处理全部交给全局处理器。代码清爽,维护也方便。