Spring Boot 全局异常处理与统一返回结构:一场优雅的邂逅
大家好,今天我们来聊聊在 Spring Boot 应用中,如何优雅地实现全局异常处理和统一返回结构。这是一个非常重要的课题,直接关系到 API 的健壮性、可维护性和用户体验。想象一下,你的 API 总是抛出各种各样的异常,返回的数据格式也五花八门,这简直就是一场噩梦。我们需要一套统一的机制,让异常处理变得可控,返回的数据结构清晰一致。
1. 为什么要全局异常处理和统一返回结构?
在开始之前,我们先明确一下为什么要这么做。
| 痛点 | 解决方案 | 收益 | 
|---|---|---|
| API 抛出各种异常,信息不统一 | 全局异常处理,将所有异常转化为统一的错误信息 | 提高 API 的可维护性,方便排查问题。前端开发者可以根据统一的错误码进行处理,减少沟通成本。 | 
| 返回数据格式不一致,前后端对接困难 | 统一返回数据结构,包含状态码、消息、数据等字段 | 前后端分离更加彻底,前端开发者无需关心后端具体实现,只需要根据统一的接口协议进行开发。降低前后端耦合度,提高开发效率。 | 
| 代码中散落着大量的 try-catch 块,冗余重复 | 将异常处理逻辑集中到一个地方,避免代码重复 | 提高代码的可读性和可维护性,减少代码量。 | 
| 缺乏统一的错误码管理 | 定义一套清晰的错误码体系,方便定位问题 | 方便前后端沟通,快速定位问题。方便监控系统对异常进行统计分析。 | 
总而言之,全局异常处理和统一返回结构是为了让我们的 API 更加健壮、易用和易维护。
2. 统一返回数据结构的设计
首先,我们需要定义一个通用的返回数据结构。这个结构应该包含以下几个要素:
- code: 状态码,用于表示 API 的调用结果,例如 200 表示成功,500 表示服务器错误。
 - message: 消息,用于描述 API 的调用结果,例如 "操作成功","用户名不能为空"。
 - data: 数据,用于存放 API 返回的实际数据。
 
我们可以创建一个 Result 类来实现这个结构:
import lombok.Data;
@Data
public class Result<T> {
    private int code;
    private String message;
    private T data;
    private Result(int code, String message, T data) {
        this.code = code;
        this.message = message;
        this.data = data;
    }
    public static <T> Result<T> success(T data) {
        return new Result<>(200, "操作成功", data);
    }
    public static <T> Result<T> success() {
        return new Result<>(200, "操作成功", null);
    }
    public static <T> Result<T> error(int code, String message) {
        return new Result<>(code, message, null);
    }
    public static <T> Result<T> error(int code, String message, T data) {
        return new Result<>(code, message, data);
    }
    public static <T> Result<T> error() {
        return new Result<>(500, "服务器错误", null);
    }
}
这个 Result 类使用了泛型,可以存放任意类型的数据。同时,我们提供了一些静态方法,方便创建不同状态的 Result 对象。例如 success(data) 用于创建成功返回结果,error(code, message) 用于创建错误返回结果。
3. 全局异常处理器的实现
接下来,我们需要创建一个全局异常处理器,用于捕获所有未处理的异常,并将它们转化为统一的错误信息。Spring Boot 提供了 @ControllerAdvice 和 @ExceptionHandler 注解来实现这个功能。
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import javax.validation.ConstraintViolationException;
@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public Result<?> handleException(Exception e) {
        e.printStackTrace(); // 打印异常信息,方便排查问题
        return Result.error(500, "服务器错误");
    }
    @ExceptionHandler(BusinessException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result<?> handleBusinessException(BusinessException e) {
        return Result.error(e.getCode(), e.getMessage());
    }
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result<?> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
        String message = e.getBindingResult().getFieldErrors().get(0).getDefaultMessage();
        return Result.error(400, message);
    }
    @ExceptionHandler(ConstraintViolationException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result<?> handleConstraintViolationException(ConstraintViolationException e) {
        String message = e.getConstraintViolations().iterator().next().getMessage();
        return Result.error(400, message);
    }
     @ExceptionHandler(HttpMessageNotReadableException.class)
     @ResponseStatus(HttpStatus.BAD_REQUEST)
     public Result<?> handleHttpMessageNotReadableException(HttpMessageNotReadableException e) {
         return Result.error(400, "请求参数格式错误");
     }
}
@RestControllerAdvice注解表示这是一个全局异常处理器,可以处理所有 Controller 中抛出的异常。@ExceptionHandler注解用于指定要处理的异常类型。例如,@ExceptionHandler(Exception.class)表示处理所有Exception类型的异常。@ResponseStatus注解用于指定 HTTP 状态码。例如,@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)表示返回 500 状态码。- 每个 
@ExceptionHandler方法都接收一个异常对象作为参数,可以从中获取异常信息。 BusinessException是一个自定义的业务异常,我们稍后会定义它。MethodArgumentNotValidException和ConstraintViolationException是 Bean Validation 框架抛出的异常,用于处理请求参数校验失败的情况。HttpMessageNotReadableException用于处理请求体无法解析的情况,例如 JSON 格式错误。
4. 自定义业务异常
为了更好地处理业务逻辑中的异常,我们可以定义一个自定义的业务异常类。
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class BusinessException extends RuntimeException {
    private int code;
    private String message;
    public BusinessException(int code, String message) {
        super(message);
        this.code = code;
        this.message = message;
    }
    public BusinessException(ErrorCode errorCode) {
        super(errorCode.getMessage());
        this.code = errorCode.getCode();
        this.message = errorCode.getMessage();
    }
}
这个 BusinessException 类继承了 RuntimeException,并添加了 code 和 message 属性,用于表示业务异常的状态码和消息。
同时,我们还可以定义一个 ErrorCode 枚举类,用于管理所有业务异常的错误码。
import lombok.Getter;
@Getter
public enum ErrorCode {
    USER_NOT_FOUND(1001, "用户不存在"),
    PASSWORD_ERROR(1002, "密码错误"),
    ;
    private int code;
    private String message;
    ErrorCode(int code, String message) {
        this.code = code;
        this.message = message;
    }
}
这样,我们就可以在业务代码中抛出自定义的业务异常,例如:
if (user == null) {
    throw new BusinessException(ErrorCode.USER_NOT_FOUND);
}
5. 在 Controller 中使用
现在,我们可以在 Controller 中使用全局异常处理器和统一返回结构了。
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
@RestController
@RequestMapping("/users")
public class UserController {
    @GetMapping("/{id}")
    public Result<User> getUser(@PathVariable Long id) {
        // 模拟根据 ID 获取用户
        User user = new User();
        user.setId(id);
        user.setName("张三");
        if (id == 0) {
            throw new BusinessException(ErrorCode.USER_NOT_FOUND);
        }
        return Result.success(user);
    }
    @PostMapping
    public Result<User> createUser(@Valid @RequestBody User user) {
        // 模拟创建用户
        return Result.success(user);
    }
}
@Valid注解用于开启 Bean Validation 功能,对请求参数进行校验。- 如果 
id为 0,则抛出一个BusinessException异常。 
6. Bean Validation 的使用
Bean Validation 是一种用于校验 Java Bean 的标准。Spring Boot 提供了对 Bean Validation 的支持,我们可以使用它来校验请求参数。
首先,需要在 pom.xml 文件中添加 Bean Validation 的依赖:
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
然后,在 User 类中添加校验注解:
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
@Data
public class User {
    private Long id;
    @NotBlank(message = "用户名不能为空")
    @Size(min = 2, max = 10, message = "用户名长度必须在 2 到 10 个字符之间")
    private String name;
    @NotBlank(message = "密码不能为空")
    private String password;
}
@NotBlank注解表示字段不能为空。@Size注解表示字段的长度必须在指定的范围内。
最后,在 Controller 中使用 @Valid 注解开启 Bean Validation 功能。
@PostMapping
public Result<User> createUser(@Valid @RequestBody User user) {
    // 模拟创建用户
    return Result.success(user);
}
如果请求参数校验失败,Spring Boot 会抛出一个 MethodArgumentNotValidException 异常,全局异常处理器会捕获这个异常,并将错误信息返回给客户端。
7. 全局异常处理的优先级问题
当存在多个 @ExceptionHandler 方法可以处理同一个异常时,Spring Boot 会根据以下规则选择合适的异常处理方法:
- 精确匹配: 如果存在一个 
@ExceptionHandler方法可以精确匹配异常类型,则选择该方法。 - 继承关系: 如果不存在精确匹配的 
@ExceptionHandler方法,则选择可以处理异常父类类型的@ExceptionHandler方法。 - 同级关系: 如果存在多个可以处理异常父类类型的 
@ExceptionHandler方法,则选择参数类型最接近异常类型的@ExceptionHandler方法。 
例如,如果同时存在以下两个 @ExceptionHandler 方法:
@ExceptionHandler(Exception.class)
public Result<?> handleException(Exception e) {
    // ...
}
@ExceptionHandler(RuntimeException.class)
public Result<?> handleRuntimeException(RuntimeException e) {
    // ...
}
当抛出一个 NullPointerException 异常时,Spring Boot 会选择 handleRuntimeException 方法,因为它比 handleException 方法更接近 NullPointerException 类型。
8. 异步任务中的异常处理
在 Spring Boot 应用中,我们经常会使用异步任务来提高程序的性能。但是,异步任务中的异常处理也是一个需要注意的问题。由于异步任务是在独立的线程中执行的,所以全局异常处理器无法捕获异步任务中抛出的异常。
为了解决这个问题,我们可以使用 AsyncUncaughtExceptionHandler 接口来处理异步任务中的异常。
首先,需要创建一个类实现 AsyncUncaughtExceptionHandler 接口:
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import java.lang.reflect.Method;
public class CustomAsyncExceptionHandler implements AsyncUncaughtExceptionHandler {
    @Override
    public void handleUncaughtException(Throwable ex, Method method, Object... params) {
        System.err.println("Exception message - " + ex.getMessage());
        System.err.println("Method name - " + method.getName());
        for (Object param : params) {
            System.err.println("Parameter value - " + param);
        }
        // 可以将异常信息记录到日志中,或者发送到监控系统
    }
}
然后,需要在配置类中配置 AsyncConfigurer:
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {
    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setQueueCapacity(25);
        executor.initialize();
        return executor;
    }
    @Override
    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
        return new CustomAsyncExceptionHandler();
    }
}
这样,当异步任务中抛出异常时,CustomAsyncExceptionHandler 就会被调用,我们可以将异常信息记录到日志中,或者发送到监控系统。
9. 总结与思考
我们学习了如何在 Spring Boot 中优雅地实现全局异常处理和统一返回结构。通过使用 @ControllerAdvice 和 @ExceptionHandler 注解,我们可以将所有异常转化为统一的错误信息。通过定义一个通用的返回数据结构,我们可以让 API 的返回数据格式清晰一致。
现在,让我们思考一下,还有哪些方面可以改进?
- 错误码管理: 我们可以使用数据库或者配置文件来管理错误码,方便维护和更新。
 - 国际化: 我们可以使用 Spring 的国际化功能,让错误信息支持多种语言。
 - 监控: 我们可以将异常信息发送到监控系统,方便统计和分析。
 
希望通过今天的学习,大家能够更好地理解和应用全局异常处理和统一返回结构,让我们的 API 更加健壮、易用和易维护。
优雅的异常处理和返回结构让代码更健壮
统一的返回结构让前后端分离更加彻底,全局异常处理避免代码冗余,清晰的错误码体系方便定位问题。