Spring Boot自定义全局异常处理:从痛点到优雅实现

在 Spring Boot 项目开发中,异常处理是保障系统稳定性的关键环节。传统的try-catch分散在各个 Controller、Service 中,不仅导致代码冗余,还会出现同一异常不同响应格式的问题 ------ 比如有的接口返回{"code":500,"msg":"错误"},有的直接返回 Tomcat 默认的 404 页面,前端处理时需反复适配。

自定义全局异常处理能统一接管项目中所有异常,实现 "一处定义、全局生效",让异常响应格式标准化、代码逻辑更简洁。本文将从核心原理到实战落地,带你掌握 Spring Boot 全局异常处理的完整方案。

一、为什么需要全局异常处理?先看传统方案的痛点

在没有全局异常处理时,我们通常这样处理异常:

java 复制代码
@RestController
@RequestMapping("/user")
public class UserController {
    @GetMapping("/{id}")
    public String getUserById(@PathVariable Long id) {
        try {
            if (id == null || id <= 0) {
                throw new IllegalArgumentException("用户ID非法");
            }
            User user = userService.getById(id);
            if (user == null) {
                return "{\"code\":404,\"msg\":\"用户不存在\",\"data\":null}";
            }
            return "{\"code\":200,\"msg\":\"成功\",\"data\":" + JSON.toJSONString(user) + "}";
        } catch (IllegalArgumentException e) {
            return "{\"code\":400,\"msg\":\"" + e.getMessage() + "\",\"data\":null}";
        } catch (Exception e) {
            // 系统异常,隐藏具体信息
            return "{\"code\":500,\"msg\":\"服务器内部错误\",\"data\":null}";
        }
    }
}

这种方式存在 3 个明显问题:

  1. 代码冗余 :每个接口都要写重复的try-catch和响应格式拼接;
  2. 格式不统一 :若多个开发者定义不同的codemsg字段,前端需反复适配;
  3. 异常遗漏 :若忘记捕获某个异常(如NullPointerException),会返回默认 500 错误页,体验极差。

而全局异常处理能一次性解决这些问题 ------ 只需定义一套规则,所有异常都会按统一格式返回,且无需在业务代码中写try-catch

二、核心技术:Spring Boot 的异常处理注解

Spring Boot 通过 3 个核心注解实现全局异常处理,需先理解其作用:

注解 作用说明
@RestControllerAdvice 全局异常处理的 "入口",是@ControllerAdvice+@ResponseBody的组合,自动返回 JSON 格式响应(适合前后端分离);若用@ControllerAdvice,需配合@ResponseBody使用。
@ExceptionHandler 定义 "异常处理器方法",指定该方法处理哪类异常(如@ExceptionHandler(NullPointerException.class)处理空指针异常)。
@ResponseStatus 可选,为异常响应设置 HTTP 状态码(如 400、404、500),默认返回 200 OK。

此外,还需用到统一响应类------ 封装所有接口(正常响应 + 异常响应)的返回格式,确保前后端交互字段一致。

三、实战:从零实现全局异常处理

下面按 "统一响应→自定义异常→全局处理器→测试验证" 的步骤,搭建完整的全局异常处理方案。

步骤 1:定义统一响应类(核心前提)

首先创建`Result`类,统一所有接口的返回格式,包含 3 个核心字段:

  • code:业务状态码(如 200 = 成功,400 = 参数错误,500 = 系统错误);
  • msg:响应消息(成功 / 错误描述);
  • data:响应数据(正常响应时返回业务数据,异常时为null)。
java 复制代码
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * 统一响应类
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result<T> {
    // 业务状态码
    private Integer code;
    // 响应消息
    private String msg;
    // 响应数据
    private T data;

    // -------------- 静态工厂方法:简化调用 --------------
    // 成功(无数据)
    public static <T> Result<T> success() {
        return new Result<>(200, "操作成功", null);
    }

    // 成功(有数据)
    public static <T> Result<T> success(T data) {
        return new Result<>(200, "操作成功", data);
    }

    // 失败(自定义状态码和消息)
    public static <T> Result<T> fail(Integer code, String msg) {
        return new Result<>(code, msg, null);
    }

    // 失败(默认系统错误)
    public static <T> Result<T> error() {
        return new Result<>(500, "服务器内部错误", null);
    }
}

有了这个类,正常接口的返回会更简洁,例如:

java 复制代码
// 正常响应示例
@GetMapping("/{id}")
public Result<User> getUserById(@PathVariable Long id) {
    User user = userService.getById(id);
    if (user == null) {
        // 直接返回失败响应,无需拼接JSON
        return Result.fail(404, "用户不存在");
    }
    return Result.success(user);
}

步骤 2:定义自定义业务异常

实际开发中,很多异常是业务相关的(如 "用户余额不足""订单已取消"),需要携带业务状态码。此时需定义**自定义异常类**,继承`RuntimeException`(无需强制捕获),并包含`code`字段。

java 复制代码
import lombok.Getter;

/**
 * 自定义业务异常(如用户不存在、参数非法等)
 */
@Getter // 提供code和message的getter方法,供全局处理器获取
public class BusinessException extends RuntimeException {
    // 业务状态码(如404=用户不存在,400=参数错误)
    private final Integer code;

    // 构造方法1:自定义code和message
    public BusinessException(Integer code, String message) {
        super(message); // 父类RuntimeException的message字段
        this.code = code;
    }

    // 构造方法2:默认code=400(参数错误)
    public BusinessException(String message) {
        this(400, message);
    }
}

后续业务中抛出异常时,直接用自定义异常:

java 复制代码
// 业务层抛出自定义异常示例
public void deductBalance(Long userId, BigDecimal amount) {
    User user = userMapper.selectById(userId);
    if (user == null) {
        // 抛出"用户不存在"异常,携带code=404
        throw new BusinessException(404, "用户不存在");
    }
    if (user.getBalance().compareTo(amount) < 0) {
        // 抛出"余额不足"异常,默认code=400
        throw new BusinessException("用户余额不足");
    }
    // 后续扣减余额逻辑...
}

步骤 3:编写全局异常处理器(核心实现)

创建GlobalExceptionHandler类,用@RestControllerAdvice标注,内部定义多个@ExceptionHandler方法,分别处理不同类型的异常(自定义异常、系统异常、参数校验异常等)。

java 复制代码
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

/**
 * 全局异常处理器:统一处理所有异常
 */
@RestControllerAdvice(basePackages = "cn.varin.demo.controller") // 只处理指定包下的Controller异常
@Slf4j // 记录异常日志
public class GlobalExceptionHandler {

    // ---------------- 1. 处理自定义业务异常 ----------------
    @ExceptionHandler(BusinessException.class)
    public Result<Void> handleBusinessException(BusinessException e) {
        // 记录异常日志(级别为WARN,因为是业务已知异常)
        log.warn("业务异常:{},状态码:{}", e.getMessage(), e.getCode());
        // 返回自定义的业务状态码和消息
        return Result.fail(e.getCode(), e.getMessage());
    }

    // ---------------- 2. 处理参数校验异常(如@NotNull、@Size) ----------------
    // 注:需配合Spring Validation依赖(如spring-boot-starter-validation)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST) // 设置HTTP状态码为400
    public Result<Void> handleValidException(MethodArgumentNotValidException e) {
        // 获取参数校验失败的第一个错误信息
        String errMsg = e.getBindingResult().getFieldError().getDefaultMessage();
        log.warn("参数校验异常:{}", errMsg);
        // 返回code=400和错误信息
        return Result.fail(400, errMsg);
    }

    // ---------------- 3. 处理系统通用异常(如NullPointerException、IllegalArgumentException) ----------------
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) // 设置HTTP状态码为500
    public Result<Void> handleGeneralException(Exception e) {
        // 记录异常堆栈信息(级别为ERROR,因为是未知系统异常,需排查)
        log.error("系统异常:", e); // 注意这里要传e,才能打印堆栈
        // 隐藏具体异常信息,返回通用提示(避免泄露系统细节)
        return Result.error();
    }
}
关键说明:
  1. basePackages属性 :限定处理器的作用范围,只处理cn.varin.demo.controller包下的 Controller 抛出的异常,避免处理其他第三方组件的异常;
  2. 日志记录分级 :业务异常(BusinessException)用warn级别(已知问题,无需紧急处理),系统异常用error级别(未知问题,需排查);
  3. @ResponseStatus:为异常响应设置 HTTP 状态码(如参数校验失败返回 400,系统错误返回 500),前端可通过状态码快速判断异常类型;
  4. 参数校验异常 :需引入spring-boot-starter-validation依赖,配合@Valid注解使用(后续会演示)。

步骤 4:引入参数校验依赖(可选但推荐)

若要处理参数校验异常(如@NotNull@Min),需在pom.xml中引入依赖:

java 复制代码
<!-- Spring Validation:参数校验 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

步骤 5:测试验证

创建测试 Controller,模拟不同异常场景,验证全局异常处理是否生效。

测试 1:自定义业务异常
java 复制代码
@RestController
@RequestMapping("/user")
public class UserController {
    @PostMapping("/deduct")
    public Result<Void> deductBalance(@RequestParam Long userId, @RequestParam BigDecimal amount) {
        // 调用业务层方法,内部会抛出BusinessException
        userService.deductBalance(userId, amount);
        return Result.success();
    }
}

请求示例POST /user/deduct?userId=999&amount=100(用户 ID=999 不存在)

响应结果(统一格式):

plain 复制代码
{

 "code": 404,

 "msg": "用户不存在",

 "data": null

}

控制台日志WARN cn.varin.demo.exception.GlobalExceptionHandler - 业务异常:用户不存在,状态码:404

测试 2:参数校验异常
java 复制代码
// 用@Valid注解开启参数校验,@RequestBody接收对象参数
@PostMapping("/add")
public Result<User> addUser(@Valid @RequestBody User user) {
    userService.save(user);
    return Result.success(user);
}

// User类(添加参数校验注解)
@Data
public class User {
    @NotNull(message = "用户名不能为空") // 校验username不能为null
    @Size(min = 2, max = 10, message = "用户名长度必须在2-10之间") // 校验长度
    private String username;

    @Min(value = 18, message = "年龄不能小于18岁") // 校验age最小值
    private Integer age;
}

请求示例 :提交{"username":"a","age":17}(用户名长度 1,年龄 17)

响应结果

plain 复制代码
{

 "code": 400,

 "msg": "用户名长度必须在2-10之间",

 "data": null

}

控制台日志WARN cn.varin.demo.exception.GlobalExceptionHandler - 参数校验异常:用户名长度必须在2-10之间

测试 3:系统异常(空指针)
java 复制代码
@GetMapping("/test/error")
public Result<Void> testSystemError() {
    // 模拟空指针异常(未处理)
    String str = null;
    str.length(); // 此处会抛出NullPointerException
    return Result.success();
}

请求示例GET /user/test/error

响应结果(隐藏具体异常信息):

plain 复制代码
{

 "code": 500,

 "msg": "服务器内部错误",

 "data": null

}

控制台日志(打印完整堆栈,方便排查):

java 复制代码
ERROR cn.varin.demo.exception.GlobalExceptionHandler - 系统异常:
java.lang.NullPointerException: Cannot invoke "String.length()" because "str" is null
at cn.varin.demo.controller.UserController.testSystemError(UserController.java:45)
...(后续堆栈省略)

四、进阶场景:处理特殊异常

除了上述常见场景,还有一些特殊异常需要单独处理,确保覆盖全面。

1. 处理 404(资源未找到)异常

Spring Boot 默认的 404 响应是 HTML 页面,需自定义 404 异常处理器,返回 JSON 格式:

java 复制代码
@ExceptionHandler(NoHandlerFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND) // HTTP状态码404
public Result<Void> handle404Exception(NoHandlerFoundException e) {
log.warn("资源未找到:{}", e.getRequestURL());
return Result.fail(404, "请求的接口不存在");
}

注意 :需在application.yml中开启 404 异常抛出配置,否则无法被全局处理器捕获:

java 复制代码
spring:

 mvc:

   throw-exception-if-no-handler-found: true # 开启404异常抛出

 web:

   resources:

     add-mappings: false # 禁用默认的静态资源映射(避免静态资源404被捕获)

2. 处理异步方法中的异常

若方法用@Async标注(异步执行),其抛出的异常无法被默认的全局处理器捕获,需自定义AsyncUncaughtExceptionHandler

java 复制代码
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;

@Configuration
@EnableAsync // 开启异步支持
public class AsyncConfig implements AsyncConfigurer {

    // 自定义异步异常处理器
    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return (ex, method, params) -> {
            // 记录异步异常日志
            log.error("异步方法[{}]执行异常,参数:{}", method.getName(), params, ex);
            // 若需要通知(如邮件、钉钉),可在此处添加逻辑
        };
    }
}

3. 区分开发 / 生产环境的异常响应

开发环境需要显示异常堆栈(方便调试),生产环境需隐藏细节。可通过@Profile注解实现环境区分:

java 复制代码
// 开发环境:返回详细异常信息
@ExceptionHandler(Exception.class)
@Profile("dev") // 仅dev环境生效
public Result<Void> handleDevGeneralException(Exception e) {
log.error("系统异常:", e);
// 返回异常堆栈的字符串形式(简化)
String stackTrace = Arrays.stream(e.getStackTrace())
.map(StackTraceElement::toString)
.limit(5) // 只显示前5行堆栈
.collect(Collectors.joining("\n"));
return Result.fail(500, "开发环境异常:" + e.getMessage() + "\n" + stackTrace);
}

// 生产环境:隐藏细节
@ExceptionHandler(Exception.class)
@Profile("prod") // 仅prod环境生效
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Result<Void> handleProdGeneralException(Exception e) {
log.error("系统异常:", e);
return Result.fail(500, "服务器繁忙,请稍后再试");
}

五、常见问题与解决方案

  1. 全局异常处理器不生效?
  • 检查@RestControllerAdvicebasePackages是否包含 Controller 所在包;
  • 检查异常是否被业务代码中的try-catch捕获(若捕获后未重新抛出,处理器无法感知);
  • 检查是否引入了正确的依赖(如参数校验需spring-boot-starter-validation)。
  1. 404 异常无法被捕获?
  • 必须在配置文件中开启spring.mvc.throw-exception-if-no-handler-found=truespring.web.resources.add-mappings=false
  1. 异步方法异常无法被捕获?
  • 需实现AsyncConfigurer,自定义AsyncUncaughtExceptionHandler,不能依赖默认的全局处理器。

六、最佳实践总结

  1. 统一响应格式 :所有接口(正常 / 异常)都通过Result类返回,避免格式混乱;
  2. 分类处理异常:自定义业务异常(已知)、系统异常(未知)、特殊异常(404、异步)分开处理,日志分级记录;
  3. 隐藏敏感信息:生产环境不返回异常堆栈,避免泄露系统架构;
  4. 配合参数校验 :用Spring Validation+ 全局处理器自动处理参数错误,减少重复代码;
  5. 环境差异化:开发环境返回详细异常信息,生产环境返回友好提示。

通过自定义全局异常处理,不仅能提升代码的简洁性和可维护性,还能让前后端交互更高效。你在项目中遇到过哪些特殊的异常处理场景?欢迎在评论区交流!

相关推荐
7hyya2 小时前
如何将Spring Boot 2接口改造为MCP服务,供大模型调用!
人工智能·spring boot·后端
zhangxuyu11182 小时前
Spring boot 学习记录
java·spring boot·学习
做运维的阿瑞2 小时前
告别性能焦虑:Python 性能革命实践指南
开发语言·后端·python
元气满满的霄霄2 小时前
Spring Boot整合缓存——Ehcache缓存!超详细!
java·spring boot·后端·缓存·intellij-idea
唐叔在学习2 小时前
文档转换神器pypandoc详解:解锁Python跨格式文档转换的终极姿势
后端·python
MClink2 小时前
架构学习之旅-架构的由来
java·学习·架构
-雷阵雨-2 小时前
数据结构——排序算法全解析(入门到精通)
java·开发语言·数据结构·排序算法·intellij-idea
aloha_7893 小时前
顺丰科技java面经准备
java·开发语言·spring boot·科技·spring·spring cloud
_extraordinary_3 小时前
Java Servlet(三)--- 写一个简单的网站,表白墙程序,登录功能的实现
java·开发语言·servlet