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

x33g5p2x  于2021-11-28 转载在 Spring  
字(14.0k)|赞(0)|评价(0)|浏览(740)

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

异常处理的乱象例举

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

前端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(input >= 0,"您输入的数据不符合业务逻辑,请确认后重新输入!");

    //…… 其他的业务
}

org.springframework.util.Assert断言提供了大量的断言方法,针对各种数据类型进行数据合法性校验,使用它我们编写代码更方便。

友好的数据校验异常处理(用户输入异常的全局处理)

我们已知当数据校验失败的时候,会抛出异常BindException或MethodArgumentNotValidException。所以我们对这两种异常做全局处理,防止程序员重复编码带来困扰。

@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseBody
public AjaxResponse handleBindException(MethodArgumentNotValidException ex) {
  FieldError fieldError = ex.getBindingResult().getFieldError();
  return AjaxResponse.error(new CustomException(CustomExceptionType.USER_INPUT_ERROR,
          fieldError.getDefaultMessage()));
}

@ExceptionHandler(BindException.class)
@ResponseBody
public AjaxResponse handleBindException(BindException ex) {
  FieldError fieldError = ex.getBindingResult().getFieldError();
  return AjaxResponse.error(new CustomException(CustomExceptionType.USER_INPUT_ERROR,
          fieldError.getDefaultMessage()));
}

我们已知使用org.springframework.util.Assert断言,如果不满足条件就抛出IllegalArgumentException。可以使用下面的全局异常处理函数

@ExceptionHandler(IllegalArgumentException.class)
@ResponseBody
public AjaxResponse handleIllegalArgumentException(IllegalArgumentException e) {
  return AjaxResponse.error(
          new CustomException(CustomExceptionType.USER_INPUT_ERROR,
                  e.getMessage())
  );
}

AOP完美处理页面跳转异常

页面跳转异常处理

之前章节给大家讲的都是JSON接口类的异常处理,那假如我们做页面模板开发时(非前后端分离的应用),Controller发生异常我们该怎么办?应该统一跳转到error.html页面,并且不能影响JSON数据接口的全局统一异常处理。

  • 面临的问题:

程序员抛出自定义异常CustomException(职责单一),全局异常处理截获之后返回@ResponseBody AjaxResponse,不是ModelAndView,所以我们无法跳转到error.html页面,那我们该如何做页面跳转error.html方式的全局的异常处理?

以下是我给出答案:

  • 用面向切面的方式,将Exception转换为ModelAndViewException。
  • 全局异常处理器拦截ModelAndViewException,返回ModelAndView,即error.html页面
  • 切入点是带@ModelView注解的Controller层方法

使用这种方法处理页面类异常,程序员只需要涉及到页面跳转的Controller方法上加@ModelView注解即可。 当该方法抛出异常的时候就会自动跳转到error页面。

错误的写法

@GetMapping("/freemarker")
public String index(Model model) {
    try{
        List<ArticleVO> articles = articleRestService.getAll();
        model.addAttribute("articles", articles);
    }catch (Exception e){
        return "error";
    }
    return "fremarkertemp";
}

正确的写法

@ModelView
@GetMapping("/freemarker")
public String index(Model model) {
    List<ArticleVO> articles = articleRestService.getAll();
    model.addAttribute("articles", articles);
    return "fremarkertemp";
}

用面向切面的方法处理页面全局异常

因为用到了面向切面编程,所以引入maven依赖包

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

ModelView 注解,只起到标注的作用

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})//只能在方法上使用此注解
public @interface ModelView {

}

以@ModelView注解为切入点,面向切面编程,将所有捕获到的Exception转换为ModelViewException抛出。

@Aspect
@Component
@Slf4j
public class ModelViewAspect {
    
    //设置切入点:这里直接拦截被@ModelView注解的方法
    @Pointcut("@annotation(com.dhy.boot.launch.exception.ModelView)")
    public void pointcut() { }
    
    /** * 当有ModelView的注解的方法抛出异常的时候,做如下的处理 */
    @AfterThrowing(pointcut="pointcut()",throwing="e")
    public void afterThrowable(Throwable e) {
        throw ModelViewException.transfer(e);
    }
}

新定义一个异常类ModelViewException,将捕获到的异常Exception转化为ModelViewException

public class ModelViewException extends RuntimeException{

    //将Exception 转换为ModelViewException
    public static ModelViewException transfer(Throwable cause) {
        return new ModelViewException(cause);
    }

    private ModelViewException(Throwable cause) {
        super(cause);
    }
}

全局异常处理器处理ModelViewException,将异常页面定位到error.html:

@ExceptionHandler(ModelViewException.class)
    public ModelAndView viewExceptionHandler(HttpServletRequest req, ModelViewException e) {
        ModelAndView modelAndView = new ModelAndView();

        //将异常信息设置如modelAndView
        modelAndView.addObject("exception", e);
        modelAndView.addObject("url", req.getRequestURL());
        modelAndView.setViewName("error");

        //返回ModelAndView
        return modelAndView;
    }

访问测试

写一个error页面,因为我使用了freemarker模板,所以是errot.ftl(如果没有模板引擎,就error.html就可以)。

<!DOCTYPE html>
<html>
<head lang="en">
    <meta charset="UTF-8" />
    <title>error.html</title>
</head>
<body>
<h1>exception.toString()</h1>
<div>${exception.toString()}</div>

<h1>exception.message</h1>
<div>${exception.message}</div>

<h1>url</h1>
<div>${url}</div>

</body>
</html>

随便找一个页面跳转的controller方法,我访问的是之前开发的 http://localhost:8888/template/freemarker 进行测试,访问之前人为的制造一个异常。重要的是不要忘了加@ModelView注解

访问结果如下,跳转到error.html页面(我的error页面做的比较简陋,大家可以自定义样式):

相关文章