SpringBoot-异常处理


演示代码地址:

  GitHub:https://github.com/zhangzhixi0305/exception-handling

  码云:https://gitee.com/zhang-zhixi/exception-handling.git

参考链接:

  https://www.yuque.com/books/share/2b434c74-ed3a-470e-b148-b4c94ba14535/db60lb

一、SpringBoot默认异常处理机制

在SpringBoot中,无论是请求不存在的路径、@Valid校验,还是业务代码(Controller、Service、Dao)抛出异常,SpringBoot对错误的默认处理机制是:

BasicErrorController会判断当前请求来自哪里,如果来自浏览器则响应错误页面,如果来自APP则响应JSON。

那么,SpringBoot是如何判断一个请求到底来自浏览器还是APP的呢?其实,主要是看HTTP的一个请求头:Accept

SpringBoot默认的异常处理机制有什么不好呢?主要还是两点:

  • 样式或数据格式不统一
  • 对外暴露的信息不可控

以JSON格式为例,通常我们希望不论接口请求是否正常,都返回以下格式:

{
  "data": {}
  "success": true,
  "massage": ""
}

如果请求失败,希望把错误信息转化为指定内容(比如“系统正在繁忙”)放在message中返回,给前端/客户端一个友好提示。

这样一来,不论请求成功还是失败,响应格式都是统一的,对外暴露的信息也可控。

自定义异常处理可以大致分为两类:

  • 自定义错误页面
  • 自定义异常JSON

二、自定义错误页面

resources/error下存放404.html和500.html,当本次请求状态码为404或500时,SpringBoot就会读取我们自定义的html返回,否则返回默认的错误页面。

现在一般都是前后端分离,所以关于自定义错误页面就略过了。

三、**自定义异常JSON**

SpringBoot默认的异常JSON格式是这样的:
{
    "timestamp": "2021-01-31T01:36:12.187+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "message": "",
    "path": "/insertUser"
}

而我们希望响应格式是这样的:

{
  "data": {}
  "success": true,
  "massage": ""
}

一般有两种方式,并且通常会组合使用:

  • 在代码中使用工具类封装(ApiResultTO/Result
  • 用全局异常处理兜底

为了方便模拟异常情况,下面案例中我们会直接抛出自定义异常,然后考虑如何处理它。

在此之前,我们先准备通用枚举类和自定义的业务异常:

1、先定义通用枚举类和自定义的业务异常

ExceptionCodeEnum.java

import lombok.Getter;

import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

/**
 * @ClassName ExceptionCodeEnum
 * @Author zhangzhixi
 * @Description 通用错误枚举(不同类型的错误也可以拆成不同的Enum细分)
 * @Date 2022-4-7 18:19
 * @Version 1.0
 */
@Getter
public enum ExceptionCodeEnum {
    /**
     * 通用结果
     */
    ERROR(-1, "网络错误"),
    SUCCESS(200, "成功"),

    /**
     * 用户登录
     */
    NEED_LOGIN(900, "用户未登录"),

    /**
     * 参数校验
     */
    ERROR_PARAM(10000, "参数错误"),
    EMPTY_PARAM(10001, "参数为空"),
    ERROR_PARAM_LENGTH(10002, "参数长度错误");

    private final Integer code;
    private final String desc;

    ExceptionCodeEnum(Integer code, String desc) {
        this.code = code;
        this.desc = desc;
    }

    private static final Map ENUM_CACHE = new HashMap<>();

    static {
        for (ExceptionCodeEnum exceptionCodeEnum : ExceptionCodeEnum.values()) {
            ENUM_CACHE.put(exceptionCodeEnum.code, exceptionCodeEnum);
        }
    }

    public static String getDesc(Integer code) {
        return Optional.ofNullable(ENUM_CACHE.get(code))
                .map(ExceptionCodeEnum::getDesc)
                .orElseThrow(() -> new IllegalArgumentException("invalid exception code!"));
    }
}

BizException.java

import lombok.Getter;

/**
 * @ClassName BizException
 * @Author zhangzhixi
 * @Description 业务异常 biz是business的缩写
 * @Date 2022-4-7 18:21
 * @Version 1.0
 */
@Getter
public class BizException extends RuntimeException {

    private static final long serialVersionUID = -3229475403587709519L;

    private ExceptionCodeEnum error;

    /**
     * 构造器,有时我们需要将第三方异常转为自定义异常抛出,但又不想丢失原来的异常信息,此时可以传入cause
     *
     * @param error
     * @param cause
     */
    public BizException(ExceptionCodeEnum error, Throwable cause) {
        super(cause);
        this.error = error;
    }

    /**
     * 构造器,只传入错误枚举
     *
     * @param error
     */
    public BizException(ExceptionCodeEnum error) {
        this.error = error;
    }
}

下面演示两种处理异常的方式。

2、第一种:Result手动封装

先封装一个Result,用来统一返回格式:

/**
 * @ClassName Result
 * @Author zhangzhixi
 * @Description 一般返回实体
 * @Date 2022-4-7 18:24
 * @Version 1.0
 */
@Data
@NoArgsConstructor
public class Result implements Serializable {

    private static final long serialVersionUID = -687690141206758604L;
    private Integer code;
    private String message;
    private T data;

    private Result(Integer code, String message, T data) {
        this.code = code;
        this.message = message;
        this.data = data;
    }

    private Result(Integer code, String message) {
        this.code = code;
        this.message = message;
        this.data = null;
    }

    /**
     * 带数据成功返回
     *
     * @param data
     * @param 
     * @return
     */
    public static  Result success(T data) {
        return new Result<>(ExceptionCodeEnum.SUCCESS.getCode(), ExceptionCodeEnum.SUCCESS.getDesc(), data);
    }

    /**
     * 不带数据成功返回
     *
     * @return
     */
    public static  Result success() {
        return success(null);
    }

    /**
     * 通用错误返回,传入指定的错误枚举
     *
     * @param exceptionCodeEnum
     * @return
     */
    public static  Result error(ExceptionCodeEnum exceptionCodeEnum) {
        return new Result<>(exceptionCodeEnum.getCode(), exceptionCodeEnum.getDesc());
    }

    /**
     * 通用错误返回,传入指定的错误枚举,但支持覆盖message
     *
     * @param exceptionCodeEnum
     * @param msg
     * @return
     */
    public static  Result error(ExceptionCodeEnum exceptionCodeEnum, String msg) {
        return new Result<>(exceptionCodeEnum.getCode(), msg);
    }

    /**
     * 通用错误返回,只传入message
     *
     * @param msg
     * @param 
     * @return
     */
    public static  Result error(String msg) {
        return new Result<>(ExceptionCodeEnum.ERROR.getCode(), msg);
    }
}

使用自定义异常后,Controller层写法的区别 

 原本的Controller层插入用户的写法:

/**
 * 插入用户
 *
 * @param userPojo 用户信息
 * @return 是否成功
 */
@PostMapping("insertUser")
public boolean insertUser(@RequestBody User userPojo) {
    return userService.save(userPojo);
}

现在的写法:

/**
 * 插入用户
 *
 * @param userPojo 用户信息
 * @return 是否成功
 */
@PostMapping("insertUser")
public Result insertUser(@RequestBody User userPojo) {
    if (userPojo == null) {
        // 只传入定义好的错误
        return Result.error(ExceptionCodeEnum.EMPTY_PARAM);
    }
    if (userPojo.getUserName().contains("死")) {
        // 抛出自定义的错误信息
        return Result.error(ExceptionCodeEnum.ERROR_PARAM, "用户名不能包含特殊字符");
    }
    if (userPojo.getUserAge() < 18) {
        // 抛出自定义的错误信息
        return Result.error("年龄不能小于18");
    }
    if (!"男".equals(userPojo.getUserSex()) && !"女".equals(userPojo.getUserSex())) {
        // 抛出自定义的错误信息 可以自定义错误码
        return Result.error("性别只能是男或女");
    }
    return Result.success(userService.save(userPojo));
}

测试:

失败案例:

POST localhost/user/insertUser

请求体JSON:

{
  "userName": "张三",
  "userAge": "23",
  "userSex": "男的"
}

成功案例:

3、第二种:@RestControllerAdvice全局异常处理兜底

异常还有一种处理方式,就是利用Spring/SpringBoot提供的@RestControllerAdvice进行兜底处理,

 

/**
 * @ClassName GlobalExceptionHandler
 * @Author zhangzhixi
 * @Description 全局异常处理
 * @Date 2022-4-8 9:49
 * @Version 1.0
 */
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    /**
     * 业务异常
     *
     * @param
     * @return
     */
    @ExceptionHandler(BizException.class)
    public Result handleBizException(BizException bizException) {
        log.error("业务异常:{}", bizException.getMessage(), bizException);
        return Result.error(bizException.getError());
    }

    /**
     * 运行时异常
     *
     * @param e
     * @return
     */
    @ExceptionHandler(RuntimeException.class)
    public Result handleRunTimeException(RuntimeException e) {
        log.error("运行时异常: {}", e.getMessage(), e);
        return Result.error(ExceptionCodeEnum.ERROR);
    }

}

测试 

总结

一般来说,全局异常处理只是一种兜底的异常处理策略,也就是说提倡自己处理异常。但现在其实很多人都喜欢直接在代码中抛异常,全部交给@RestControllerAdvice处理:

这个异常抛到@RestControllerAdvice后,其实还是被封装成Result返回了。

所以Result和@ResultControllerAdvice两种方式归根结底是一样的: