Spring Boot 全局异常处理:原理与实践

1. 为什么需要全局异常处理

一个接口正常返回时,通常会返回统一格式:

css 复制代码
{
  "code": 0,
  "msg": "success",
  "data": {}
}

但是接口出错时,如果不做统一处理,返回结果可能就会变得很混乱。有的地方返回:

json 复制代码
{
  "message": "用户不存在"
}

有的地方返回:

json 复制代码
{
  "error": "Internal Server Error",
  "status": 500
}

有的地方甚至直接把 Java 异常堆栈返回给前端。这会带来几个问题:第一,前端不好处理,前端本来只需要判断 code,现在却要适配各种错误格式;第二,项目规范不统一,不同开发人员写出来的接口,错误返回结构可能不一样;第三,系统内部信息可能泄露,比如 SQL、类名、文件路径、服务器路径,都不应该直接返回给外部。所以,全局异常处理的第一个目的,是维持统一响应结构。

例如:

json 复制代码
{
  "code": 400,
  "msg": "请求参数不正确:用户ID不能为空",
  "data": null
}

或者:

json 复制代码
{
  "code": 500,
  "msg": "系统异常,请联系管理员",
  "data": null
}

它的第二个目的,是让业务代码更干净。业务代码只需要关心业务本身:

sql 复制代码
public User getUser(Long id) {
    User user = userMapper.selectById(id);

    if (user == null) {
        throw new ServiceException(1001001, "用户不存在");
    }

    return user;
}

Controller 也只需要正常返回:

less 复制代码
@GetMapping("/user/{id}")
public CommonResult<User> getUser(@PathVariable Long id) {
    return CommonResult.success(userService.getUser(id));
}

至于异常怎么转成 JSON,交给全局异常处理器。这就是全局异常处理的核心价值:业务逻辑、异常转换、响应格式三者分离。


2. 最小可用示例

先看一个最小版本。

kotlin 复制代码
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ServiceException.class)
    public CommonResult<?> handleServiceException(ServiceException ex) {
        return CommonResult.error(ex.getCode(), ex.getMessage());
    }

    @ExceptionHandler(Exception.class)
    public CommonResult<?> handleException(Exception ex) {
        return CommonResult.error(500, "系统异常,请联系管理员");
    }
}

业务异常:

scala 复制代码
public class ServiceException extends RuntimeException {

    private final Integer code;

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

    public Integer getCode() {
        return code;
    }
}

统一返回对象:

typescript 复制代码
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CommonResult<T> {

    private Integer code;

    private String msg;

    private T data;

    public static <T> CommonResult<T> success(T data) {
        return new CommonResult<>(0, "success", data);
    }

    public static <T> CommonResult<T> error(Integer code, String msg) {
        return new CommonResult<>(code, msg, null);
    }
}

这样,业务代码可以直接抛异常:

csharp 复制代码
if (user == null) {
    throw new ServiceException(1001001, "用户不存在");
}

最终返回给前端的是统一 JSON,而不是 Java 异常堆栈。


3. 原理:Spring MVC 是怎么处理异常的

很多人以为 @RestControllerAdvice 就是异常处理的核心,但这个理解并不准确。Spring MVC 真正负责异常处理的核心组件叫:

复制代码
HandlerExceptionResolver

Spring 官方文章也明确说明,DispatcherServlet 应用上下文中实现了 HandlerExceptionResolver 的 Bean,会被用来拦截和处理 MVC 系统中未被 Controller 自己处理的异常。Spring MVC 默认还会创建一组异常解析器,例如 ExceptionHandlerExceptionResolverResponseStatusExceptionResolverDefaultHandlerExceptionResolver,并通过组合链的方式依次处理异常。(Home)

一次请求的大致流程可以理解为:

css 复制代码
浏览器请求
   ↓
Filter
   ↓
DispatcherServlet
   ↓
HandlerMapping 找到 Controller 方法
   ↓
HandlerAdapter 调用 Controller 方法
   ↓
Controller 执行业务代码
   ↓
如果抛出异常
   ↓
HandlerExceptionResolver 解析异常
   ↓
返回 ModelAndView 或 JSON 响应

所以,Controller 抛出异常后,并不是 @RestControllerAdvice 自己"主动捕获"了异常。更准确地说,是 Spring MVC 在调用 Controller 的过程中发现异常,然后交给 HandlerExceptionResolver 体系处理。其中,和 @ExceptionHandler@ControllerAdvice 关系最密切的是:

复制代码
ExceptionHandlerExceptionResolver

它会查找可以处理当前异常的方法。比如:

kotlin 复制代码
@ExceptionHandler(ServiceException.class)
public CommonResult<?> handleServiceException(ServiceException ex) {
    return CommonResult.error(ex.getCode(), ex.getMessage());
}

如果 Controller 抛出了 ServiceException,Spring MVC 就会找到这个方法,并调用它。如果没有找到更具体的异常处理方法,才会走更通用的:

php 复制代码
@ExceptionHandler(Exception.class)
public CommonResult<?> handleException(Exception ex) {
    return CommonResult.error(500, "系统异常");
}

所以,异常处理的匹配逻辑是:优先找更具体的异常类型,再找更通用的异常类型。


3.1 @ExceptionHandler@ControllerAdvice@RestControllerAdvice 的关系

先看 @ExceptionHandler,它的作用是声明一个方法,用来处理某类异常。

kotlin 复制代码
@ExceptionHandler(IllegalArgumentException.class)
public CommonResult<?> handleIllegalArgumentException(IllegalArgumentException ex) {
    return CommonResult.error(400, ex.getMessage());
}

这个方法可以写在 Controller 里,也可以写在全局异常处理类里。如果写在 Controller 里,它只处理当前 Controller 抛出的异常。Spring 官方文章中也提到,Controller 内部可以定义 @ExceptionHandler 方法,用来处理当前 Controller 请求处理方法抛出的异常。(Home)

例如:

less 复制代码
@RestController
@RequestMapping("/order")
public class OrderController {

    @GetMapping("/{id}")
    public CommonResult<?> getOrder(@PathVariable Long id) {
        throw new IllegalArgumentException("订单ID不正确");
    }

    @ExceptionHandler(IllegalArgumentException.class)
    public CommonResult<?> handleIllegalArgumentException(IllegalArgumentException ex) {
        return CommonResult.error(400, "订单接口参数错误:" + ex.getMessage());
    }
}

这个写法适合少数特殊场景。如果希望异常处理规则对所有 Controller 生效,就要使用:

css 复制代码
@ControllerAdvice

@ControllerAdvice 可以把异常处理逻辑应用到整个应用,而不是某一个 Controller。官方文章也说明,@ControllerAdvice 中可以定义 @ExceptionHandler 方法,从而处理任意 Controller 抛出的异常。(Home) 但是 @ControllerAdvice 默认更偏向传统 MVC,可以返回页面。前后端分离项目里,后端通常返回 JSON,所以一般使用:

css 复制代码
@RestControllerAdvice

它可以简单理解为:

less 复制代码
@ControllerAdvice
@ResponseBody

也就是说:

kotlin 复制代码
@RestControllerAdvice
public class GlobalExceptionHandler {

}

等价于告诉 Spring:这个类里面的方法,是全局增强逻辑;如果方法有返回值,直接写入响应体;通常会被转成 JSON 返回给前端。


4. 实践建议:项目里应该怎么写

4.1 为什么不建议 Controller 里到处写 try-catch

不推荐这样写:

less 复制代码
@GetMapping("/user/{id}")
public CommonResult<?> getUser(@PathVariable Long id) {
    try {
        User user = userService.getUser(id);
        return CommonResult.success(user);
    } catch (Exception e) {
        return CommonResult.error(500, e.getMessage());
    }
}

这种代码看起来直观,但问题很多:第一,重复,每个接口都要写一遍 try-catch;第二,破坏 Controller 的职责,Controller 本来应该负责接收请求、调用业务、返回结果,异常转换不应该散落在每个接口里;第三,容易导致错误格式不一致,有的人返回 e.getMessage(),有的人返回固定文案,有的人返回不同结构,最后前端很难统一处理;第四,可能泄露内部信息,e.getMessage() 里可能包含数据库错误、SQL 片段、内部类名、服务器路径,这些都不应该直接暴露给用户。更推荐的写法是:

less 复制代码
@GetMapping("/user/{id}")
public CommonResult<User> getUser(@PathVariable Long id) {
    return CommonResult.success(userService.getUser(id));
}

业务层:

sql 复制代码
public User getUser(Long id) {
    User user = userMapper.selectById(id);

    if (user == null) {
        throw new ServiceException(1001001, "用户不存在");
    }

    return user;
}

异常处理器:

kotlin 复制代码
@ExceptionHandler(ServiceException.class)
public CommonResult<?> handleServiceException(ServiceException ex) {
    return CommonResult.error(ex.getCode(), ex.getMessage());
}

这时,各层职责就比较清楚:

复制代码
Controller:处理 HTTP 请求
Service:处理业务规则
ServiceException:表达业务失败
GlobalExceptionHandler:把异常转换成统一响应
CommonResult:定义统一返回结构

4.2 为什么企业项目要定义业务异常

项目里的异常大体可以分成两类。一类是业务异常。

例如:

复制代码
用户不存在
余额不足
订单已关闭
手机号已被注册
当前用户无权操作该数据

这些异常是可预期的,它们不是系统坏了,而是业务规则不允许继续执行。另一类是系统异常。

例如:

复制代码
NullPointerException
数据库连接失败
Redis 超时
文件读写失败
第三方接口不可用

这些异常通常是不可预期的,说明系统运行过程中出现了问题。所以企业项目一般会定义一个业务异常类:

scala 复制代码
public class ServiceException extends RuntimeException {

    private final Integer code;

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

    public Integer getCode() {
        return code;
    }
}

也可以进一步抽象错误码:

csharp 复制代码
public interface ErrorCode {

    Integer getCode();

    String getMsg();
}

例如:

less 复制代码
@Getter
@AllArgsConstructor
public enum UserErrorCode implements ErrorCode {

    USER_NOT_EXISTS(1001001, "用户不存在"),
    USER_DISABLED(1001002, "用户已被禁用");

    private final Integer code;

    private final String msg;
}

再提供一个工具方法:

typescript 复制代码
public class ServiceExceptionUtil {

    public static ServiceException exception(ErrorCode errorCode) {
        return new ServiceException(errorCode.getCode(), errorCode.getMsg());
    }
}

业务代码就可以写成:

ini 复制代码
if (user == null) {
    throw ServiceExceptionUtil.exception(UserErrorCode.USER_NOT_EXISTS);
}

这种写法的好处是:错误码集中管理,业务代码只表达失败原因,最终响应格式由全局异常处理器统一转换。


4.3 一份比较完整的全局异常处理器

下面是一份比较常见的实践版本。

typescript 复制代码
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    /**
     * 业务异常
     */
    @ExceptionHandler(ServiceException.class)
    public CommonResult<?> handleServiceException(ServiceException ex) {
        log.warn("业务异常:code={}, msg={}", ex.getCode(), ex.getMessage());
        return CommonResult.error(ex.getCode(), ex.getMessage());
    }

    /**
     * 参数校验异常:@RequestBody + @Valid
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public CommonResult<?> handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) {
        String message = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .findFirst()
                .map(FieldError::getDefaultMessage)
                .orElse("请求参数不正确");

        log.warn("请求参数校验失败:{}", message);
        return CommonResult.error(400, message);
    }

    /**
     * 参数绑定异常:普通表单参数、Query 参数绑定失败
     */
    @ExceptionHandler(BindException.class)
    public CommonResult<?> handleBindException(BindException ex) {
        String message = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .findFirst()
                .map(FieldError::getDefaultMessage)
                .orElse("请求参数不正确");

        log.warn("请求参数绑定失败:{}", message);
        return CommonResult.error(400, message);
    }

    /**
     * 参数类型错误
     */
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public CommonResult<?> handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException ex) {
        log.warn("请求参数类型错误:name={}, value={}", ex.getName(), ex.getValue());
        return CommonResult.error(400, "请求参数类型错误:" + ex.getName());
    }

    /**
     * 请求方式不支持
     */
    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
    public CommonResult<?> handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException ex) {
        log.warn("请求方式不支持:{}", ex.getMethod());
        return CommonResult.error(405, "请求方式不支持:" + ex.getMethod());
    }

    /**
     * 兜底异常
     */
    @ExceptionHandler(Exception.class)
    public CommonResult<?> handleException(HttpServletRequest request, Exception ex) {
        log.error("系统异常:url={}", request.getRequestURI(), ex);
        return CommonResult.error(500, "系统异常,请联系管理员");
    }
}

这个结构基本覆盖了常见场景:

css 复制代码
ServiceException                       业务异常
MethodArgumentNotValidException         @RequestBody 参数校验异常
BindException                           Query/Form 参数绑定异常
MethodArgumentTypeMismatchException     参数类型不匹配
HttpRequestMethodNotSupportedException  请求方法错误
Exception                               兜底系统异常

芋道源码项目的全局异常处理也是类似思路:把不同异常分别转换成统一的 CommonResult,包括参数异常、权限异常、业务异常、系统异常等;同时还提供了一个 allExceptionHandler 方法,专门给 Filter 等非 Spring MVC 流程复用。(GitHub)


5. 边界问题:为什么 Filter 异常进不了全局异常处理

这是一个很常见的坑。假设有一个 Filter:

typescript 复制代码
@Component
public class TokenFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
        throw new ServiceException(401, "Token 无效");
    }
}

你可能会以为,既然我已经写了:

python 复制代码
@ExceptionHandler(ServiceException.class)

那这个异常应该也会被全局异常处理器处理,实际上不会。原因在于执行顺序不同,请求大致流程是:

css 复制代码
Tomcat
   ↓
Filter
   ↓
DispatcherServlet
   ↓
Spring MVC
   ↓
Controller

@RestControllerAdvice 属于 Spring MVC 的异常处理机制,它只能处理进入 DispatcherServlet 之后,在 Spring MVC 调用链里抛出的异常。而 Filter 在 DispatcherServlet 之前执行,如果异常在 Filter 中就抛出了,请求还没有进入 Spring MVC,自然也就不会走 HandlerExceptionResolver,更不会走 @ExceptionHandler。这就是为什么 Filter 的异常"进不了全局异常处理"。


5.1 Filter 异常应该怎么处理

比较好的做法是:Filter 自己捕获异常,然后复用统一异常转换逻辑。例如:

scala 复制代码
@Component
@RequiredArgsConstructor
public class TokenFilter extends OncePerRequestFilter {

    private final GlobalExceptionHandler globalExceptionHandler;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        try {
            // token 校验逻辑
            filterChain.doFilter(request, response);
        } catch (Throwable ex) {
            CommonResult<?> result = globalExceptionHandler.allExceptionHandler(request, ex);
            writeJson(response, result);
        }
    }

    private void writeJson(HttpServletResponse response, CommonResult<?> result) throws IOException {
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(JSON.toJSONString(result));
    }
}

这里的关键点是:不要让 Filter 异常直接抛到容器,而是把异常转换成和 Controller 一样的统一响应格式。芋道源码项目的 TokenAuthenticationFilter 就是类似做法:在 Filter 中捕获 Throwable,然后调用 GlobalExceptionHandler#allExceptionHandler 转成 CommonResult,最后直接写 JSON 响应。(GitHub) 这也是为什么一些企业项目会在全局异常处理器里额外提供一个普通方法,而不只是写 @ExceptionHandler 方法。

例如:

scss 复制代码
public CommonResult<?> allExceptionHandler(HttpServletRequest request, Throwable ex) {
    if (ex instanceof ServiceException) {
        return handleServiceException((ServiceException) ex);
    }

    if (ex instanceof MethodArgumentNotValidException) {
        return handleMethodArgumentNotValidException((MethodArgumentNotValidException) ex);
    }

    return handleException(request, ex);
}

这个方法不是给 Spring MVC 自动调用的,而是给 Filter、拦截器或其他非 MVC 场景手动调用的。


5.2 日志应该怎么打

异常处理里,日志很重要,但不是所有异常都应该用同一种日志方式。业务异常一般用 warn

less 复制代码
@ExceptionHandler(ServiceException.class)
public CommonResult<?> handleServiceException(ServiceException ex) {
    log.warn("业务异常:code={}, msg={}", ex.getCode(), ex.getMessage());
    return CommonResult.error(ex.getCode(), ex.getMessage());
}

原因是业务异常通常是预期内的。比如:

复制代码
用户不存在
余额不足
验证码错误
订单状态不允许修改

这些不是系统故障,不需要每次都打印完整堆栈。系统异常一般用 error,并且要打印完整堆栈。

vbscript 复制代码
@ExceptionHandler(Exception.class)
public CommonResult<?> handleException(HttpServletRequest request, Exception ex) {
    log.error("系统异常:url={}", request.getRequestURI(), ex);
    return CommonResult.error(500, "系统异常,请联系管理员");
}

这里有一个原则:返回给用户的信息要克制,写入日志的信息要完整。不要直接这样写:

go 复制代码
return CommonResult.error(500, ex.getMessage());

因为 ex.getMessage() 可能包含敏感信息。比如:

arduino 复制代码
SQL syntax error near ...
Connection refused: 10.10.10.11:5432
File not found: /data/app/config/secret.yml

这些信息应该留在日志里,而不是返回给前端。Spring 官方文章也提醒,不应该把 Java 异常细节和堆栈暴露给用户页面;更合理的做法是记录有用日志,方便事后分析。(Home)


5.3 对于单个Controller 单独处理异常

虽然一般推荐使用全局异常处理,但 Spring MVC 也支持在单个 Controller 内部定义异常处理方法。例如:

kotlin 复制代码
@RestController
@RequestMapping("/import")
public class ImportController {

    @PostMapping("/excel")
    public CommonResult<?> importExcel(MultipartFile file) {
        throw new IllegalArgumentException("Excel 格式不正确");
    }

    @ExceptionHandler(IllegalArgumentException.class)
    public CommonResult<?> handleIllegalArgumentException(IllegalArgumentException ex) {
        return CommonResult.error(400, "导入失败:" + ex.getMessage());
    }
}

这种写法适合非常局部、非常特殊的异常处理。比如:

复制代码
某个导入接口需要返回特殊错误格式
某个 Controller 需要对异常做额外上下文包装
某个老接口为了兼容前端,需要单独返回旧格式

但不建议滥用,因为一旦 Controller 里到处都有自己的 @ExceptionHandler,项目的全局规范就会被削弱。Spring 官方文章也提醒,如果同一种异常可以被多种方式处理,最终行为可能不是你想要的;Controller 内部的 @ExceptionHandler 会优先于 @ControllerAdvice 中的异常处理方法。(Home) 所以比较稳妥的规则是:

复制代码
通用异常:放到 GlobalExceptionHandler
特殊接口异常:少量放到 Controller 自己处理
不要在项目里混用太多异常处理方式

5.4 推荐的项目结构

实际项目里,可以把异常处理相关代码放在 common 模块。例如:

markdown 复制代码
common
 ├── exception
 │    ├── ServiceException.java
 │    ├── ErrorCode.java
 │    ├── GlobalErrorCodeConstants.java
 │    └── ServiceExceptionUtil.java
 │
 ├── web
 │    └── GlobalExceptionHandler.java
 │
 └── pojo
      └── CommonResult.java

其中:

复制代码
CommonResult

负责统一响应格式。

复制代码
ErrorCode

负责定义错误码规范。

复制代码
ServiceException

负责表达业务失败。

复制代码
GlobalExceptionHandler

负责把异常转换成统一响应。

复制代码
ServiceExceptionUtil

负责简化业务异常创建。

业务代码最终可以写得很简单:

ini 复制代码
if (user == null) {
    throw exception(USER_NOT_EXISTS);
}

Controller 也很简单:

less 复制代码
@GetMapping("/{id}")
public CommonResult<User> getUser(@PathVariable Long id) {
    return success(userService.getUser(id));
}

异常处理器统一兜底:

kotlin 复制代码
@ExceptionHandler(ServiceException.class)
public CommonResult<?> handleServiceException(ServiceException ex) {
    return CommonResult.error(ex.getCode(), ex.getMessage());
}

@ExceptionHandler(Exception.class)
public CommonResult<?> handleException(Exception ex) {
    log.error("系统异常", ex);
    return CommonResult.error(500, "系统异常,请联系管理员");
}

这套结构的好处是,项目越大越明显,因为错误码、响应格式、日志规则、异常分类都是统一的。


6. 总结

Spring Boot 全局异常处理,本质上不是为了少写几个 try-catch,它真正解决的是项目规范问题。一个比较好的异常体系,至少要做到几件事:

第一,返回结构统一 ,不管成功还是失败,前端都能按照同一套格式处理;

第二,业务代码干净 ,业务层只需要表达业务规则,不需要关心 HTTP 响应怎么拼;

第三,异常分类清楚 ,业务异常是业务规则失败,系统异常是程序运行故障,两者不能混在一起处理;

第四,日志策略明确,业务异常可以简单记录,系统异常必须打印完整堆栈;

第五,边界要清楚,@RestControllerAdvice 只能处理 Spring MVC 调用链里的异常,Filter、MQ、定时任务、异步线程里的异常,不会自动进入 Spring MVC 的异常处理机制,需要单独处理,或者复用统一异常转换方法。可以把整个机制理解成一句话:

业务代码负责抛出异常,全局异常处理器负责把异常翻译成统一结果。

相关推荐
庞轩px1 小时前
第八篇:Spring与微服务——从SpringBoot到SpringCloud的演进
spring boot·spring·微服务·nacos·gateway·sentinel
若阳安好1 小时前
【备忘录】正则表达式
后端·正则表达式·restful
Cosolar2 小时前
AI Agent 的记忆战争:OpenClaw vs Hermes vs QwenPaw vs HiClaw,谁真正"记得住"?
人工智能·后端·面试
M ? A2 小时前
VuReact:Vue转React的增量编译利器
前端·vue.js·后端·react.js·面试·开源·vureact
fanzhonghong2 小时前
javaWeb开发之Maven高级
java·开发语言·spring boot·spring cloud·私服
aircrushin2 小时前
给宝宝办了个宴,朋友用trae做的工具帮了大忙
前端·后端
码上小翔哥2 小时前
Jackson 配置深度解析
java·后端
程序员Sunday2 小时前
爆肝万字!这应该是全网最全的 Codex 实战教程了
前端·后端·ai编程
aircrushin2 小时前
朋友用trae搭建的工具,解决了旅行拍照共享的大事儿
前端·后端