在多人协同开发时, 经常会有一个问题 : 同一个错误信息, 不同的人因为代码风格不同, 返回的格式是不确定不一样的, 而我们是不会直接跟用户进行交互, 需要前端通过我们传来的错误信息进一步包装展示给用户. 因此, 每个人同一个问题返回不一样的信息表示, 那么前端就很难处理了.
既然是同一个问题, 那么我们就可以返回一个统一的数据格式, 让前端可以更方便的进行处理. 比如 : 对于一个异常的空指针异常, 张三给前端传错误状态码 -1, 李四给前端传错误状态码 -2. 那么前端对于这两种情况一样的时候还需要多做一次判断是非常麻烦的. 因此我们就统一规定这时候的空指针异常返回一个错误状态码 -1 表示.
那么, 统一的数据返回格式有什么作用呢 ?
- 首先就是我们刚刚说的, 方便前端程序员更好地接收和解析后端数据接口返回的数据
- 降低前后端沟通成本, 按照统一的方式处理.
- 利于项目统一数据的维护和修改
- 防止错误的返回信息格式
一. 统一数据格式的实现
在 Spring MVC 中提供了一个全局的异常处理 ( 不符合我们的返回格式也算作异常处理 ) 的注解 @ControllerAdvice, 用于拦截 Spring MVC 控制器抛出的异常. 来看看如何利用该注解实现统一格式返回
1. 约定返回对象
在每个接口功能实现之前, 前后端都应该去约定返回一个什么样的格式, 是返回一个 JSON 对象, 还是一个字符串, 还是其他类型. 约定好后, 大家就按照共同的格式进行返回, 降低沟通的成本, 提高处理的效率.
这里, 我就约定登陆功能这个接口, 有三个字段, 一个是状态码, 默认返回 200, 无论后端是否发生错误, 都应该正确把信息返回给前端, 状态码非 200 情况下, 前端拿到的就是一个错误信息, 它是没法处理的. 因此我们就需要让它不进行错误页面的展示, 而看我们通过状态码 200 发给他的 msg 错误信息来判断是什么问题, 并且这个错误信息一般都是用于前后端开发阶段, 不会展示给用户的. 具体的如何实现, 下面的统一异常处理会对它进行实现.
前端它只认我们返回的这三个字段, 其它的他是不会处理的.
@Data @Component public class ResultAjax { private int code; private Object mes; private Object date; }
2. 创建统一的数据返回类
创建一个类来实现我们的统一数据返回, 并且该类一定需要加上 @ControllerAdvice, 申明它是一个控制器通知类. ( 检查是否异常, 异常后执行该处理 )
@ControllerAdvice public class ResponseAdvice { }
3. 实现 ResponseBodyAdvice 接口
ResponseBodyAdvice 接口, 即返回数据格式通知. 该接口中有两个方法需要重写.
- supports 方法
判断传来的数据是否需要执行 BeforeBodyWrite 方法
- beforeBodyWrite 方法
按照自定义的格式重写该方法后, 传来的数据格式不符合要求被拦截后都会进行自定义的重写
@ControllerAdvice public class ResponseAdvice implements ResponseBodyAdvice { /** * 判断内容是否需要重写 * 返回 true 表示重写 */ @Override public boolean supports(MethodParameter returnType, Class converterType) { // return false; // 默认不会使用同一返回 return true; } @Autowired private ResultAjax resultAjax; /** * 执行重写方法, 同一数据的格式. * * @param body 传来的没有处理的原始数据 * TODO : 作为一个保底策略, 防止其他人忘记返回统一的数据格式给前端 */ @Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { // 判断格式是否符合预定要求 if (body instanceof ResultAjax) { return body; } // 不符合预期的格式, 强制进行自定义的格式返回 resultAjax.setCode(200); // 默认规定状态码 200 resultAjax.setDate(body); resultAjax.setMes(""); // 数据成功后, 是不会关注该信息的, 返回空 return resultAjax; } }
4. 数据验证
@RestController public class UserController { @RequestMapping("/user/login1") public int login1() { return 1; } @RequestMapping("/user/login2") public Object login2() { ResultAjax resultAjax = new ResultAjax(); resultAjax.setCode(200); resultAjax.setDate("1"); resultAjax.setMes(""); return resultAjax; } }
- 对于 login1 方法, 返回的是一个 int 类型的, 很明显是不符合我们预期的格式的, 看看能否对它进行转为自定义的格式
对于这种情况, 当我们设定了保底的返回格式时, 当我们返回的数据格式出错了, 它一样会帮我们按照自定义的 ResultAjax 格式进行返回给前端. 如果直接返回 1 给了前端, 前端是不知道如何处理的. 这就是保底策略, 在数据返回失误时, 帮我们进行兜底.
- 对于 login2 方法, 返回的是一个 ResultAjax 对象, 是符合我们的要求的
如果本身符合格式需求, 直接返回响应的数据就行. 不会执行我们的自定义格式处理
5. 特殊 String 类型处理
为什么说 String 类型在这是特殊的呢 ? 我们执行这个方法, 它会不会正确的将它按照预期的 ResultAjax 格式返回呢 ?
@RequestMapping("user/login3") public String login3() { return "登陆成功"; }
可以看到, 这次我们的保底策略居然失效了 ! 它不仅没有给我们正确的转成 ResultAjax 格式, 还报错格式异常转换错误. 这是为什么 ?
控制台错误信息 : ResultAjax 不能转换为 String 类型
看到这儿, 应该就很懵了. 我们不是该将 String 类型转为 ResultAjax 类型嘛 ? 怎么现在还报错是 ResultAjax 无法转为 String 类型了 ?
对于这个问题, 从自定义的返回类型的执行流程一步一步来看
- 首先肯定是执行路由方法 login3 返回一个 String 类型的数据
- 进行统一的数据转换并返回 -> String Convert ResultAjax
- 将 ResultAjax 转为 application/json 字符串传给全段
这一步有点特殊, 通过每次成功后的结果我们可以看到, 它最终展现出来的格式就是 application/json 字符串类型
而通过我们刚刚控制台的异常错误信息来看, 问题出在第三步. 它无法将 ResultAjax 转为字符串
对于第三步, 它在执行之前会先对原 body 的数据类型进行判断
- 如果当前 body 不是 String 类型, HttpMessageConverter 转换器进行类型转换
- 如果当前 body 是 String 类型, 它会使用 StringHttpMessageConverter 转换器进行类型转换
我们的问题就出在这个是 String 类型时的转换器. ( 其他类型的修饰器都可以, Java 中唯独这个有点问题, 也许是官方设计有问题 )
既然问题我们找到了, 那么如何解决这个问题就很容易了
5.1 对于 String 类型单独处理
既然对于 String 类型它的转换器有问题, 那我们避免使用它, 不让他进行转换单独处理返回就可以了
/** * 判断内容是否需要重写 * 返回 true 表示重写 */ @Override public boolean supports(MethodParameter returnType, Class converterType) { // return false; // 默认不会使用同一返回 return true; } @Autowired private ResultAjax resultAjax; @Autowired private ObjectMapper objectMapper; /** * 执行重写方法, 同一数据的格式. * * @param body 传来的没有处理的原始数据 * TODO : 作为一个保底策略, 防止其他人忘记返回统一的数据格式给前端 */ @SneakyThrows @Override public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) { // 判读数据格式是否符合预定要求 if (body instanceof ResultAjax) { return body; } // 不符合预期的格式, 强制进行自定义的格式返回 resultAjax.setCode(200); resultAjax.setDate(body); resultAjax.setMes(""); if(body instanceof String) { // 将 ResultAJax 转为 JSON 字符串 return objectMapper.writeValueAsString(resultAjax); } return resultAjax; }
5.2 直接剔除该转换方法
除了单独处理, 更方便的是在配置文件中直接剔除 String 类型是时使用的 StringHttpMessageConverter 处理器
@Configuration // 配置注解 public class MyConfig implements WebMvcConfigurer { // 随着 Spring Web 加载生效, 实现 WebMvcConfigurer 接口 @Override public void configureMessageConverters(List<HttpMessageConverter<?>> converters) { converters.removeIf(converter -> converter instanceof StringHttpMessageConverter); } }
注释掉之前的单独处理 String 的代码, 重新访问 login3 方法, 现在它就可以正常转换了.
二. 统一异常处理
在程序运行时, 难免会产生一些异常错误, 当后端发生异常时, 前端将无法正确获取到数据. 因此对要求无论是否异常, 后端都应该给前端返回约定格式的数据, 这时候统一的异常处理就非常有必要了比如, 下面这个方法 : 登陆时出现了空指针异常, 那么程序去运行时, 肯定是会发生报错并且前端是无法拿到数据的.
@RequestMapping("user/login4") public int login4() { Object obj = null; obj.hashCode(); return 1; }
它直接报错 500, 但是前端是无法去处理的, 前端处理的即使是异常情况下, 也是约定好的返回对象 ResultAjax 里面的三个字段, 但是现在报错了, 这三个字段没有传给前端, 它无法将这个情况处理展示给用户.
那么, 无论是否异常, 我们都需要将数据按照约定格式正确传给前端, 在 Spring MVC 中提供了 @ControllerAdvice 注解 ( 控制器通知类 )和 @ExceptionHandler 注解 ( 异常处理器) 来解决异常的返回信息.
1. 创建异常处理类
该类表示加上 @ControllerAdvice 注解和 @ExceptionHandler 注解后, 当出现异常后, 执行某个具体的方法事件.
@ControllerAdvice // 控制器通知, 随着 Spring 框架的启动而启动 @ResponseBody // 返回 JSON 数据 public class MyExceptionAdvice { /** * @ExceptionHandler (异常处理器): 针对那个异常进行处理 * 该注解默认返回的是静态页面 */ @ExceptionHandler(NullPointerException.class) public ResultAjax doNullPointerException(NullPointerException e) { // 自定义的异常处理 } }
2. 实现自定义的异常处理
@ControllerAdvice // 控制器通知, 随着 Spring 框架的启动而启动 @ResponseBody // 返回 JSON 数据 public class MyExceptionAdvice { @Autowired private ResultAjax resultAjax; /** * @ExceptionHandler (异常处理器): 针对那个异常进行处理 * 该注解默认返回的是静态页面 */ @ExceptionHandler(NullPointerException.class) public ResultAjax doNullPointerException(NullPointerException e) { // 自定义的异常处理, 返回统一的数据格式 resultAjax.setCode(200); resultAjax.setDate(null); resultAjax.setMes(e); return resultAjax; } }
3. 验证异常处理
实现这个异常的处理类后, 当我们再次访问时, 这时候状态码就变成了 200, 即使异常情况下也会有响应的数据返回给前端, 前端在根据里面设置的错误信息从而进行处理.
返回的我们约定的三个字段, code mes 和 data.
4. 实现异常全部监测
实际上在生产中, 错误异常出现很多都是意想不到的, 不仅仅是空指针这样的常见异常. 比如算数异常, 栈溢出等等很多
@RequestMapping("/user/login5") public int login5() { int a = 2 / 0; System.out.println(a); return 1; }
对于 login5 方法它很明显是一个错误的算数异常, 我们有了刚刚的异常处理, 它能正确的返回数据给前端嘛 ? 答案还不能的, 执行后发现它仍然是 500, 意味着我们的异常处理并未生效.
异常处理未生效相信细心的已经看出来了, @ExceptionHandler 注解要针对某个异常进行拦截处理, 在上面的代码中并没有进行算数异常的处理. 那么我们对算数异常进行单独处理就能解决了
@ExceptionHandler(ArithmeticException.class) public ResultAjax doArithmeticException(ArithmeticException e) { resultAjax.setCode(200); resultAjax.setDate(null); resultAjax.setMes(e); return resultAjax; }
添加后就可以正常的拦截处理并返回约定格式给前端进行处理了
但是, 在生产中往往意外是很难预料的, 说不清在那个点就发生了意想不到的异常, 那我们要针对每一个异常都写一个单独的处理方法吗 ? 这是非常麻烦的
因此我们采取对所有的异常都进行拦截, 但对某些有要求的异常进行特殊处理
通过 @ExceptionHandler 注解针对某个异常可以发现, 如果我们写的是所有异常的父类 Exception 异常类, 把这个类处理了, 那是不是意味着所有的异常都可以拦截了.
// 拦截所有异常 @ExceptionHandler(Exception.class) public ResultAjax doException(Exception e) { resultAjax.setCode(200); resultAjax.setDate(null); resultAjax.setMes(e); return resultAjax; }
实现一个常见的数组下标越界异常 :
@RequestMapping("/user/login6") public int login6() { int[] array = new int[3]; for(int i = 0; i < 4; i++) { array[i] = i; } return 1; }
此时我们是没有对下标越界的 ArrayIndexOutOfBoundsException 进行单独处理的, 预期是无法正确返回数据给前端的
惊奇的发现, 即使这样它依然是可以正确返回的, 这都归功于我们的全部异常拦截
5. 异常处理特点
对于拦截全部后, 有一个非常有意思的事, Exception 是所有异常的父类啊, 哪我现在出现了空指针异常, 该执行 Exception 拦截处理还是 NullPointerException 拦截处理呢 ?
为了对比清楚, 将 date 设置成了不同字符串
@ExceptionHandler(NullPointerException.class) public ResultAjax doNullPointerException(NullPointerException e) { resultAjax.setCode(200); resultAjax.setDate("空指针异常"); resultAjax.setMes(e); return resultAjax; } // 拦截所有异常 @ExceptionHandler(Exception.class) public ResultAjax doException(Exception e) { resultAjax.setCode(200); resultAjax.setDate("父类异常拦截"); resultAjax.setMes(e); return resultAjax; }
执行后发现, 当父类子类异常同时存在时, 优先子类自己的异常处理.