重学SpringBoot系列之统一全局异常处理

Posted 大忽悠爱忽悠

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了重学SpringBoot系列之统一全局异常处理相关的知识,希望对你有一定的参考价值。


设计一个优秀的异常处理机制

异常处理的乱象例举

乱象一:捕获异常后只输出到控制台

前端js-ajax代码

$.ajax(
    type: "GET",
    url: "/user/add",
    dataType: "json",
    success: function(data)
        alert("添加成功");
    
);

后端业务代码

try 
    // do something
 catch (XyyyyException e) 
    e.printStackTrace();

问题:

  • 后端直接将异常捕获,而且只做了日志打印。用户体验非常差,一旦后台出错,用户没有任何感知,页面无状态。
  • 后端只给出前端异常结果,没有给出异常的原因的描述。用户不知道是自己操作输入错误,还是系统bug。用户无法判断自己需要等一下再操作?还是继续下一步?
  • 如果没有人去经常关注服务端日志,不会有人发现系统出现异常。

乱象二:混乱的返回方式

前端代码

$.ajax(
    type: "GET",
    url: "/goods/add",
    dataType: "json",
    success: function(data) 
        if (data.flag) 
            alert("添加成功");
         else 
            alert(data.message);
        
    ,
    error: function(data)
        alert("添加失败");
    
);

后端代码

@RequestMapping("/goods/add")
@ResponseBody
public Map add(Goods goods) 
    Map map = new HashMap();
    try 
        // do something
        map.put(flag, true);
     catch (Exception e) 
        e.printStackTrace();
        map.put("flag", false);
        map.put("message", e.getMessage());
    
    reutrn map;

问题:

  • 每个人返回的数据有每个人自己的规范,你叫flag他叫isOK,你的成功code是0,它的成功code是0000。这样导致后端书写了大量的异常返回逻辑代码,前端也随之每一个请求一套异常处理逻辑。很多重复代码。
  • 如果是前端后端一个人开发还勉强能用,如果前后端分离,这就是系统灾难。

该如何设计异常处理


面向相关方友好

  • 后端开发人员职责单一,只需要将异常捕获并转换为自定义异常一直对外抛出。不需要去想页面跳转404,以及异常响应的数据结构的设计。
  • 面向前端人员友好,后端返回给前端的数据应该有统一的数据结构,统一的规范。不能一个人一个响应的数据结构。而在此过程中不需要后端开发人员做更多的工作,交给全局异常处理器去处理“异常”到“响应数据结构”的转换。
  • 面向用户友好,用户能够清楚的知道异常产生的原因。这就要求自定义异常,全局统一处理,ajax接口请求响应统一的异常数据结构,页面模板请求统一跳转到404页面
  • 面向运维友好,将异常信息合理规范的持久化,以日志的形式存储起来,以便查询。

为什么要将系统运行时异常捕获,转换为自定义异常抛出?

答:因为用户不认识ConnectionTimeOutException类似这种异常是什么东西,但是转换为自定义异常就要求程序员对运行时异常进行一个翻译,比如:自定义异常里面应该有message字段,后端程序员应该明确的在message字段里面用面向用户的友好语言,说明服务端发生了什么。


开发规范

  • Controller、Service、DAO层拦截异常转换为自定义异常,不允许将异常私自截留。必须对外抛出。
  • 统一数据响应代码,使用http状态码,不要自定义。自定义不方便记忆,HTTP状态码程序员都知道。但是太多了程序员也记不住,在项目组规定范围内使用几个就可以。比如:200请求成功,400用户输入错误导致的异常,500系统内部异常,999未知异常。
  • 自定义异常里面有message属性,用对用户友好的语言描述异常的发生情况,并赋值给message.
  • 不允许对父类Exception统一catch,要分小类catch,这样能够清楚地将异常转换为自定义异常传递给前端。

自定义异常和相关数据结构

该如何设计数据结构

  • CustomException 自定义异常。核心要素包含异常错误编码(400,500)、异常错误信息message。
  • ExceptionTypeEnum 枚举异常分类,将异常分类固化下来,防止开发人员思维发散。
  • AjaxResponse 用于响应HTTP 请求的统一数据结构。

枚举异常的类型

为了防止开发人员大脑发散,每个开发人员都不断的发明自己的异常类型,我们需要规定好异常的类型(枚举)。比如:系统异常、用户(输入)操作导致的异常、其他异常等。

public enum CustomExceptionType 

    USER_INPUT_ERROR(400,"您输入的数据错误或您没有权限访问资源!"),
    SYSTEM_ERROR (500,"系统出现异常,请您稍后再试或联系管理员!"),
    OTHER_ERROR(999,"系统出现未知异常,请联系管理员!");

    CustomExceptionType(int code, String desc) 
        this.code = code;
        this.desc = desc;
    

    private String desc;//异常类型中文描述

    private int code; //code

    public String getDesc() 
        return desc;
    

    public int getCode() 
        return code;
    

  • 以笔者的经验,最好不要超过5个,否则开发人员将会记不住,也不愿意去记。对于我来说上面的三种异常类型就足够了。
  • 这里的code表示异常类型的唯一编码,为了方便大家记忆,就使用Http状态码400、500
  • 这里的desc是通用的异常描述,在创建自定义异常的时候,为了给用户更友好的回复,通常异常信息描述应该更具体更友好。

自定义异常

  • 自定义异常有两个核心内容,一个是code。使用CustomExceptionType 来限定范围。
  • 另外一个是message,这个message信息是要最后返回给前端的,所以需要用友好的提示来表达异常发生的原因或内容
public class CustomException extends RuntimeException 
    //异常错误编码
    private int code ;
    //异常信息
    private String message;

    private CustomException()

    public CustomException(CustomExceptionType exceptionTypeEnum) 
        this.code = exceptionTypeEnum.getCode();
        this.message = exceptionTypeEnum.getDesc();
    

    public CustomException(CustomExceptionType exceptionTypeEnum,
                           String message) 
        this.code = exceptionTypeEnum.getCode();
        this.message = message;
    

    public int getCode() 
        return code;
    

    @Override
    public String getMessage() 
        return message;
    


请求接口统一响应数据结构

为了解决不同的开发人员使用不同的结构来响应给前端,导致规范不统一,开发混乱的问题。我们使用如下代码定义统一数据响应结构

  • isok表示该请求是否处理成功(即是否发生异常)。true表示请求处理成功,false表示处理失败。
  • code对响应结果进一步细化,200表示请求成功,400表示用户操作导致的异常,500表示系统异常,999表示其他异常。与CustomExceptionType枚举一致。
  • message:友好的提示信息,或者请求结果提示信息。如果请求成功这个信息通常没什么用,如果请求失败,该信息需要展示给用户。data:通常用于查询数据请求,成功之后将查询数据响应给前端。
/**
 * 接口数据请求统一响应数据结构
 */
@Data
public class AjaxResponse 
  private boolean isok;  //请求是否处理成功
  private int code; //请求响应状态码
  private String message;  //请求结果描述信息
  private Object data; //请求结果数据(通常用于查询操作)

  private AjaxResponse()

  //请求出现异常时的响应数据封装
  public static AjaxResponse error(CustomException e) 
    AjaxResponse resultBean = new AjaxResponse();
    resultBean.setIsok(false);
    resultBean.setCode(e.getCode());
    resultBean.setMessage(e.getMessage());
    return resultBean;
  

  //请求出现异常时的响应数据封装
  public static AjaxResponse error(CustomExceptionType customExceptionType,
                                   String errorMessage) 
    AjaxResponse resultBean = new AjaxResponse();
    resultBean.setIsok(false);
    resultBean.setCode(customExceptionType.getCode());
    resultBean.setMessage(errorMessage);
    return resultBean;
  

  //请求成功的响应,不带查询数据(用于删除、修改、新增接口)
  public static AjaxResponse success()
    AjaxResponse ajaxResponse = new AjaxResponse();
    ajaxResponse.setIsok(true);
    ajaxResponse.setCode(200);
    ajaxResponse.setMessage("请求响应成功!");
    return ajaxResponse;
  

  //请求成功的响应,带有查询数据(用于数据查询接口)
  public static AjaxResponse success(Object obj)
    AjaxResponse ajaxResponse = new AjaxResponse();
    ajaxResponse.setIsok(true);
    ajaxResponse.setCode(200);
    ajaxResponse.setMessage("请求响应成功!");
    ajaxResponse.setData(obj);
    return ajaxResponse;
  

  //请求成功的响应,带有查询数据(用于数据查询接口)
  public static AjaxResponse success(Object obj,String message)
    AjaxResponse ajaxResponse = new AjaxResponse();
    ajaxResponse.setIsok(true);
    ajaxResponse.setCode(200);
    ajaxResponse.setMessage(message);
    ajaxResponse.setData(obj);
    return ajaxResponse;
  



对于不同的场景,提供了四种构建AjaxResponse 的方法。

  • 当请求成功的情况下,可以使用AjaxResponse.success()构建返回结果给前端。
  • 当查询请求等需要返回业务数据,请求成功的情况下,可以使用AjaxResponse.success(data)构建返回结果给前端。携带结果数据。
  • 当请求处理过程中发生异常,需要将异常转换为CustomException ,然后在控制层使用AjaxResponse .error(CustomException)构建返回结果给前端。
  • 在某些情况下,没有任何异常产生,我们判断某些条件也认为请求失败。这种使用AjaxResponse.error(customExceptionType,errorMessage)构建响应结果。

使用示例如下

例如:更新操作,Controller无需返回额外的数据

return AjaxResponse.success();


例如:查询接口,Controller需返回结果数据(data可以是任何类型数据)

 return AjaxResponse.success(data);

通用全局异常处理逻辑

通用异常处理逻辑

程序员的异常处理逻辑要十分的单一:无论在Controller层、Service层还是什么其他位置,程序员只负责一件事:那就是捕获异常,并将异常转换为自定义异常。使用用户友好的信息去填充CustomException的message,并将CustomException抛出去。

@Service
public class ExceptionService 

    //服务层,模拟系统异常
    public void systemBizError() 
        try 
            Class.forName("com.mysql.jdbc.xxxx.Driver");
         catch (ClassNotFoundException e) 
            throw new CustomException(
                    CustomExceptionType.SYSTEM_ERROR,
                    "在XXX业务,myBiz()方法内,出现ClassNotFoundException,请将该信息告知管理员");
        
    

    //服务层,模拟用户输入数据导致的校验异常
    public void userBizError(int input)  
        if(input < 0) //模拟业务校验失败逻辑
            throw new CustomException(
                    CustomExceptionType.USER_INPUT_ERROR,
                    "您输入的数据不符合业务逻辑,请确认后重新输入!");
        

        //…… 其他的业务
    



全局异常处理器

通过团队内的编码规范的要求,我们已经知道了:不允许程序员截留处理Exception,必须把异常转换为自定义异常CustomException全都抛出去。那么程序员把异常跑出去之后由谁来处理?那就是ControllerAdvice。

ControllerAdvice注解的作用就是监听所有的Controller,一旦Controller抛出CustomException,就会在@ExceptionHandler(CustomException.class)注解的方法里面对该异常进行处理。

处理方法很简单就是使用AjaxResponse.error(e)包装为通用的接口数据结构返回给前端。

@ControllerAdvice
public class WebExceptionHandler 

    //处理程序员主动转换的自定义异常
    @ExceptionHandler(CustomException.class)
    @ResponseBody
    public AjaxResponse customerException(CustomException e) 
        if(e.getCode() == CustomExceptionType.SYSTEM_ERROR.getCode())
                 //400异常不需要持久化,将异常信息以友好的方式告知用户就可以
                //TODO 将500异常信息持久化处理,方便运维人员处理
        
        return AjaxResponse.error(e);
    

    //处理程序员在程序中未能捕获(遗漏的)异常
    @ExceptionHandler(Exception.class)
    @ResponseBody
    public AjaxResponse exception(Exception e) 
        //TODO 将异常信息持久化处理,方便运维人员处理

        return AjaxResponse.error(new CustomException(
                CustomExceptionType.OTHER_ERROR));
    




业务状态与HTTP协议状态一致

不知道大家有没有注意到一个问题(看上图)?这个问题就是我们的AjaxResponse的code是400,但是真正的HTTP协议状态码是200?
通说的说,目前

  • AjaxResponse的code是400代表的是业务状态,也就是说用户的请求业务失败了
  • 但是HTTP请求是成功的,也就是说数据是正常返回的。

在很多的公司开发RESTful服务时,要求HTTP状态码能够体现业务的最终执行状态,所以说:我们有必要让业务状态与HTTP协议Response状态码一致。

@Component
@ControllerAdvice
public class GlobalResponseAdvice implements ResponseBodyAdvice 
    @Override
    public boolean supports(MethodParameter returnType, Class converterType) 
        //return returnType.hasMethodAnnotation(ResponseBody.class);
        return true;
    
    @Override
    public Object beforeBodyWrite(Object body,
                                  MethodParameter returnType,
                                  MediaType selectedContentType,
                                  Class selectedConverterType,
                                  ServerHttpRequest request,
                                  ServerHttpResponse response) 

        //如果响应结果是JSON数据类型
        if(selectedContentType.equalsTypeAndSubtype(
                MediaType.APPLICATION_JSON))
                //为HTTP响应结果设置状态码,状态码就是AjaxResponse的code,二者达到统一
                response.setStatusCode(
                        HttpStatus.valueOf(((AjaxResponse) body).getCode())
                );
                return body;
        
        return body;
    

  • 实现ResponseBodyAdvice 接口的作用是:在将数据返回给用户之前,做最后一步的处理。也就是说,ResponseBodyAdvice 的处理过程在全局异常处理的后面。


进一步优化

我们已经知道了,ResponseBodyAdvice 接口的作用是:在将数据返回给用户之前,做最后一步的处理。将上文的GlobalResponseAdvice 中beforeBodyWrite方法代码优化如下。

  • 如果Controller或全局异常处理响应的结果body是AjaxResponse,就直接return给前端。
  • 如果Controller或全局异常处理响应的结果body不是AjaxResponse,就将body封装为AjaxResponse之后再return给前端。

所以,我们之前的代码是这样写的,比如:某个controller方法返回值

return AjaxResponse.success(objList);

现在就可以这样写了,因为在GlobalResponseAdvice 里面会统一再封装为AjaxResponse。

return objList;

最终代码如下:

@Component
@ControllerAdvice
public class GlobalResponseAdvice implements ResponseBodyAdvice 

  @Override
  public boolean supports(MethodParameter methodParameter, Class aClass) 
    return true;
  

  @Override
  public Object beforeBodyWrite(Object body,
                                MethodParameter methodParameter,
                                MediaType mediaType,
                                Class aClass,
                                ServerHttpRequest serverHttpRequest,
                                ServerHttpResponse serverHttpResponse) 
    //如果响应结果是JSON数据类型
    if(mediaType.equalsTypeAndSubtype(
            MediaType.APPLICATION_JSON))
      if(body instanceof AjaxResponse)
        AjaxResponse ajaxResponse = (AjaxResponse)body;
        if(ajaxResponse.getCode() != 999) //999 不是标准的HTTP状态码,特殊处理
          serverHttpResponse.setStatusCode(HttpStatus.valueOf(
                  ajaxResponse.getCode()
          ));
        
        
        return body;
      else
        serverHttpResponse.setStatusCode(HttpStatus.OK);
        return AjaxResponse.success(body);
      

    


    return body;
  


服务端数据校验异常处理逻辑

异常校验的规范及常用注解

在web开发时,对于请求参数,一般上都需要进行参数合法性校验的,原先的写法时一个个字段一个个去判断,这种方式太不通用了,所以java的JSR 303: Bean Validation规范就是解决这个问题的。

JSR 303只是个规范,并没有具体的实现,目前通常都是才有hibernate-validator进行统一参数校验。

JSR303定义的校验类


Hibernate Validator 附加的 constraint


用法:把以上注解加在ArticleVO的属性字段上,然后在参数校验的方法上加@Valid注解 如:



当用户输入参数不符合注解给出的校验规则的时候,会抛出BindException或MethodArgumentNotValidException。

参考


Assert断言与IllegalArgumentException

之前给大家讲通用异常处理的时候,用户输入异常判断是这样处理的。这种方法也是可以用的,但是我们学了这么多的知识,可以优化一下

//服务层,模拟用户输入数据导致的校验异常
public void userBizError(int input)  
    if(input < 0) //模拟业务校验失败逻辑
        throw new CustomException(
                CustomExceptionType.USER_INPUT_ERROR,
                "您输入的数据不符合业务逻辑,请确认后重新输入!");
    
    
    //…… 其他的业务

更好的写法是下面这样的,使用org.springframework.util.Assert断言input >= 0,如果不满足条件就抛出IllegalArgumentException,参数不合法的异常。

//服务层,模拟用户输入数据导致的校验异常
public void userBizError(int input)  
    Assert.isTrue重学springboot系列之JSON处理工具类

重学SpringBoot系列之日志框架与全局日志管理

Spring Boot2 系列教程 (十四) | 统一异常处理

重学Springboot系列之整合数据库开发框架---下

Spring Boot2 系列教程(十三)Spring Boot 中的全局异常处理

重学SpringBoot系列之生命周期内的拦截过滤与监听