SpringBoot!你的请求、响应、异常规范了吗?(文末红包)

  |   0 评论   |   0 浏览

image-20201207014448887 如遇图片加载失败,可尝试使用手机流量访问

长文警告!!!

前言

这段时间在调整老系统相关的一些业务代码;发现一些模块,在无形中就被弄的有点乱了,由于每个开发人员技术水平不同、编码习惯差异;从而导致在请求、响应、异常这一块儿,出现了一些比较别扭的代码;但是归根究底,主要问题还是出在规范上面;不管是大到项目还是小到功能模块,对于请求、响应、异常这一块儿,应该是一块儿公共的模板化的代码,一旦定义清楚之后,是不需要做任何改动,而且业务开发过程中,也几乎是不需要动到他丝毫;所以,一个好的规范下,是不应该在这部分代码上出现混乱或者别扭的情况的;忍不住又得来整理一下这一块儿的东西;

作为一个后台的工程师,接受请求、处理业务、解决异常、响应数据,几乎覆盖了日常开发的全部;但是这个中间,除了业务代码是不可避免且无可替代之外;其他的三项操作,不管是啥功能,也都是大同小异的,那我们要如何来把这一块儿的东西抽离出来,让我们只需要去管业务,不用去管那些杂七杂八的的破事儿,从而腾出更多的时间学(mo)习(yu)呢?当然就是得去定义一个好的规则,运用优秀的轮子;让这部分重复的、可复用的工作给模板化、标准化

这样,开发一遍,后面就不需要再去弄这些通用的东西了。

思考一下,关于请求、响应、异常,我们到底要注意些啥问题呢?

问题点

请求
  1. 如何优雅的接受数据?
  2. 如何优雅的校验数据?
响应
  1. 响应数据格式如何统一
  2. 错误码如何规范
  3. 如何将业务功能和响应给剥离开来?
异常
  1. 异常如何捕获
  2. 业务异常、校验异常如何合理的转换为友好的标准响应
  3. 如何规避未捕获到的异常并优雅返回标准响应?

这一些列的问题,就衍生出,我们该如何去规范的问题?任何利用已有的优秀框架去解决这些问题?

接下来,就通过一个完整的示例,基于这三个大点下面的小问题,去把这个规范给讲清楚;

示例源码地址: https://github.com/183619962/springcloud-mbb/tree/main/springboot-valid

讲每个大的问题点之前,我会给大家一个或几个疑问;然后可以带着这些疑问,边思考边看。

下面的介绍,我们就以一个简单的用户信息(UserInfo)的CURD展开

hibernate-validator优雅的处理请求

疑问
  1. 我们要如何去校验请求的数据?
  2. 相同的对象去接受不同请求数据,如何能区别校验?

    主要的目的是为了减少一些非必要的DTO对象

如果我们要去做用户的添加和修改,我们会如何去写请求参数的接受?

@RestController
@RequestMapping("user")
public class UserController{

    @PostMapping("add")
    public String add(@RequestBody UserAddRequestDto addInfo){
        // ......
        return "ok";
    }

    @PutMapping("update")
    public void update(@RequestBody UserUpdateRequestDto updateInfo)
    {
        // ......
        return "ok";
    }
}

这样?嗯!这样确实可以接受到请求参数,但是我们回归到上面的疑问;

参数如何校验?难道这样?

if(null==addInfo.getUserName()){
    throw new Exceprion();
}
if(null==addInfo.getPassWord()){
    throw new Exceprion();
}
// 。。。。

固然可以,这样真的好吗?很明显不好。。。。劳力伤神的事儿,咱可不干。

addInfo和updateInfo大部分属性都是一样的,添加的字段,大部分都是可以进行修改的,但是也有部分是不可以修改的;比如密码,一般都是单独写接口进行修改;

既然大部分都一样;有必要定义这么多个请求的DTO对象吗?有必要!!没办法啊!大部分一样,他也有不一样的地方!

那有没有能优雅的去解决参数校验问题,又可以将请求对象合多为一呢?

hibernate-validator就是一个可以完美的解决这些问题的优秀框架;

接下来,我们就详细的来看一下,如何使用这个工具。

hibernate-validator
优点
  • 解耦,数据的校验与业务逻辑进行分离,降低耦合度

    到controller的对象就已经是校验过的对象了,接受到之后就只需要安心处理业务就好,不用再进行数据校验相关逻辑

  • 规范的校验方式,减少参数校验所带来的繁琐体力活

    以注解的方式配置校验规则;大大减少校验的工作量,而且复用性强

  • 简洁代码,提高代码的可读性

    以注解方式即可完成属性校验,去掉了各种冗长的校验代码;且所有的校验规则都定义在对象内部;使得代码结构更加清晰,可读性非常强。

注解说明

下面包含了validator的所有内置的注解

| 注解 | 作用 |
| - | - |
| @AssertFalse | 被注释的元素必须为 false |
| @AssertTrue | 被注释的元素必须为 true |
| @DecimalMin(value) | 被注释的元素必须是一个数字,其值必须大于等于指定的最小值 |
| @DecimalMax(value) | 被注释的元素必须是一个数字,其值必须小于等于指定的最大值 |
| @Digits (integer, fraction) | 被注释的元素必须是一个数字,其值必须在可接受的范围内 |
| @Email | 被注释的元素必须是电子邮箱地址 |
| @Future | 被注释的元素必须是一个将来的日期 |
| @Length(min=,max=) | 被注释的字符串的大小必须在指定的范围内 |
| @Min(value) | 被注释的元素必须是一个数字,其值必须大于等于指定的最小值 |
| @Max(value) | 被注释的元素必须是一个数字,其值必须小于等于指定的最大值 |
| @Negative | 该值必须小于0 |
| @NegativeOrZero | 该值必须小于等于0 |
| @Null | 被注释的元素必须为 null |
| @NotNull | 被注释的元素必须不为 null |
| @NotBlank(message =) | 验证字符串非null,且长度必须大于0 |
| @NotEmpty | 被注释的字符串的必须非空 |
| @Past | 被注释的元素必须是一个过去的日期 |
| @Pattern(regex=,flag=) | 被注释的元素必须符合指定的正则表达式 |
| @Positive | 该值必须大于0 |
| @PositiveOrZero | 该值必须大于等于0 |
| @Range(min=,max=,message=) | 被注释的元素必须在合适的范围内 |
| @Size(max=, min=) | 数组大小必须在[min,max]这个区间 |
| @URL(protocol=,host,port) | 检查是否是一个有效的URL,如果提供了protocol,host等,则该URL还需满足提供的条件 |
| @Valid | 该注解主要用于字段为一个包含其他对象的集合或map或数组的字段,或该字段直接为一个其他对象的引用,这样在检查当前对象的同时也会检查该字段所引用的对象 |

如何简单使用?
  • 第一步;引入依赖

    <dependency>
       <groupId>org.hibernate.validator</groupId>
       <artifactId>hibernate-validator</artifactId>
    </dependency>
    
  • 第二步;属性添加对应的注解

    按照上面表格的说明,根据自己定义 属性的特点,添加相应的注解

    如下示例,用户名,密码,年龄不能为空;那我们就用@NotBlank @NotNull去修饰,如果违背规则,就会按message的文本提示

    年龄不能小于0岁、大于120岁;那么就用@min @max进行约束

    message描述了违背校验规则之后的描述。

    @Data
    public class UserRequestDto {
        /**
         * 用户名
         */
        @NotBlank(message = "姓名不能为空")
        public String userName;
    
        /**
         * 密码
         */
        @NotBlank(message = "密码不能为空")
        public String passWord;
    
        /**
         * 年龄
       */
        @NotNull(message = "年龄不能为空")
        @Min(value = 0,message = "年龄不能小于0岁")
        @Max(value = 120,message = "年龄不能大于120岁")
        private Integer age;
        
        /**
         * 手机号码;使用正则进行匹配
         */
        @NotBlank(message = "手机号码不能为空")
        @Pattern(regexp = "^((13[0-9])|(14[5,7])|(15[0-3,5-9])|(17[0,3,5-8])|(18[0-9])|166|198|199|(147))\\d{8}$", message = "号码格式不正确!")
        private String phoneNum;
    
        // 。。。。
    }
    
  • 第三步,Controller的参数加上@Validated

    @PostMapping("add")
    public BaseResponceDto add(@Validated @RequestBody UserRequestDto userRequestDto) {
        // 。。。。
    }
    
  • 第四步,测试

    加上validate之后,再次请求的时候,就会出现以下的错误

    由于太长,只截取了部分;可以看出,在接受到请求,处理业务之前,就已经报错了,并提示了对应的message信息;

    前端也收到了400的错误码

    Resolved [org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public com.lupf.springbootvalid.dto.UserResponceDto ..... default message [userName]]; default message [姓名不能为空]] ..... ]
    

    image-20201201231837804 如遇图片加载失败,可尝试使用手机流量访问

  • 第五步,异常处理
    上面的操作可以看出,当请求参数如果不符合条件的话,就已经抛出异常并响应客户端了;
    但是异常并没有针对性的处理,也没有进行友好的提示;前端收到错误之后,没办法根据错误信息准确的判断出是什么问题;因此对于的异常还需要进行特殊处理;具体的处理方式,会在后续讲解异常的时候说到,这里暂时不展开,可以继续往后看。

上面我们已经将请求的参数以一种比较优雅的方式给验证了;但是并没有将请求对象合并,依然还是使用的addInfo和updateInfo对参数进行接受的;下面就一起来看一下,如何将这边同质化的对象进行优雅的合并。

请求对象的合并
  • group说明
    上面的业务场景中添加和修改用户信息,添加的时候,密码字段是必传的;修改的时候,密码是不需要传的;那我们能否把添加和修改所有用到的属性定义到一个对象中,然后根据不同的请求,去校验参数,比如,调用添加接口,密码是必传的;调用修改接口,就不需要传密码;为了能做到接口区分校验,就可以用到group这个关键参数;
  • group的理解
    可以简单的理解就是把各个属性进行分组;校验的时候,会根据当前Controller指定的组进行校验,这些组里面包含了那些属性,就只校验那些属性,其他不在范围内的,就直接给忽略调掉。
  • group定义
    group的定义是以接口为基本单元;也就是一个接口代表一个组;
  • 使用示例
    • 定义基础的、修改、添加的接口(group)

      // 基础的校验接口,标识着所有操作都需要校验的字段
      public interface UserRequestDtoSimpleValidate {};
      
      // 修改的校验;继承自UserRequestDtoSimpleValidate 
      // 也就是说指定为这个组的时候在满足当前校验规则的同时还得校验simple接口的属性
      public interface UserRequestDtoUpdateValidate extends UserRequestDtoSimpleValidate {}
      
      // 原理同上
      public interface UserRequestDtoAddValidate extends UserRequestDtoUpdateValidate {}
      

      image-20201201233848289 如遇图片加载失败,可尝试使用手机流量访问

    • 属性校验添加上分组配置

      /**
       * 用户名
       */
      @NotBlank(message = "姓名不能为空",groups = UserRequestDtoSimpleValidate.class)
      public String userName;
      
      /**
       * 密码
       */
      @NotBlank(message = "密码不能为空",groups = UserRequestDtoAddValidate.class)
      public String passWord;
      
      /**
       * 年龄
       */
      @NotNull(message = "年龄不能为空",groups = UserRequestDtoSimpleValidate.class)
      @Min(value = 0,message = "年龄不能小于0岁",groups = UserRequestDtoSimpleValidate.class)
      @Max(value = 120,message = "年龄不能大于120岁",groups = UserRequestDtoSimpleValidate.class)
      private Integer age;
      
      /**
       * 手机号码;使用正则进行匹配
       */
      @NotBlank(message = "手机号码不能为空",groups = UserRequestDtoAddValidate.class)
      @Pattern(regexp = "^((13[0-9])|(14[5,7])|(15[0-3,5-9])|(17[0,3,5-8])|(18[0-9])|166|198|199|(147))\\d{8}$", message = "号码格式不正确!",groups = UserRequestDtoAddValidate.class)
      private String phoneNum;
      
      // 。。。。
      
    • Controller指定分组进行校验

      如下@Validated中,指定分组接口类;可以一个,也可以多个,这样就会按照指定的分组进行参数校验

      @PostMapping("add")
      public UserResponceDto add(@Validated(UserRequestDto.UserRequestDtoAddValidate.class) @RequestBody UserRequestDto userRequestDto) {
          // 后续业务
      }
      
      @PutMapping("update")
      public void update(@Validated(UserRequestDto.UserRequestDtoUpdateValidate.class) @RequestBody UserRequestDto userRequestDto) throws BaseException {
          // 后续业务
      }
      
自定义校验

上面的所有校验,全部使用的是内置的注解,实际的使用过程中,不可避免的有一些特殊的业务场景,参数规则太过于个性化,内置的注解无法满足我们的需求时,要怎么办?比如说,文本必须全部是大写或者小写(该需求其实也可以通过正则表达式的方式进行);为了剧情需要,那我们可以基于这个需求,来自定义一个校验器;

  • 定义大小写的枚举

    用于注解使用的时候,来指定是校验规则是大写的还是小写的

    public enum CaseMode {
        //大写
        UPPER,
        //小写
        LOWER;
    }
    
  • 定义校验大小写的注解

    @Documented
    @Target({ElementType.METHOD, ElementType.FIELD, ElementType.ANNOTATION_TYPE, ElementType.CONSTRUCTOR, ElementType.PARAMETER, ElementType.TYPE_USE})
    @Retention(RetentionPolicy.RUNTIME)
    //指定校验器
    @Constraint(validatedBy = CaseCheckValidator.class)
    public @interface CaseCheck {
        String message() default "";
    
        Class<?>[] groups() default {};
    
        Class<? extends Payload>[] payload() default {};
    
        CaseMode value() default CaseMode.UPPER;
    }
    

    这里面,我们将CaseMode枚举作为了注解中的value参数,可以根据需要动态设置大小写的参数,这里默认就是大写的;
    @Constraint(validatedBy = CaseCheckValidator.class) 指明的使用CaseCheckValidator这个校验器进行数据校验;具体的校验规则,判断逻辑,就是写在这个校验器里面。

  • 自定义校验器

    public class CaseCheckValidator implements ConstraintValidator<CaseCheck, String> {
        //大小写的枚举
        private CaseMode caseMode;
    
        @Override
        public void initialize(CaseCheck caseCheck) {
            this.caseMode = caseCheck.value();
        }
    
        @Override
        public boolean isValid(String value, ConstraintValidatorContext context) {
            //如果文本是空,则不进行校验,因为有其他的注解是可以校验空或者空字符串的
            if (null == value) {
                return true;
            }
    
            //文本只能是字母的正则
            String pattern = "^[a-zA-Z]*$";
            //校验传进来的是否是只包含了字母的文本
            boolean isMatch = Pattern.matches(pattern, value);
            //如果存在其他字符则返回校验失败
            if (!isMatch) {
                return false;
            }
    
            //如果没有指定方式,则直接返回false
            if (null == caseMode) {
                return false;
            }
    
            //判断是否符合大小写条件
            if (caseMode == CaseMode.UPPER) {
                return value.equals(value.toUpperCase());
            } else {
                return value.equals(value.toLowerCase());
            }
        }
    }
    
    • 泛型说明
      该校验器继承自ConstraintValidator这个接口;并传递了两个泛型参数;第一个是指明你自定义的注解;第二个是该注解作用的属性类型
    • 校验初始化
      如果属性添加了该校验器对应的注解,就会初始化(initialize)该校验器时,将你加在属性上面的注解传递进来;
    • 验证
      `初始化完会调用isValid方法·,并传递属性值;拿到属性值之后,就可以根据初始化传入的注解指定的规则,对属性值进行校验。验证通过返回true,并进行下一个属性的校验;验证失败返回false,并抛出异常;
  • 测试

    /**
     * 用户名
     */
    @NotBlank(message = "姓名不能为空",groups = UserRequestDtoSimpleValidate.class)
    @CaseCheck(value = CaseMode.UPPER,message = "用户名必须大写字母",groups = UserRequestDtoSimpleValidate.class)
    public String userName;
    
    // 。。。。
    

    image-20201202230819477 如遇图片加载失败,可尝试使用手机流量访问


响应

疑问
  1. 以什么样的格式返回数据?

    具体的格式,并没有一个绝对的标准,但是他必须满足一些条件:格式统一,易于扩展

  2. 异常码如何规范、如何定义便于扩展?

  3. 相同的返回对象,该如何根据不同的接口,返回不同的数据呢?

    比如,用户信息,列表查询的时候,只返回用户的姓名、年龄;响应查询的时候,需要返回用户的密码、创建时间等信息;

    而这些返回都是基于用户响应的DTO对象进行返回的;那如何能让其在不同的接口中返回不同的属性呢?

响应格式规范
  • 方式一
    基于内置的标准状态码进行响应,不做任何新的错误码定义,异常、错误就直接响应对应的HttpStatus;正常就返回200并在body中带上业务数据;

    • 优点
      基于标准的状态码;不用进行新的定义;
      减少前后端对于状态码的沟通
    • 缺点
      标准的只是定义了一些最基本的,无法满足一些个性化的业务场景;不过这种场景,也可以基于响应数据的格式去做
  • 方式二
    不用内置的标准状态码,所有的接口请求不管是正常、异常、错误;全部返回200;然后在doby的数据中定义自己系统的状态码;客户端收到body的数据之后,根据前后端约定的状态码进行校验并友好提示;

    • 优点
      灵活性强;可以根据自己的业务场景,去定义个性化的规则,
      可扩展性强;可以根据需要任意扩展;
    • 缺点
      规则约定带来的负担,
      维护成本增加;可能因为定义不规范导致后续维护的困难;

上面说的方式,没有对错,只有合不合适,更多的是根据业务的需要,场景的需要,找更合适的方式;

下面采用了上面两种方式的二合一版本;去定义一套响应规范;

成功:

{
    "status": 200,
    "msg": "成功!",
    "data": {
        "userName": "李四",
        "email": "lisi@qq.com"
    }
}

失败:

{
    "status": 1000,
    "msg": "参数错误!"
}
  • status
    当前请求的状态码;这里定义的是200为成功;200之外的为异常情况;
  • msg
    状态码对应的描述
  • data
    响应的数据;该属性是一个泛型值;其类型、值都是根据具体的业务场景需要进行匹配
纯枚举的错误码定义(不采取)

我们可以延用系统自带的状态码;即org.springframework.http.HttpStatus枚举;但是这个往往只表述的一些通用的状态,不能够表达或说明一些详细的问题点;因此通常情况下我们会对错误码进行自定义;以更加详细的描述出现的问题;如下:

@Getter
@AllArgsConstructor
public enum BaseStatusCode {
    SUCCESS(200,"成功!"),

    ERR_1000(1000,"参数错误!"),

    ERR_9999(9999,"未知错误!")
    ;
    // 状态码
    private Integer status;

    // 状态码描述
    private String msg;
}

通常情况下就是这样去定义,使用也没有什么问题;但是这样写有一个比较大的问题;就是 不够灵活、不易于扩展;因为这样,意味着所有的错误码都得定义在这一个枚举里面(后面的异常对象需要通过这个枚举值实例化);比如说,用户模块、设备模块、电商模块、库存模块都有自己个性化的错误码;就意味着,所有的验证码都堆在这么一个注解里面;耦合性太强,不便于扩展;

可扩展性状态码

为了解决上面不灵活的问题,那我们就采用一种 面向接口的状态码定义;来提高状态码的可扩展性和灵活性;

调整起来也很简单,状态码同样也是以上面的枚举当时定义,但是 增加一个接口出来

  • 第一步;定义接口

    该接口的

    /**
     * 错误码的接口
     */
    public interface IStatusCode {
        /**
         * 获取状态码
         * @return 返回
         */
         Integer getStatus();
    
        /**
         * 获取状态描述
         * @return 返回描述信息
         */
         String getMsg();
    }
    
  • 第二步;定义状态码

    此时;枚举中status和msg对应的get方法也就对应成了IStatusCode的实现

    @Getter
    @AllArgsConstructor
    public enum BaseStatusCode implements IStatusCode {
        SUCCESS(200,"成功!"),
    
        ERR_1000(1000,"参数错误!"),
    
        ERR_9999(9999,"未知错误!")
        // 状态码
        private Integer status;
    
        // 状态码描述
        private String msg;
    }
    
  • 第三步;扩展错误码

    /**
     * @author LENOVO
     * @title: UserStatusCode
     * @projectName springcloud-mbb
     * @description: TODO 用户相关的状态码
     * @date 2020/12/2 23:30
     */
    @Getter
    @AllArgsConstructor
    public enum UserStatusCode implements IStatusCode{
        ERR_2000(2000,"用户信息不存在"),
    
        ERR_2001(2001,"用户昵称格式错误")
        ;
        // 状态码
        private Integer status;
    
        // 状态码描述
        private String msg;
    }
    
    /**
     * @author LENOVO
     * @title: DeviceStatusCode
     * @projectName springcloud-mbb
     * @description: TODO 设备相关的状态码
     * @date 2020/12/2 23:33
     */
    @Getter
    @AllArgsConstructor
    public enum DeviceStatusCode implements IStatusCode{
        ERR_3000(3000,"设备id有误"),
        ERR_3001(3001,"设备名称格式错误"),
        ERR_3002(3002,"设备MAC地址无效")
        ;
        // 状态码
        private Integer status;
    
        // 状态码描述
        private String msg;
    }
    
  • 第四步;状态码的获取

    IStatusCode baseStatusCode = BaseStatusCode.ERR_9999;
    IStatusCode userStatusCode = UserStatusCode.ERR_2000;
    IStatusCode deviceStatusCode = DeviceStatusCode.ERR_3002;
    

    如此获取,优势就展现出来了,不管是以枚举的状态码还是对象的方式,只要是 实现了IStatusCode接口的类,都可以作为一个状态码对象;通过接口的getStatus()和getMsg()即可拿到状态码和状态描述;
    这样,我们就可以只需要把所有模块公共的状态码定义在公共模块里面;其他模块个性化的状态码,定义在模块内部即可;

  • 优点分析

    • 定义解耦;不需要将所有的状态码定义到一起了;只要实现了IStatusCode接口即可
    • 不限于枚举;因为是基于接口获取状态码和描述,因此不限于枚举,任何只要实现了IStatusCode都可以作为状态码
公共响应对象定义

有了规范好的响应对象的格式;有了状态码;那就可以定义一个基础的响应对象用来包装最后的返回结果;其中定义了4个构造方法,用于能够快速的实例化一个响应对象;

为了能更好的兼容;这里将HttpStatus状态码也封装了进来,这样就既可以使用默认状态码,可以使用自定义状态码,根据自己的需要灵活选择。

其中@JsonView的可以先不看,后面会介绍;只是为了后面不重复贴这一块的代码,先全部贴出来

/**
 * 基础的响应对象
 *
 * @param <T> 响应数据
 */
@Data
public class BaseResponceDto<T> {
    /**
     * 响应数据最外层的视图 也是所有响应视图的父类
     */
    public interface ResponceBaseDtoView {
    }

    /**
     * 状态码
     */
    @JsonView(ResponceBaseDtoView.class)
    private Integer status;

    /**
     * 状态描述
     */
    @JsonView(ResponceBaseDtoView.class)
    private String msg;

    /**
     * 响应数据
     */
    @JsonView(ResponceBaseDtoView.class)
    private T data;

    /**
     * 只有状态码的响应
     *
     * @param statusCode
     */
    public BaseResponceDto(IStatusCode statusCode) {
        if (null != statusCode) {
            this.status = statusCode.getStatus();
            this.msg = statusCode.getMsg();
        }
    }

    /**
     * 有状态码且有参数的响应
     *
     * @param statusCode
     * @param data
     */
    public BaseResponceDto(IStatusCode statusCode, T data) {
        if (null != statusCode) {
            this.status = statusCode.getStatus();
            this.msg = statusCode.getMsg();
        }
        if (null != data) {
            this.data = data;
        }
    }

    /**
     * 根据HttpStatus响应
     *
     * @param httpStatus http请求状态码
     */
    public BaseResponceDto(HttpStatus httpStatus) {
        if (null != httpStatus) {
            this.status = httpStatus.value();
            this.msg = httpStatus.getReasonPhrase();
        }
    }

    /**
     * 根据http状态码返回 并返回额外返回数据
     *
     * @param httpStatus http状态码
     * @param data       数据
     */
    public BaseResponceDto(HttpStatus httpStatus, T data) {
        if (null != httpStatus) {
            this.status = httpStatus.value();
            this.msg = httpStatus.getReasonPhrase();
        }
        if (null != data) {
            this.data = data;
        }
    }

    /**
     * 根据异常响应错误码
     *
     * @param baseException 异常对象
     */
    public BaseResponceDto(BaseException baseException) {
        if (null != baseException) {
            this.status = baseException.getError();
            this.msg = baseException.getMsg();
            this.data = (T) baseException.getData();
        }
    }
}
响应数据初始化工具

上面是提供了各种方式的构造方法,可以根据实际的需要进行实例化;为了能够更加方便的使用,所以这里写了一个静态工具类;用于将实例化响应对象的动作进一步封装,让响应数据对象的实例化更加简单、便捷

/**
 * 响应帮助类
 */
public class ReturnUtils {

    /**
     * 响应成功
     *
     * @return
     */
    public static BaseResponceDto<Void> success() {
        return new BaseResponceDto(BaseStatusCode.SUCCESS);
    }

    /**
     * 根据Http状态码返回
     *
     * @return 基础的响应对象
     */
    public static BaseResponceDto<Void> successByHttpStatus() {
        return new BaseResponceDto(HttpStatus.OK);
    }

    /**
     * 根据自定义的状态码返回
     * 有响应数据的成功
     *
     * @param data 响应的数据
     * @param <T>  响应的数据类型
     * @return 基础的响应对象
     */
    public static <T> BaseResponceDto success(T data) {
        return new BaseResponceDto<T>(BaseStatusCode.SUCCESS, data);
    }

    /**
     * 根据http状态码返回
     *
     * @param data 响应的数据
     * @param <T>  响应的数据类型
     * @return 基础的响应对象
     */
    public static <T> BaseResponceDto successByHttpStatus(T data) {
        return new BaseResponceDto<T>(HttpStatus.OK, data);
    }

    /**
     * 没有响应数据的失败
     *
     * @param statusCode 状态码
     * @return
     */
    public static BaseResponceDto<Void> error(BaseStatusCode statusCode) {
        return new BaseResponceDto(statusCode);
    }

    /**
     * 有响应数据的失败
     *
     * @param statusCode 状态码
     * @param data       数据
     * @return
     */
    public static <T> BaseResponceDto error(BaseStatusCode statusCode, T data) {
        return new BaseResponceDto<T>(statusCode, data);
    }

    /**
     * 异常后的响应
     *
     * @param baseException 异常
     * @return
     */
    public static BaseResponceDto error(BaseException baseException) {
        return new BaseResponceDto(baseException);
    }
}
  • 使用示例
    成功响应

    // 不带数据
    return ReturnUtils.success();
    // 带数据
    return ReturnUtils.success("123456");
    

    失败响应

    // 不带数据
    return ReturnUtils.error(BaseStatusCode.ERR_9999);
    
    // 带数据
    return ReturnUtils.error(BaseStatusCode.ERR_9999,"123456");
    
使用JsonView;规范响应对象

这个放在响应的最后说,是因为他并不属于响应结构的东西,但是他又属于响应的一部分,而且很重要;一个系统,权限是不可缺少的一部分,所谓的权限,简单的说,也就是不同的人,不同的接口,看到的数据不一样;同样是用户查询,用户列表只需要返回用户名即可,而用户详情就需要返回更多的数据;那么这种情况我们需要怎么去响应呢?定义多个响应DTO,当然这是最简单的方式;同样,我们也可以和validator中的分组一样;使用JsonView对响应的结果进行分组,使得同一个对象,在不同接口中返回不同的属性;

  • JsonView说明
    JsonView的定义和validator中的group是类似的概念;也是基于接口,使用也和validator类似;
  • 使用
    • 定义顶级接口

      此接口为所有JsonView接口的父类;其作用于响应的基础属性上;如下:

      @Data
      public class BaseResponceDto<T> {
          /**
           * 响应数据最外层的视图 也是所有响应视图的父类
           */
          public interface ResponceBaseDtoView {
          }
          
          /**
           * 状态码
           */
          @JsonView(ResponceBaseDtoView.class)
          private Integer status;
          
          //....
      }
      
    • 业务接口定义

      如下所示:

      所有视图都直接或者间接继承自ResponceBaseDtoView基础视图;否则会导致响应的BaseResponceDto对象为空json {}

      简单视图只返回用户名和手机号码

      详情视图,返回所有的属性

      /**
       * 用户响应请求
       */
      @Data
      public class UserResponceDto {
          // 简单视图,只返回最基数的属性
          public interface UserResponceSimpleDtoView extends BaseResponceDto.ResponceBaseDtoView {};
      
          // 详情视图,返回详细的属性参数
          public interface UserResponceDetailDtoView extends UserResponceSimpleDtoView {};
      
      
          /**
           * 用户名
           */
          @JsonView(UserResponceSimpleDtoView.class)
          public String userName;
      
          /**
           * 年龄
           */
          @JsonView(UserResponceDetailDtoView.class)
          private Integer age;
      
          /**
           * 性别
           */
          @JsonView(UserResponceDetailDtoView.class)
          private Integer gender;
      
          /**
           * 邮箱
           */
          @JsonView(UserResponceDetailDtoView.class)
          private String email;
      
          /**
           * 电话号码
           */
          @JsonView(UserResponceSimpleDtoView.class)
          private String phoneNum;
      
          /**
           * 修改人
           */
          @JsonView(UserResponceDetailDtoView.class)
          private String optUser;
      }
      
    • 使用;Controller指定视图

      @GetMapping("getSimple")
      // 指定JsonView的简单视图
      @JsonView(UserResponceDto.UserResponceSimpleDtoView.class)
      public BaseResponceDto getSimple() {
          UserResponceDto userResponceDto = new UserResponceDto();
          userResponceDto.setUserName("张三");
          userResponceDto.setAge(10);
          userResponceDto.setEmail("zhangsan@qq.com");
          userResponceDto.setGender(0);
          userResponceDto.setPhoneNum("13888888888");
          userResponceDto.setOptUser("admin");
      
          return ReturnUtils.success(userResponceDto);
      }
      
      @GetMapping("getDetail")
      // 指定详细视图
      @JsonView(UserResponceDto.UserResponceDetailDtoView.class)
      public BaseResponceDto getDetail() {
          UserResponceDto userResponceDto = new UserResponceDto();
          // 内容和上面一样
      
          return ReturnUtils.success(userResponceDto);
      }
      

      image-20201207000639957 如遇图片加载失败,可尝试使用手机流量访问
      即可看到,两个接口,根据我们的指定,返回了不同的属性值。

如何统一返回包装对象
  • 需求
    上面定义的代码;为了保证数据的响应格式是BaseResponceDto格式的;因此Controller所有的方法都是返回了这个对象;目的也是为了保证响应格式的一致性;但是,我国我们不返回这个对象可以吗?完全是可以的,而且也不会有任何报错;但是,这样却打破了我们定义的规则,导致响应的结构不一致了。
    能够在一个统一的地方去配置返回;保证响应的都是BaseResponceDto;而controller只需要返回数据即可;如下:

    @GetMapping("getSimple")
    @JsonView(UserResponceDto.UserResponceSimpleDtoView.class)
    public UserResponceDto getSimple() {
        UserResponceDto userResponceDto = new UserResponceDto();
        // ....
        return userResponceDto;
    }
    

    响应自动包装外层结构

    {
        "status": 200,
        "msg": "成功!",
        "data": {
            "userName": "张三",
            "phoneNum": "13888888888"
        }
    }
    
  • RestControllerAdvice拦截并重构响应

    • 创建继承自@ResponseBody的注解

      用来添加到方法或者类上;当响应写入body之间拦截结果

      /**
       * @author LENOVO
       * @title: ResponseDataBody
       * @projectName springcloud-mbb
       * @description: TODO 规范响应数据的注解
       * @date 2020/12/1 15:24
       */
      @Retention(RetentionPolicy.RUNTIME)
      @Target({ElementType.TYPE, ElementType.METHOD})
      @Documented
      @ResponseBody
      public @interface ResponseDataBody {
      }
      
    • 定义ResponseDataBodyAdvice拦截添加了@ResponseDataBody注解的响应

      @RestControllerAdvice(basePackages = "com.lupf")
      @Slf4j
      public class ResponseDataBodyAdvice implements ResponseBodyAdvice<Object> {
      
          /**
           * 得到自定义的注解
           */
          private static final Class<? extends Annotation> ANNOTATION_TYPE = ResponseDataBody.class;
      
          /**
           * 判断类或者方法是否使用了 @ResponseDataBody
           * 这里将注解添加在BaseController上面;以为着只要继承了BaseController的Controller都使用了该注解
           */
          @Override
          public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
              return AnnotatedElementUtils.hasAnnotation(returnType.getContainingClass(), ANNOTATION_TYPE) || returnType.hasMethodAnnotation(ANNOTATION_TYPE);
          }
      
          /**
           * 当类或者方法使用了 @ResponseDataBody 也就是上面的方法返回的true 就会调用这个方法
           */
          @Override
          public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
              // 防止重复包裹的问题出现 如果已经是要返回的基础对象了 就直接返回
              if (body instanceof BaseResponceDto) {
                  return body;
              }
              // 否则的话就直接返回
              return ReturnUtils.success(body);
          }
      }
      
      • supports
        判断是否指定了特定的注解
      • beforeBodyWrite
        在写入body之前,会调用这个方法;因此,就可以在这里将响应对象给改掉
        if (body instanceof BaseResponceDto) {
            return body;
        }
        // 否则的话就直接返回
        return ReturnUtils.success(body);
        
      • @RestControllerAdvice(basePackages = "com.lupf")
        如果以jar的方式加入;这里务必要指明一下当前类所处的路径;否则可能因为没有扫描到导致加载失败。
  • 封装的优点

    • 规范响应;
      避免因为代码错误或者响应错误导致报文格式异常;这样写,可以包装返回的对象必定是BaseResponceDto
    • 减少冗余代码
      Controller中直接返回数据对象;封装统一去进行。

异常

最后咱来说这个异常;在整个业务的请求到响应,异常并不是必定会出现的;但是,异常并不是必定会出现,但是又是不得不处理的;并且他贯穿了整个业务的始终,从请求到响应,都有可能牵扯到异常;所以一个好的异常处理机制,是整个代码健壮性必定要考虑的因素。

定义业务异常
  • 为什么要定义业务异常
    上面,我们定义了各种异常码;目的也就是当代码不是按我们预想的方式在跑的话,就基于错误码,抛出异常,终止业务流程;但是现有的系统异常并不认我们的状态码;所以,我们需要自定义一个认识我们状态码的异常;

  • 异常定义

    /**
     * 基础异常
     */
    @Data
    public class BaseException extends RuntimeException {
    
        /**
         * 错误码
         */
        private Integer error;
    
        /**
         * 错误描述
         */
        private String msg;
    
        /**
         * 错误后响应的信息
         */
        private Object data;
    
        /**
         * 根据错误码实例化异常
         *
         * @param statusCode 自定义错误码
         */
        public BaseException(IStatusCode statusCode) {
            // 校验是否传递了异常码
            if (null == statusCode) {
                // 如果没有统一设置为未知错误
                setInfo(BaseStatusCode.ERR_9999);
            } else {
                setInfo(statusCode);
            }
        }
    
        /**
         * 根据http状态码抛出异常
         *
         * @param httpStatus http状态码
         */
        public BaseException(HttpStatus httpStatus) {
            if (null == httpStatus) {
                // 没有传递默认使用 未知异常
                setInfo(BaseStatusCode.ERR_9999);
            } else {
                setInfo(httpStatus);
            }
        }
    
        /**
         * 根据错误码实例化异常 并返回数据
         *
         * @param statusCode 自定义错误码
         * @param data       数据
         */
        public BaseException(IStatusCode statusCode, Object data) {
            // 校验是否传递了异常码
            if (null == statusCode) {
                // 如果没有统一设置为未知错误
                setInfo(BaseStatusCode.ERR_9999);
            } else {
                setInfo(statusCode);
            }
            // 校验数据是否为null
            if (null != data) {
                this.data = data;
            }
        }
    
        /**
         * 根据http的状态码实例化异常 并返回数据
         *
         * @param httpStatus http状态码
         * @param data       数据
         */
        public BaseException(HttpStatus httpStatus, Object data) {
            // 校验是否传递了异常码
            if (null == httpStatus) {
                // 如果没有统一设置为未知错误
                setInfo(BaseStatusCode.ERR_9999);
            } else {
                setInfo(httpStatus);
            }
            // 校验数据是否为null
            if (null != data) {
                this.data = data;
            }
        }
    
    
        /**
         * 设置状态码及描述信息
         * 内部使用的方法
         *
         * @param statusCode
         */
        private void setInfo(IStatusCode statusCode) {
            if (null != statusCode) {
                this.error = statusCode.getStatus();
                this.msg = statusCode.getMsg();
            }
        }
    
        /**
         * 根据HttpStatus设置属性
         * @param httpStatus
         */
        private void setInfo(HttpStatus httpStatus) {
            if (null != httpStatus) {
                this.error = httpStatus.value();
                this.msg = httpStatus.getReasonPhrase();
            }
        }
    }
    
    • error
      错误码
    • msg
      错误描述
    • data
      绑定的数据,异常也可能需要返回数据,因此可以在这里去指定
    • 构造方法
      基于IStatusCodeHttpStatus的构造方法;用于快速实例化异常对象
  • 扩展异常
    如果因业务需要,在特定场所需要一些一些特殊的异常;我们可以再建BaseException的子类去进一步细化。

  • 抛异常

    异常定义好之后,想抛一个异常,自然就是很简单的啦

    throw new BaseException(HttpStatus.ACCEPTED, "123456");
    
如何优雅的全局捕获异常
  • 问题点
    当我们的业务逻辑中出现了异常;比如要修改某个用户,请求的数据也没有问题;结果在修改直接去查找用户的时候,发现已经没有这个用户了;那么一般就抛出一个用户不存在的异常,如果不对异常进行处理的话,前端就只会收到一个400的错误;而我们希望的是这样:

    {
        "status": 2001,
        "msg": "用户不存在!"
    }
    
  • 通过ExceptionHandler捕获全局异常

    定义一个BaseController;所有的controller都继承自他

    如下所示;当出现指定的异常之后;根据匹配,返回不同的响应数据;

    /**
     * Controller的的基础对象
     * 所有的Controller都将继承自他
     */
    @Slf4j
    @ResponseDataBody
    public class BaseController {
    
        @ExceptionHandler(HttpMessageNotReadableException.class)
        @ResponseStatus(HttpStatus.BAD_REQUEST)
        @ResponseBody
        public Object HttpMessageNotReadableExceptionHandler(HttpMessageNotReadableException httpMessageNotReadableException){
            log.error("捕获请求参数读取异常....",httpMessageNotReadableException);
            // 前端未传递参数 导致读取参数异常
            return ReturnUtils.error(BaseStatusCode.ERR_1000);
        }
    
        @ExceptionHandler(BindException.class)
        @ResponseStatus(HttpStatus.BAD_REQUEST)
        @ResponseBody
        public Object bindExceptionHandler(BindException bindException){
            log.error("捕获请求参数校验异常....",bindException);
            // 获取到所有的校验失败的属性
            List<FieldError> fieldErrors = bindException.getFieldErrors();
    
            // 实例化一个用于装参数错误的list
            List<ParamErrDto> paramErrDtos = new ArrayList<>();
            for (FieldError fieldError : fieldErrors) {
                // 那段字段名
                String field = fieldError.getField();
                // 拿到异常的描述
                String defaultMessage = fieldError.getDefaultMessage();
                log.info("field:{} msg:{}", field, defaultMessage);
                // 添加到list中去
                paramErrDtos.add(new ParamErrDto(field, defaultMessage));
            }
    
            // 返回前端参数错误 并告诉前端那些字段不对 具体描述是什么
            return ReturnUtils.error(BaseStatusCode.ERR_1000, paramErrDtos);
        }
    
        @ExceptionHandler(BaseException.class)
        @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
        @ResponseBody
        public Object baseExceptionHandler(BaseException baseException){
            log.error("捕获到业务异常!",baseException);
            // 基础的业务异常
            return ReturnUtils.error(baseException);
        }
    
        /**
         * 通过ExceptionHandler 捕获controller未捕获到的异常,给用户一个友好的返回
         *
         * @param ex 异常信息
         * @return
         */
        @ExceptionHandler(Exception.class)
        @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
        @ResponseBody
        public Object exceptionHandler(Exception ex) {
            log.error("exceptionHandler....");
            // 所有的  自定义的、已知的异常全部都没有匹配上
            // 直接响应响应一个未知错误的提醒
            return ReturnUtils.error(BaseStatusCode.ERR_9999);
        }
    }
    

    使用

    public class UserController extends BaseController {
    // ....
    }
    
    • @ExceptionHandler(HttpMessageNotReadableException.class)
      当body没有传参数时,会触发这个异常,并返回参数错误的状态码
    • @ExceptionHandler(BindException.class)
      当validator校验失败之后,会触发这个异常;因此这里将所有不符合规范的传参整理成列表返回。
    • @ExceptionHandler(BaseException.class)
      自定义业务异常;直接将异常对象转换为响应对象;返回给前端
    • @ExceptionHandler(Exception.class)
      用来处理那些没有特定处理的异常;然后由这里拦截之后,统一返回未知错误;

总结

请求、响应、异常是每项业务不可或缺的一部分;三者相辅相成,缺一不可,所谓的规范,也就是让他们三个之间衔接、配合的更顺畅、更默契;因此,只有一开始就将一系列的东西考虑清楚并打好地基,才会使得后面的路越走越顺;

扫码关注公众号;回复" 666 "即可获取抽奖链接;12月9日22点开奖



标题:SpringBoot!你的请求、响应、异常规范了吗?(文末红包)
作者:码霸霸
地址:https://blog.lupf.cn/articles/2020/12/07/1607280194906.html