说明

那么异常统一处理有什么好处呢?

  • 提高用户体验;
  • 业务逻辑和异常处理逻辑解耦;
  • 对异常进行分类统一处理,减少冗余代码;
  • 便于代码风格统一,并且更优雅(比如参数校验的时候,得写很多if else,并且不同的人写法不一致);

异常处理

  • Spring在3.2版本增加了一个注解@ControllerAdvice,可以与@ExceptionHandler、@InitBinder、@ModelAttribute 等注解注解配套使用。

  • 不过跟异常处理相关的只有注解@ExceptionHandler,从字面上看,就是 异常处理器 的意思

    • 其实际作用也是:若在某个Controller类定义一个异常处理方法,并在方法上添加该注解,那么当出现指定的异常时,会执行该处理异常的方法,
    • 其可以使用springmvc提供的数据绑定,比如注入HttpServletRequest等,还可以接受一个当前抛出的Throwable对象。
  • 这样一来,就必须在每一个Controller类都定义一套这样的异常处理方法,因为异常可以是各种各样。这样一来,就会造成大量的冗余代码,而且若需要新增一种异常的处理逻辑,就必须修改所有Controller类,很不优雅。

    • 那就定义个类似BaseController的基类,这种做法虽然没错,但仍不尽善尽美,因为这样的代码有一定的侵入性和耦合性。
    • 简简单单的Controller,我为啥非得继承这样一个类呢,万一已经继承其他基类了呢。大家都知道Java只能继承一个类。
  • @ControllerAdvice注解可以把异常处理器应用到所有控制器,而不是单个控制器。

    • 借助该注解,我们可以实现:在独立的某个地方,比如单独一个类,定义一套对各种异常的处理机制,然后在类的签名加上注解@ControllerAdvice
    • 统一对 不同阶段的、不同异常 进行处理。这就是统一异常处理的原理。
  • 异常按阶段进行分类

    • 进入Controller前的异常
    • Service 层异常(controller转接给service)

统一API返回格式

  • 在开发中,对接口的响应通常为状态码+消息+数据

  • 提供的类型

    • 成功 带数据
    • 成功 不带数据+ 提示消息
    • 失败 带消息
    • 失败 带消息 + 数据
    • of(状态码,消息,数据)
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    @Getter
    public class ApiResponse {
    /** 状态码 */
    private Integer code;

    /** 返回内容 */
    private String message;

    /** 返回数据 */
    private Object data;

    /** 无参构造函数 */
    private ApiResponse() {}

    /**
    * 全参构造函数
    *
    * @param code 状态码
    * @param message 返回内容
    * @param data 返回数据
    */
    private ApiResponse(Integer code, String message, Object data) {
    this.code = code;
    this.message = message;
    this.data = data;
    }

    /**
    * 构造一个自定义的API返回
    *
    * @param code 状态码
    * @param message 返回内容
    * @param data 返回数据
    * @return ApiResponse
    */
    public static ApiResponse of(Integer code, String message, Object data) {
    return new ApiResponse(code, message, data);
    }

    /**
    * 构造一个成功且带数据的API返回
    *
    * @param data 返回数据
    * @return ApiResponse
    */
    public static ApiResponse ofSuccess(Object data) {
    return ofStatus(Status.OK, data);
    }

    /**
    * 构造一个成功且自定义消息的API返回
    *
    * @param message 返回内容
    * @return ApiResponse
    */
    public static ApiResponse ofMessage(String message) {
    return of(Status.OK.getCode(), message, null);
    }

    /**
    * 构造一个异常且带数据的API返回
    *
    * @param t 异常
    * @param data 返回数据
    * @param <T> {@link BaseException} 的子类
    * @return ApiResponse
    */
    public static <T extends BaseException> ApiResponse ofException(T t, Object data) {
    return of(t.getCode(), t.getMessage(), data);
    }

    /**
    * 构造一个异常且不带数据的API返回
    *
    * @param t 异常
    * @param <T> {@link BaseException} 的子类
    * @return ApiResponse
    */
    public static <T extends BaseException> ApiResponse ofException(T t) {
    return ofException(t, null);
    }
    }

引入枚举

  • 响应中定义的code、message对属性在枚举类一般也会定义,可以引入枚举

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    @Getter
    public enum Status {
    OK(200, "操作成功"),
    /** 未知异常 */
    UNKNOWN_ERROR(500, "服务器出错啦");

    /** 状态码 */
    private Integer code;
    /** 内容 */
    private String message;

    /** 操作成功 */
    Status(Integer code, String message) {
    this.code = code;
    this.message = message;
    }
    }
  • 约定一些常用的配置

    1
    2
    3
    4
    5
    6
    7
    OK(200, "操作成功"),
    UNKNOWN_ERROR(500, "服务器出错啦");

    public static ApiResponse ofSuccess(Object data) {
    return ofStatus(Status.OK, data);
    }

  • ApiResponse 添加带状态的构造方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    /**
    * 构造一个有状态的API返回
    *
    * @param status 状态 {@link Status}
    * @return ApiResponse
    */
    public static ApiResponse ofStatus(Status status) {
    return ofStatus(status, null);
    }

    /**
    * 构造一个有状态且带数据的API返回
    *
    * @param status 状态 {@link Status}
    * @param data 返回数据
    * @return ApiResponse
    */
    public static ApiResponse ofStatus(Status status, Object data) {
    return of(status.getCode(), status.getMessage(), data);
    }

测试

  • 测试接口返回的是数据格式

    1
    2
    3
    4
    5
    6
    7
    8
    @RestController
    public class TestController {
    @GetMapping("/test1")
    public ApiResponse test1() {
    // int i = 1 / 0;
    return ApiResponse.ofMessage("OK");
    }
    }
  • 打开注释后,重新测试,页面会直接打印错误日志

    1
    2
    3
    4
    5
    6
    Whitelabel Error Page This application has no explicit mapping for /error, so
    you are seeing this as a fallback. Wed Jun 30 16:20:38 CST 2021 There was an
    unexpected error (type=Internal Server Error, status=500). / by zero
    java.lang.ArithmeticException: / by zero at
    tk.fulsun.demo.controller.TestController.test2(TestController.java:20) at
    sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)

处理异常

  • 根据上面的测试结果,在程序出现异常时候,打印错误日志不是很友好,希望返回的是统一格式,在message中提示错误原因
  • @ControllerAdvice可以全局扩展Controller的功能
    • @ExceptionHandler、value属性指定要处理的异常类型

异常性能优化

  • RuntimeException类和Exception中有如下方法

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    protected RuntimeException(String message, Throwable cause,
    boolean enableSuppression,
    boolean writableStackTrace) {
    super(message, cause, enableSuppression, writableStackTrace);
    }

    protected Exception(String message, Throwable cause,
    boolean enableSuppression,
    boolean writableStackTrace) {
    super(message, cause, enableSuppression, writableStackTrace);
    }
  • 参数解释

    • message 异常的描述信息,也就是在打印栈追踪信息时异常类名后面紧跟着的描述字符串
    • cause 导致此异常发生的父异常,即追踪信息里的caused by
    • enableSuppress 关于异常挂起的参数,这里我们永远设为false即可
    • writableStackTrace 表示是否生成栈追踪信息,只要将此参数设为false
  • 对于一些业务(参数的校验),并不需要生成栈追踪信息,writableStackTrace 可以设置为false。

  • 原文链接:https://blog.csdn.net/neosmith/article/details/82626960

编写基本异常类

  • 基础异常类,所有自定义异常类都需要继承本类

  • 定义异常返回码+异常消息,这里的参数可以使用状态枚举来标识

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    @Getter
    public class BaseException extends RuntimeException {

    private static final long serialVersionUID = 1L;

    /** 返回码 */
    protected Integer code;
    /** 异常消息参数 */
    protected String message;

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

    public BaseException(Integer code, String message, boolean recordStackTrace) {
    super(message, null, false, recordStackTrace);
    this.code = code;
    this.message = message;
    }

    public BaseException(Status status) {
    this(status, false);
    }

    public BaseException(Status status, boolean recordStackTrace) {
    super(status.getMessage(), null, false, recordStackTrace);
    this.code = status.getCode();
    this.message = status.getMessage();
    }

    /**
    * 包含message和cause, 会记录栈异常
    */
    public BaseException(Status status, Throwable cause) {
    super(status.getMessage(), cause, false, true);
    this.code = status.getCode();
    this.message = status.getMessage();
    }
    }

自定义业务异常

  • 继承基本异常类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    public class BusinessException extends BaseException {

    /** 没有cause, 也不记录栈异常, 性能最高 */
    public BusinessException(Integer code, String message) {
    this(code, message, false);
    }

    /** 包含message, 可指定是否记录异常 */
    public BusinessException(Integer code, String message, boolean recordStackTrace) {
    super(code, message, recordStackTrace);
    }

    public BusinessException(Status status) {
    super(status);
    }

    public BusinessException(Status status, boolean recordStackTrace) {
    super(status, recordStackTrace);
    }

    public BusinessException(Status status, Throwable cause) {
    super(status, cause);
    }
    }

设置异常处理

  • @ControllerAdvice 开启异常通知,指定对异常类型的处理,代码如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    @Slf4j
    @ControllerAdvice
    public class BusinessExceptionHandler {
    @ResponseBody
    @ExceptionHandler(value = {BusinessException.class,IllegalStateException.class})
    public ApiResponse BusinessErrorHandle(BusinessException exception) {
    log.error("【BusinessException】:{}", exception.getMessage());
    return ApiResponse.ofException((exception));
    }

    @ExceptionHandler(value = BaseException.class)
    public ApiResponse BaseException(BaseException exception) {
    log.error("【BaseException】:{}", exception.getMessage());
    return ApiResponse.ofException((exception));
    }
    }

测试

  • 测试代码如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    @GetMapping("/test1")
    public ApiResponse test1() {
    try {
    int i = 1 / 0;
    Thread.sleep(1000);
    } catch (ArithmeticException | InterruptedException e) {
    e.printStackTrace();
    throw new BusinessException(1001, "测试异常", false);
    }
    return ApiResponse.ofMessage("OK");
    }
  • 重新测试后,页面没有直接打印错误日志数据类型符合要求

断言优化try/catch

断言优点

  • 有没有感觉第一种判定非空的写法很优雅,第二种写法则是相对丑陋的 if {…} 代码块。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    import org.springframework.util.Assert;

    @Test
    public void test1() {
    User user = userDao.selectById(userId);
    Assert.notNull(user, "用户不存在.");
    ...
    }

    @Test
    public void test2() {
    // 另一种写法
    User user = userDao.selectById(userId);
    if (user == null) {
    throw new IllegalArgumentException("用户不存在.");
    }
    }
  • Assert.notNull() 背后到底做了什么呢?下面是 Assert 的部分源码

1
2
3
4
5
6
7
8
9
10
11
public static void isNull(@Nullable Object object, String message) {
if (object != null) {
throw new IllegalArgumentException(message);
}
}

public static void notNull(@Nullable Object object, String message) {
if (object == null) {
throw new IllegalArgumentException(message);
}
}
  • 可以看到,Assert 其实就是帮我们把 if {…} 封装了一下,虽然很简单,但不可否认的是编码体验至少提升了一个档次。

自定义断言类

  • 远吗断言失败后抛出的异常是IllegalArgumentException 这些内置异常

  • 我门希望能够抛出我们自己定义的异常

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    public interface Assert {
    /**
    * 创建异常
    * @param args
    * @return
    */
    BaseException newException(Object... args);

    /**
    * <p>断言对象obj非空。如果对象obj为空,则抛出异常
    * @param obj 待判断对象
    */
    default void assertNotNull(Object obj) {
    if (obj == null) {
    throw newException(obj);
    }
    }

    }
  • 接口中定义默认方法是Java8的新语法,断言方法是使用接口的默认方法定义的

  • 当断言失败后,抛出的异常不是具体的某个异常,而是交由newException接口方法提供。

  • 因为业务逻辑中出现的异常基本都是对应特定的场景,UserNotFoundException,EmailisNullException等等,具体异常由Assert的实现类决定。

  • 按照这个思路,使用XXxAsser.assertNotNull(obj)就可以判断并抛出对应的异常了, 这里如果是多个异常情况,就得有多个定义等量的断言实现类类和异常类?

引入Enum

  • 上面的代码中,自定义异常BaseException有2个属性,即code、message,枚举也会定义这二个属性。

  • 如果Assert的实现类中包含了二个属性,那就只用关注异常类型了,java支撑接口的多继承

  • 定义响应接口

    1
    2
    3
    4
    5
    6
    7
    public interface IStatus {
    /** 状态码 */
    int getCode();

    /** 内容 */
    String getMessage();
    }
  • 业务异常类中添加这种情况

    1
    2
    3
    public BusinessException(IStatus status,  String message) {
    super(status.getCode(), message);
    }

继承Assert&IStatus接口

  • Assert接口返回异常信息,IStatus中获取状态码和消息

  • 接口支持多继承,实现代码如下

    1
    2
    3
    4
    5
    6
    7
    public interface BusinessExceptionAssert extends Assert, IStatus {
    @Override
    default BaseException newException(Object... args) {
    String msg = MessageFormat.format(this.getMessage(), args);
    return new BusinessException(this, msg);
    }
    }

枚举类

  • 现在只需要枚举继承BusinessExceptionAssert接口后,就能抛出我们想要的异常。

  • 使用枚举类只需根据特定的异常情况定义不同的枚举实例,,就能够针对不同情况抛出特定的异常(这里指携带特定的异常码和异常消息),这样既不用定义大量的异常类,同时还具备了断言的良好可读性

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    @Getter
    @AllArgsConstructor
    public enum BusineseResponseEnum implements BusinessExceptionAssert {
    /** 成功 */
    SUCCESS(0, "SUCCESS"),
    /** 服务器繁忙,请稍后重试 */
    SERVER_BUSY(9998, "服务器繁忙"),
    /** 服务器异常,无法识别的异常,尽可能对通过判断减少未定义异常抛出 */
    SERVER_ERROR(9999, "网络异常"),

    // Time
    DATE_NOT_NULL(5001, "日期不能为空"),
    DATETIME_NOT_NULL(5001, "时间不能为空"),
    TIME_NOT_NULL(5001, "时间不能为空"),
    DATE_PATTERN_MISMATCH(5002, "日期[%s]与格式[%s]不匹配,无法解析"),
    PATTERN_NOT_NULL(5003, "日期格式不能为空"),
    PATTERN_INVALID(5003, "日期格式[%s]无法识别"),
    ;

    /** 返回码 */
    private int code;
    /** 返回消息 */
    private String message;
    }

测试

  • 若不使用断言,代码可能如下

    1
    2
    3
    4
    5
    6
    7
    private void checkNotNull(Licence licence) {
    if (licence == null) {
    throw new LicenceNotFoundException();
    // 或者这样
    throw new BusinessException(1001, "测试异常");
    }
    }
  • 使用断言后

    1
    2
    3
    4
    5
    6
    7
    /**
    * 校验{@link Licence}存在
    * @param licence
    */
    private void checkNotNull(Licence licence) {
    BusineseResponseEnum.SERVER_ERROR.assertNotNull(licence);
    }

统一异常处理器类

  • 异常实际上只有两大类,一类是ServletExceptionServiceException,即对应 进入Controller前的异常 和 Service 层异常;然后 ServiceException 再分成自定义异常、未知异常。

  • 对应关系如下:

    • 进入Controller前的异常: handleServletException、handleBindException、handleValidException
    • 自定义异常: handleBusinessException、handleBaseException
    • 未知异常: handleException
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    @ControllerAdvice
    @ConditionalOnWebApplication
    public class UnifiedExceptionHandler {

    /**
    * 业务异常
    */
    @ExceptionHandler( // ....)
    @ResponseBody
    public ErrorResponse handleBusinessException(BaseException e) {
    }

    /**
    * 自定义异常
    */
    @ExceptionHandler( // ....)
    @ResponseBody
    public ErrorResponse handleBaseException(BaseException e) {
    }

    /**
    * Controller上一层相关异常
    */
    @ExceptionHandler( // ....)
    @ResponseBody
    public ErrorResponse handleServletException(Exception e) {
    }


    /**
    * 参数绑定异常
    */
    @ExceptionHandler( // ....)
    @ResponseBody
    public ErrorResponse handleBindException(BindException e) {
    }

    /**
    * 参数校验异常,将校验失败的所有异常组合成一条错误信息
    */
    @ExceptionHandler( // ....)
    @ResponseBody
    public ErrorResponse handleValidException(MethodArgumentNotValidException e) {

    }

    /**
    * 未定义异常
    */
    @ExceptionHandler(value = Exception.class)
    @ResponseBody
    public ErrorResponse handleException(Exception e) {
    }
    }

Controller前异常

handleServletException

  • 一个http请求,在到达Controller前,会对该请求的请求信息与目标控制器信息做一系列校验。这里简单说一下:

    异常 描述
    NoHandlerFoundException 首先根据请求Url查找有没有对应的控制器,若没有则会抛该异常,也就是大家非常熟悉的404异常;
    HttpRequestMethodNotSupportedException 若匹配到了(匹配结果是一个列表,不同的是http方法不同,如:Get、Post等),则尝试将请求的http方法与列表的控制器做匹配,若没有对应http方法的控制器,则抛该异常;
    HttpMediaTypeNotSupportedException 进入方法再对请求头与控制器支持的做比较,比如content-type请求头,若控制器的参数签名包含注解@RequestBody,但是请求的content-type请求头的值没有包含application/json,那么会抛该异常(当然,不止这种情况会抛这个异常);
    MissingPathVariableException 未检测到路径参数。比如url为:/licence/{licenceId},参数签名包含@PathVariable("licenceId"),当请求的url为/licence,在没有明确定义url为/licence的情况下,会被判定为:缺少路径参数;
    MissingServletRequestParameterException 缺少请求参数。比如定义了参数@RequestParam(“licenceId”) String licenceId,但发起请求时,未携带该参数,则会抛该异常;
    TypeMismatchException 参数类型匹配失败。比如:接收参数为Long型,但传入的值确是一个字符串,那么将会出现类型转换失败的情况,这时会抛该异常;
    HttpMessageNotReadableException 与上面的HttpMediaTypeNotSupportedException举的例子完全相反,即请求头携带了”content-type: application/json;charset=UTF-8”,但接收参数却没有添加注解@RequestBody,或者请求体携带的 json 串反序列化成 pojo 的过程中失败了,也会抛该异常;
    HttpMessageNotWritableException 返回的 pojo 在序列化成 json 过程失败了,那么抛该异常;
    HttpMediaTypeNotAcceptableException 未知
    ServletRequestBindingException 未知
    ConversionNotSupportedException 未知
    MissingServletRequestPartException 未知
    AsyncRequestTimeoutException 未知

handleBindException

  • 参数绑定校验异常

handleValidException

  • 参数校验异常

自定义异常

  • handleBusinessException、handleBaseException 处理自定义的业务异常
  • 只是handleBaseException处理的是除了 BusinessException 以外的所有业务异常。就目前来看,这2个是可以合并成一个的。

未知异常

handleException

  • 处理所有未知的异常,比如操作数据库失败的异常。

上面的 handleServletException、handleException 这两个处理器,返回的异常信息,不同环境返回的可能不一样

这些异常信息都是框架自带的异常信息,一般都是英文的,不太好直接展示给用户看,推荐统一返回SERVER_ERROR代表的异常信息。

404问题

  • 当请求没有匹配到控制器的情况下,会抛出NoHandlerFoundException异常,但其实默认情况下不是这样,默认情况下会出现:Whitelabel Error Page

  • 这个页面是如何出现的呢?

    • 当出现404的时候,默认是不抛异常的,而是 forward跳转到/error控制器,

    • spring也提供了默认的error控制器,BasicErrorController类的error如下:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      @RequestMapping(
      produces = {"text/html"}
      )
      public ModelAndView errorHtml(HttpServletRequest request, HttpServletResponse response) {
      HttpStatus status = this.getStatus(request);
      Map<String, Object> model = Collections.unmodifiableMap(this.getErrorAttributes(request, this.getErrorAttributeOptions(request, MediaType.TEXT_HTML)));
      response.setStatus(status.value());
      ModelAndView modelAndView = this.resolveErrorView(request, response, status, model);
      return modelAndView != null ? modelAndView : new ModelAndView("error", model);
      }
  • 那么,如何让404也抛出异常呢,只需在properties文件中加入如下配置即可:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    spring.mvc.throw-exception-if-no-handler-found=true
    spring.resources.add-mappings=false

    ------------------

    spring:
    mvc:
    throw-exception-if-no-handler-found: true
    resources:
    add-mappings: false
  • 如此,就可以异常处理器中捕获它了,然后前端只要捕获到特定的状态码,立即跳转到404页面即可。

完整配置

  • 代码如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    166
    167
    168
    169
    170
    171
    172
    173
    174
    175
    176
    177
    178
    179
    180
    181
    182
    183
    184
    185
    186
    187
    188
    189
    190
    191
    192
    193
    194
    195
    196
    197
    198
    199
    200
    201
    202
    203
    204
    205
    206
    207
    208
    209
    210
    211
    212
    213
    214
    215
    216
    217
    218
    219
    220
    221
    222
    223
    224
    225
    226
    227
    228
    229
    230
    231
    232
    233
    234
    235
    package tk.fulsun.demo.exception.handler;

    import javax.validation.ConstraintViolation;
    import javax.validation.ConstraintViolationException;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.beans.ConversionNotSupportedException;
    import org.springframework.beans.TypeMismatchException;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
    import org.springframework.http.converter.HttpMessageNotReadableException;
    import org.springframework.http.converter.HttpMessageNotWritableException;
    import org.springframework.validation.BindException;
    import org.springframework.validation.BindingResult;
    import org.springframework.validation.FieldError;
    import org.springframework.validation.ObjectError;
    import org.springframework.web.HttpMediaTypeNotAcceptableException;
    import org.springframework.web.HttpMediaTypeNotSupportedException;
    import org.springframework.web.HttpRequestMethodNotSupportedException;
    import org.springframework.web.bind.MethodArgumentNotValidException;
    import org.springframework.web.bind.MissingPathVariableException;
    import org.springframework.web.bind.MissingServletRequestParameterException;
    import org.springframework.web.bind.ServletRequestBindingException;
    import org.springframework.web.bind.annotation.ControllerAdvice;
    import org.springframework.web.bind.annotation.ExceptionHandler;
    import org.springframework.web.bind.annotation.ResponseBody;
    import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
    import org.springframework.web.multipart.support.MissingServletRequestPartException;
    import org.springframework.web.servlet.NoHandlerFoundException;
    import tk.fulsun.demo.exception.BaseException;
    import tk.fulsun.demo.exception.BusinessException;
    import tk.fulsun.demo.exception.constant.enums.ArgumentResponseEnum;
    import tk.fulsun.demo.exception.constant.enums.CommonResponseEnum;
    import tk.fulsun.demo.exception.constant.enums.ServletResponseEnum;
    import tk.fulsun.demo.exception.i18n.UnifiedMessageSource;
    import tk.fulsun.demo.exception.pojo.response.ErrorResponse;

    /**
    * @author fulsun
    * @date 7/1/2021
    */
    @Slf4j
    @ControllerAdvice
    @ConditionalOnWebApplication
    public class UnifiedExceptionHandler {
    /** 生产环境 */
    private static final String ENV_PROD = "prod";

    /** 当前环境 */
    @Value("${spring.profiles.active}")
    private String profile;

    @Autowired private UnifiedMessageSource unifiedMessageSource;

    /**
    * 业务异常
    *
    * @param e 异常
    * @return 异常结果
    */
    @ExceptionHandler(value = BusinessException.class)
    @ResponseBody
    public ErrorResponse handleBusinessException(BaseException e) {
    log.error(e.getMessage(), e);
    return new ErrorResponse(e.getResponseEnum().getCode(), getMessage(e));
    }

    /**
    * 自定义异常
    *
    * @param e 异常
    * @return 异常结果
    */
    @ExceptionHandler(value = BaseException.class)
    @ResponseBody
    public ErrorResponse handleBaseException(BaseException e) {
    log.error(e.getMessage(), e);

    return new ErrorResponse(e.getResponseEnum().getCode(), getMessage(e));
    }

    /**
    * Controller上一层相关异常
    *
    * @param e 异常
    * @return 异常结果
    */
    @ExceptionHandler({
    NoHandlerFoundException.class,
    HttpRequestMethodNotSupportedException.class,
    HttpMediaTypeNotSupportedException.class,
    HttpMediaTypeNotAcceptableException.class,
    MissingPathVariableException.class,
    MissingServletRequestParameterException.class,
    TypeMismatchException.class,
    HttpMessageNotReadableException.class,
    HttpMessageNotWritableException.class,
    // BindException.class,
    // MethodArgumentNotValidException.class
    ServletRequestBindingException.class,
    ConversionNotSupportedException.class,
    MissingServletRequestPartException.class,
    AsyncRequestTimeoutException.class
    })
    @ResponseBody
    public ErrorResponse handleServletException(Exception e) {
    log.error(e.getMessage(), e);
    int code = CommonResponseEnum.SERVER_ERROR.getCode();
    try {
    ServletResponseEnum servletExceptionEnum =
    ServletResponseEnum.valueOf(e.getClass().getSimpleName());
    code = servletExceptionEnum.getCode();
    } catch (IllegalArgumentException e1) {
    log.error(
    "class [{}] not defined in enum {}",
    e.getClass().getName(),
    ServletResponseEnum.class.getName());
    }

    if (ENV_PROD.equals(profile)) {
    // 当为生产环境, 不适合把具体的异常信息展示给用户, 比如404.
    code = CommonResponseEnum.SERVER_ERROR.getCode();
    BaseException baseException = new BaseException(CommonResponseEnum.SERVER_ERROR);
    String message = getMessage(baseException);
    return new ErrorResponse(code, message);
    }

    return new ErrorResponse(code, e.getMessage());
    }

    /**
    * 参数绑定异常
    *
    * @param e 异常
    * @return 异常结果
    */
    @ExceptionHandler(value = BindException.class)
    @ResponseBody
    public ErrorResponse handleBindException(BindException e) {
    log.error("参数绑定异常", e);

    return wrapperBindingResult(e.getBindingResult());
    }

    /**
    * 参数校验(Valid)异常,将校验失败的所有异常组合成一条错误信息
    *
    * @param e 异常
    * @return 异常结果
    */
    @ExceptionHandler(value = MethodArgumentNotValidException.class)
    @ResponseBody
    public ErrorResponse handleValidException(MethodArgumentNotValidException e) {
    log.error("参数校验异常", e);

    return wrapperBindingResult(e.getBindingResult());
    }

    /**
    * 参数校验(违例)异常,将校验失败的所有异常组合成一条错误信息
    *
    * @param e 异常
    * @return 异常结果
    */
    @ExceptionHandler(value = ConstraintViolationException.class)
    @ResponseBody
    public ErrorResponse handleViolationException(ConstraintViolationException e) {
    log.error("参数校验异常", e);
    StringBuilder msg = new StringBuilder();
    msg.append(", ");
    for (ConstraintViolation constraintViolation : e.getConstraintViolations()) {
    // msg.append(constraintViolation.getPropertyPath()).append(": ");
    msg.append(constraintViolation.getMessage() == null ? "" : constraintViolation.getMessage());
    }
    return new ErrorResponse(ArgumentResponseEnum.VALID_ERROR.getCode(), msg.substring(2));
    }

    /**
    * 包装绑定异常结果
    *
    * @param bindingResult 绑定结果
    * @return 异常结果
    */
    private ErrorResponse wrapperBindingResult(BindingResult bindingResult) {
    StringBuilder msg = new StringBuilder();
    for (ObjectError error : bindingResult.getAllErrors()) {
    msg.append(", ");
    if (error instanceof FieldError) {
    msg.append(((FieldError) error).getField()).append(": ");
    }
    msg.append(error.getDefaultMessage() == null ? "" : error.getDefaultMessage());
    }

    return new ErrorResponse(ArgumentResponseEnum.VALID_ERROR.getCode(), msg.substring(2));
    }

    /**
    * 未定义异常
    *
    * @param e 异常
    * @return 异常结果
    */
    @ExceptionHandler(value = Exception.class)
    @ResponseBody
    public ErrorResponse handleException(Exception e) {
    log.error(e.getMessage(), e);

    if (ENV_PROD.equals(profile)) {
    // 当为生产环境, 不适合把具体的异常信息展示给用户, 比如数据库异常信息.
    int code = CommonResponseEnum.SERVER_ERROR.getCode();
    BaseException baseException = new BaseException(CommonResponseEnum.SERVER_ERROR);
    String message = getMessage(baseException);
    return new ErrorResponse(code, message);
    }

    return new ErrorResponse(CommonResponseEnum.SERVER_ERROR.getCode(), e.getMessage());
    }

    /**
    * 获取国际化消息
    *
    * @param e 异常
    * @return
    */
    public String getMessage(BaseException e) {
    String code = "response." + e.getResponseEnum().toString();
    String message = unifiedMessageSource.getMessage(code, e.getArgs());

    if (message == null || message.isEmpty()) {
    return e.getMessage();
    }

    return message;
    }
    }

统一结果优化

  • 上面提到了返回结果的数据结构,使用ApiResponse类表示。

  • code、message 是所有返回结果中必有的字段,而当需要返回数据时,则需要另一个字段 data 来表示。

  • 对ApiResponse类进行优化,先定义一个 BaseResponse 来作为所有返回结果的基类;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @Getter
    @AllArgsConstructor
    @NoArgsConstructor
    public class BaseResponse {
    /** 状态码 */
    private Integer code;

    /** 返回内容 */
    private String message;
    }
  • 然后定义一个通用返回结果类CommonResponse,继承 BaseResponse,而且多了字段 data

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    @Data
    @EqualsAndHashCode(callSuper = true)
    public class CommonResponse<T> extends BaseResponse {
    /**
    * 数据列表
    */
    protected T data;

    public CommonResponse() {
    super();
    }

    public CommonResponse(T data) {
    super();
    this.data = data;
    }
    }
  • 区分成功和失败返回结果,于是再定义一个 ErrorResponse;

    1
    2
    3
    4
    5
    6
    7
    8
    9
    public class ErrorResponse extends BaseResponse {

    public ErrorResponse() {
    }

    public ErrorResponse(int code, String message) {
    super(code, message);
    }
    }
  • 最后还有返回的数据带有分页信息的结构,因为这种接口比较常见,所以有必要单独定义一个返回结果类 QueryDataResponse,该类继承自 CommonResponse,只是把 data 字段的类型限制为 QueryDdata,QueryDdata中定义了分页信息相应的字段,即totalCount、pageNo、 pageSize、records。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    @Data
    public class QueryData<T> {
    /** 数据列表 */
    private List<T> records;
    /** 总记录数 */
    private long totalCount;
    /** 当前页,从1开始 */
    private long pageNo;
    /** 分页大小 */
    private long pageSize;

    public QueryData() {}

    public QueryData(List<T> records, long totalCount, int pageNo, int pageSize) {
    this.records = records;
    this.totalCount = totalCount;
    this.pageNo = pageNo;
    this.pageSize = pageSize;
    }
    }


    @Data
    @EqualsAndHashCode(callSuper = true)
    public class QueryDataResponse<T> extends CommonResponse<QueryData<T>> {
    public QueryDataResponse() {
    }

    public QueryDataResponse(QueryData<T> data) {
    super(data);
    }
    }

验证统一异常处理

  • 因为统一异常处理可以说是通用的,所有可以设计成一个 common包,以后每一个新项目/模块只需引入该包即可。

  • 项目使用Mybatis项目的 dao和mapper 文件,修改配置文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    spring:
    datasource:
    # com.mysql.cj.jdbc.Driver
    driver-class-name: com.mysql.cj.jdbc.Driver
    # jdbc:mysql://localhost:3306/test
    url: jdbc:mysql://192.168.56.101:3306/springbootstudy?useUnicode=true&characterEncoding=UTF-8&zeroDateTimeBehavior=convertToNull&serverTimezone=Asia/Shanghai&useSSL=false
    username: test
    password: 123456
    mybatis:
    # 映射实体地址
    type-aliases-package: tk.fulsun.demo.model
    # xml配置文件地址
    mapper-locations: classpath:mybatis/mapper/*.xml
    # mybatis全局配置,与configuration 不能同时存在
    # config-location: classpath:mybatis/mybatis-config.xml
    configuration:
    # 开启驼峰命名
    map-underscore-to-camel-case: true
    #当传入null的时候对应的jdbctype
    jdbc-type-for-null: null
    #用map接受查询结果时,会自动将查询结果为null的字段忽略
    #查询到0条记录时 会接收到一个所有key值都为null的map
    #只查询一个字段,而用map接收 会接收到一个为null的map
    call-setters-on-nulls: true

Controller类

  • 为了方便使用Controller层进行测试

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    package tk.fulsun.demo.controller;

    import java.util.List;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.web.bind.annotation.DeleteMapping;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.PathVariable;
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.PutMapping;
    import org.springframework.web.bind.annotation.RequestBody;
    import org.springframework.web.bind.annotation.RestController;
    import tk.fulsun.demo.exception.constant.enums.BusinessResponseEnum;
    import tk.fulsun.demo.exception.pojo.response.BaseResponse;
    import tk.fulsun.demo.exception.pojo.response.CommonResponse;
    import tk.fulsun.demo.mapper.UserMapper;
    import tk.fulsun.demo.model.User;

    /**
    * @author fulsun
    * @title: TestController
    * @projectName springboot-study
    * @description: 用户控制层代码
    * @date 5/26/2021 1:57 PM
    */
    @RestController
    public class UserController {

    @Autowired private UserMapper userMapper;

    @GetMapping("/user/all")
    public List<User> getAllUser() {
    return userMapper.selectByExample(null);
    }

    @GetMapping("/user/info/{id}")
    public BaseResponse getUserById(@PathVariable("id") int id) {
    User user = userMapper.selectByPrimaryKey(id);
    BusinessResponseEnum.USER_NOT_FOUND.assertNotNull(user);
    return new CommonResponse<>(user);
    }

    @PostMapping("/user/add")
    public BaseResponse addUser(@RequestBody User user) {
    int status = userMapper.insertSelective(user);
    BusinessResponseEnum.OPERATION_FAIL.assertIsTrue(status > 0);
    return new CommonResponse(user);
    }

    @PutMapping("/user/update")
    public BaseResponse updateUser(@RequestBody User user) {
    int status = userMapper.updateByPrimaryKeySelective(user);
    BusinessResponseEnum.OPERATION_FAIL.assertIsTrue(status > 0);
    return new CommonResponse<>(user);
    }

    @DeleteMapping("/user/{id}")
    public BaseResponse delUser(@PathVariable("id") int id) {
    int status = userMapper.deleteByPrimaryKey(id);
    BusinessResponseEnum.OPERATION_FAIL.assertIsTrue(status > 0);
    return new BaseResponse(BusinessResponseEnum.OPERATION_SUCCESS);
    }
    }
  • 启动项目后,查询所有的数据

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    [
    {
    "id": 1,
    "name": "wyq",
    "age": 18
    },
    {
    "id": 11,
    "name": "wls",
    "age": 22
    }
    ]

捕获自定义异常

  • 获取不存在的 User :http://127.0.0.1:8080/user/info/2

捕获进入 Controller 前的异常

  • 访问不存在的接口:http://127.0.0.1:8080/user/auth

  • http 方法不支持:http://localhost:8080/user/all

校验异常

通过配置@Validation可以很轻松的完成对数据的约束。@Validated作用在类、方法和参数上, @valid可以实现属性嵌套校验(对类中的属性的内容进行校验)

在全局的异常处理类,我们可以对参数校验失败后抛出的异常进行处理, 因为参数绑定校验异常的异常信息的获取方式与其它异常不一样,所以才把这3种情况的异常从 进入 Controller 前的异常 单独拆出来

MethodArgumentNotValidException

  • 前端提交的方式为json格式有效,出现异常时会被该异常类处理

  • 修改post方法,加上@Validated

    1
    2
    3
    4
    5
    6
    @PostMapping("/user/add")
    public BaseResponse addUser(@Validated @RequestBody User user) {
    int status = userMapper.insertSelective(user);
    BusinessResponseEnum.OPERATION_FAIL.assertIsTrue(status > 0);
    return new CommonResponse(user);
    }
  • User对象上添加校验规则

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    @Data
    public class User implements Serializable {
    @Min(value = 1, message = "不能小于1")
    private Integer id;

    @NotBlank(message = "姓名不能为空")
    private String name;

    @NotNull(message = "年龄不能为空")
    private Integer age;
    }
    //----------------------
    <dependency>
    <groupId>org.hibernate.validator</groupId>
    <artifactId>hibernate-validator</artifactId>
    </dependency>
  • 异常处理器

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    /**
    * 参数校验(Valid)异常,将校验失败的所有异常组合成一条错误信息
    *
    * @param e 异常
    * @return 异常结果
    */
    @ExceptionHandler(value = MethodArgumentNotValidException.class)
    @ResponseBody
    public ErrorResponse handleValidException(MethodArgumentNotValidException e) {
    log.error("参数校验异常", e);
    return wrapperBindingResult(e.getBindingResult());
    }
  • body使用JSON,测试结果

BindException

  • 仅对于表单提交有效,对于以json格式提交将会失效

  • 添加getlist方法,使用Dto来接收请求参数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    @GetMapping("/user/list")
    @Validated
    public BaseResponse getUser(@Validated UserDto user) {
    UserExample example = new UserExample();
    if (!StringUtils.isEmpty(user.getName())) {
    UserExample.Criteria criteria1 = example.createCriteria();
    criteria1.andNameLike("%" + user.getName() + "%");
    example.or(criteria1);
    }
    if (null != user.getAge()) {
    UserExample.Criteria criteria2 = example.createCriteria();
    criteria2.andAgeEqualTo(user.getAge());
    example.or(criteria2);
    }
    List<User> lists = userMapper.selectByExample(example);
    return new CommonResponse<>(lists);
    }
    1
    2
    3
    4
    5
    6
    7
    @Data
    public class UserDto {
    private String name;

    @Min(value = 1, message = "最小年龄为1")
    private Integer age;
    }
  • 测试 http://localhost:8080/user/list?age=0

ConstraintViolationException

  • 方法内的参数校验失败时会交给ConstraintViolationException

  • 修改getById方法,校验id不为空

    1
    2
    3
    4
    5
    6
    7
    @GetMapping("/user/info")
    public BaseResponse getUserByName(@Validated @NotBlank(message = "姓名不能为空") @RequestParam(value = "name") String name) {
    UserExample userExample = new UserExample();
    userExample.createCriteria().andNameEqualTo("name");
    List<User> user = userMapper.selectByExample(userExample);
    return new CommonResponse<>(user);
    }
  • 测试 http://localhost:8080/user/info?name=

捕获未知异常

  • 假设我们现在修改了表名,但不修改数据库表结构,然后重新访问

    1
    2
    3
    4
    mysql> use springbootstudy
    Database changed
    mysql> alter table user rename to t_user;
    Query OK, 0 rows affected (0.04 sec)
  • 捕获数据库异常

扩展

生产环境转换

  • 在生产环境,若捕获到 未知异常 或者 ServletException,因为都是一长串的异常信息,若直接展示给用户看,显得不够专业,

  • 我们可以这样做:当检测到当前环境是生产环境,那么直接返回 “网络异常”。

  • 修改当前环境为生产环境

    1
    2
    3
    spring:
    profiles:
    active: prod #default
  • 异常处理添加环境判断

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    /** 生产环境 */
    private static final String ENV_PROD = "prod";

    /** 当前环境 */
    @Value("${spring.profiles.active}")
    private String profile;

    @ExceptionHandler(value = Exception.class)
    @ResponseBody
    public ErrorResponse handleException(Exception e) {
    log.error(e.getMessage(), e);

    if (ENV_PROD.equals(profile)) {
    // 当为生产环境, 不适合把具体的异常信息展示给用户, 比如数据库异常信息.
    int code = CommonResponseEnum.SERVER_ERROR.getCode();
    // 返回“网络异常”
    BaseException baseException = new BaseException(CommonResponseEnum.SERVER_ERROR);
    String message = getMessage(baseException);
    return new ErrorResponse(code, message);
    }
    return new ErrorResponse(CommonResponseEnum.SERVER_ERROR.getCode(), e.getMessage());
    }
  • 生产环境数据库异常情况测试

消息国际化

  • 当需要考虑国际化的时候,捕获异常后的异常信息一般不能直接返回,需要转换成对应的语言,

  • 获取消息的时候进行国际化映射,逻辑如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    package tk.fulsun.demo.exception.i18n;

    import java.util.Locale;
    import javax.annotation.Resource;
    import org.springframework.context.MessageSource;
    import org.springframework.context.i18n.LocaleContextHolder;
    import org.springframework.stereotype.Service;

    /**
    * @author fulsun
    * @date 7/1/2021
    */
    @Service
    public class UnifiedMessageSource {
    @Resource
    private MessageSource messageSource;

    /**
    * 获取国际化消息
    * @param code 消息code
    * @return
    */
    public String getMessage(String code) {

    return getMessage(code, null);
    }

    /**
    * 获取国际化消息
    * @param code 消息code
    * @param args 参数
    * @return
    */
    public String getMessage(String code, Object[] args) {

    return getMessage(code, args, "");
    }

    /**
    * 获取国际化消息
    * @param code 消息code
    * @param args 参数
    * @param defaultMessage 默认消息
    * @return
    */
    public String getMessage(String code, Object[] args, String defaultMessage) {
    Locale locale = LocaleContextHolder.getLocale();
    return messageSource.getMessage(code, args, defaultMessage, locale);
    }
    }

总结

使用 断言枚举类 相结合的方式,再配合统一异常处理,基本大部分的异常都能够被捕获。为什么说大部分异常,因为当引入 spring cloud security 后,还会有认证/授权异常,网关的服务降级异常、跨模块调用异常、远程调用第三方服务异常等,这些异常的捕获方式与本文介绍的不太一样, 以后会有单独的文章介绍。