先讲 Spring Boot 全局异常处理的简洁实现思路,再结合苍穹外卖项目代码讲解。
零基础预备
如果你刚接触后端,先记住下面 4 句话:
- 异常是什么 :程序运行到某一步失败了(比如密码不对、数据重复),会
throw一个异常对象。 - 为什么不在每个接口里 try-catch :重复、啰嗦、易漏。更推荐
业务处抛异常,统一处处理异常。 @RestControllerAdvice是什么:给所有 Controller 做"统一增强",其中一个能力就是集中处理异常。@ExceptionHandler是什么:告诉 Spring"这个方法专门处理哪一类异常"。
一个最小例子(不依赖苍穹外卖项目)
java
// 业务层:只负责抛出语义明确的异常
if (!passwordOk) {
throw new RuntimeException("密码错误");
}
java
// 全局处理层:统一把异常变成前端可读 JSON
@RestControllerAdvice
public class DemoExceptionHandler {
@ExceptionHandler(RuntimeException.class)
public Map<String, Object> handle(RuntimeException ex) {
return Map.of("code", 0, "msg", ex.getMessage());
}
}
你可以先把它理解成一句话:
业务代码负责"发现错误并抛出",全局处理器负责"把错误翻译成统一返回格式"。
一、Spring Boot 的简洁实现思路
我们不希望每个 Controller 都写重复的 try-catch,而是把异常统一转成前端可识别的 JSON。
最小实现只需要 3 个点:
- 统一返回结构(如
Result) - 业务异常体系(如
BaseException+ 子类) - 全局异常处理器(
@RestControllerAdvice+@ExceptionHandler)
运行流程
sequenceDiagram
participant Controller
participant Service
participant DispatcherServlet
participant ExceptionResolver
participant GlobalExceptionHandler
participant 浏览器
Controller->>Service: 调用业务方法
Service-->>DispatcherServlet: throw BaseException("密码错误")
DispatcherServlet->>ExceptionResolver: 触发异常解析链
ExceptionResolver->>GlobalExceptionHandler: 匹配 @ExceptionHandler
GlobalExceptionHandler->>浏览器: Result.error("密码错误")
- 业务层只负责"什么时候抛错、抛什么错"
- 全局处理器负责"返回给前端什么格式"
- 前端只认一种失败结构,不需要针对每个接口写不同解析逻辑
最小代码骨架(通用写法)
java
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BaseException.class)
public Result<?> handleBase(BaseException ex) {
return Result.error(ex.getMessage());
}
@ExceptionHandler(Exception.class)
public Result<?> handleUnknown(Exception ex) {
return Result.error("系统繁忙,请稍后重试");
}
}
核心思想:错误展示是横切关注点,应集中处理,而不是散落在每个接口里。
二、苍穹外卖项目是怎么实现的
2.1 业务场景
| 场景 | 谁抛异常 | 用户看到什么(示意) |
|---|---|---|
| 登录时用户名不存在 | AccountNotFoundException |
账号不存在类提示 |
| 登录密码错误 | PasswordErrorException |
密码错误 |
| 账号被禁用 | AccountLockedException |
账号锁定 |
| 新增员工用户名重复 | SQLIntegrityConstraintViolationException |
"xxx已存在"类文案 |
| 删除起售中的菜品 | DeletionNotAllowedException |
起售中不能删 |
这些异常最终都应在 Result 中以统一 JSON 返回(业务失败多为 HTTP 200 + code=0)。
抛异常位置(Service)
java
if (employee == null) {
// 账号不存在
throw new AccountNotFoundException(MessageConstant.ACCOUNT_NOT_FOUND);
}
...
if (!password.equals(employee.getPassword())) {
// 密码错误
throw new PasswordErrorException(MessageConstant.PASSWORD_ERROR);
}
if (employee.getStatus() == StatusConstant.DISABLE) {
// 账号被锁定
throw new AccountLockedException(MessageConstant.ACCOUNT_LOCKED);
}
java
for (Long id : ids) {
Dish dish = dishMapper.getById(id);
if (dish.getStatus() == StatusConstant.ENABLE) {
// 当前菜品处于起售中,不能删除
throw new DeletionNotAllowedException(MessageConstant.DISH_ON_SALE);
}
}
List<Long> setmealIds = setmealDishMapper.getSetmealIdsByDishIds(ids);
if (setmealIds != null && setmealIds.size() > 0) {
throw new DeletionNotAllowedException(MessageConstant.DISH_BE_RELATED_BY_SETMEAL);
}
统一返回结构(Result)
java
@Data
public class Result<T> implements Serializable {
private Integer code; // 编码:1 成功,0 和其它数字为失败
private String msg; // 错误信息
private T data; // 返回数据
public static <T> Result<T> error(String msg) {
Result result = new Result();
result.msg = msg;
result.code = 0;
return result;
}
}
约定:前端通常判断 code == 1 成功,否则读取 msg 提示。
业务异常基类(BaseException)
java
package com.sky.exception;
/**
* 业务异常
*/
public class BaseException extends RuntimeException {
public BaseException() {
}
public BaseException(String msg) {
super(msg);
}
}
项目中很多异常都继承它(如 AccountNotFoundException、PasswordErrorException、OrderBusinessException)。 这样全局处理器可以一次覆盖多数业务异常。
全局处理器
java
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler
public Result exceptionHandler(BaseException ex){
log.error("异常信息:{}", ex.getMessage());
return Result.error(ex.getMessage());
}
@ExceptionHandler
public Result exceptionHandler(SQLIntegrityConstraintViolationException ex){
String message = ex.getMessage();
if(message.contains("Duplicate entry")){
String[] split = message.split(" ");
String username = split[2];
String msg = username + MessageConstant.ALREADY_EXISTS;
return Result.error(msg);
}else{
return Result.error(MessageConstant.UNKNOWN_ERROR);
}
}
}
当前仓库里显式处理了两类异常:
BaseException:返回业务可读文案SQLIntegrityConstraintViolationException:把重复键英文报错转成"已存在"提示
执行顺序可以概括为:
- Service 抛出
BaseException子类 - Spring 匹配到
GlobalExceptionHandler对应方法 - 返回
Result.error(...) - 前端统一按
Result结构展示提示
三、业务错误与 401 的区别(容易混淆)
| 类型 | 典型来源 | HTTP 层 | 前端处理 |
|---|---|---|---|
| 未登录 / token 无效 | JwtTokenAdminInterceptor |
401 | 跳转登录页 |
| 已登录但业务不允许 | BaseException |
多为 200 + Result.code=0 |
弹出业务提示 |
不要混用,否则前端无法区分"重新登录"和"业务失败"。
四、注意事项与可选增强
- 不要吞异常:捕获后至少记录日志。
- 生产环境建议有兜底处理器:
@ExceptionHandler(Exception.class)。 - 建议记录堆栈:
log.error("...", ex),排障比只打 message 更有效。 - SQL 重复键字符串解析较脆弱,生产可考虑错误码映射或约束名规范。
- 异常不要滥用做流程控制,能正常分支表达的就不要抛异常。
总结
@RestControllerAdvice + @ExceptionHandler 把"错误返回格式"集中到一个地方治理。
业务代码专注抛出语义明确的异常(如 BaseException 子类),全局处理器负责统一转成 Result 返回前端。
附录:相关源码路径
css
sky-server/src/main/java/com/sky/handler/GlobalExceptionHandler.java
sky-common/src/main/java/com/sky/exception/BaseException.java
sky-common/src/main/java/com/sky/result/Result.java
sky-common/src/main/java/com/sky/exception/*.java
参考
苍穹外卖www.bilibili.com/video/BV1TP...
deekseek-v4