要想接口做的好、入参校验少不了!

要想接口做的好、入参校验少不了!

敲得码黛 291 2021-03-03

前言

两年前我刚步入社会接受毒打时,对整个开发流程还没什么概念,当时我以为的开发工作=“CRUD+接口对接”,所以代码写的那叫一个为所欲为(Map传参、不加注释、不打日志等等等等)。后来我被分到另一个项目组,原来的代码被我一个同事接手,后来这位同事辞职了。。。。。

老大瞅了一眼我写的代码,差点没把早上吃的两个包子吐出来,然后拉着我就是长达一个小时的谈话,啥代码可读性、接口可用性、系统健壮性啥的,咱也听不懂呀,从头到尾就听明白了一句话:“系统的Bug 80%以上都是因为没有做入参校验”。

小何内心ps:曾经有一个机会摆在我面前,我没有好好珍惜,如果上天再给我个机会,我一定会做好入参校验,如果非要给这件事情加个期限,我希望是Just Now

环境准备

假设我们现在需要编写一个下单接口,这个接口的参数包含商户订单号(orderId)、订单金额(money)、支付方式(payType)、平台商户号(pfMchId)、子商户号(subMchId)等字段,按照之前的写法,这个接口应该长如下模样

@RestController
public class OrderController {

 @PostMapping("/unifiedOrder")
 public String unifiedOrder(@RequestBody Map map){
     if (map.get("orderId") != null) {
         if (map.get("orderMoney") != null) {
             if (map.get("payType") != null) {
                 if(map.get("mchId") !=null&& map.get("subMchId")!=null){
                     // TO DO SOMETHING
                     return "SUCCESS";
                 }else{
                     return "平台商户号与子商户号不能同时为空";
                 }
             }else{
                 return "支付类型不能为空";
             }
         }else{
             return "订单金额不能为空";
         }
     }else{
         return "订单号不能为空";
     }

 }
}

看完这段代码不知道你怎么想,反正放到现在肯定是要被我DS的,下面就开始着手于此接口的改造。

Spring validation

引入依赖

如果是基于SpringBoot 2.3之前版本的项目会默认引入此依赖。2.3之后的版本则需要手动引入此依赖

  <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-validation</artifactId>
      <version>2.2.5.RELEASE</version>
  </dependency>

创建数据模型

这一步其实是为了避免使用Map传递数据,毕竟Map的可读性实在是不怎么理想(觉得这一步没有必要的拉出去毒打一顿

于是将接口的入参模型Map改为如下数据模型

/**
 * 下单接口入参模型
 * @author hcq
 * @date 2021/3/13 13:23
 */
@Data
public class OrderModel {

    /**
     * 订单Id
     */
    private String orderId;

    /**
     * 订单金额
     */
    private String money;

    /**
     * 支付方式
     */
    private String payType;

    /**
     * 平台商户号
     */
    private String pfMchId;

    /**
     * 子商户号
     */
    private String subMchId;
}

接口变更如下

@RestController
public class OrderController {

    @PostMapping("/unifiedOrder")
    public String unifiedOrder(@RequestBody OrderModel order){
        if (order.getOrderId() != null) {
            if (order.getMoney() != null) {
                if (order.getPayType() != null) {
                    if(order.getPfMchId()!=null&& order.getSubMchId()!=null){
                        // TO DO SOMETHING
                        return "SUCCESS";
                    }else{
                        return "平台商户号与子商户号不能同时为空";
                    }
                }else{
                    return "支付类型不能为空";
                }
            }else{
                return "订单金额不能为空";
            }
        }else{
            return "订单号不能为空";
        }

    }
}

好吧~看起来几乎没啥变动,重头戏还在后面

SpringValidation(划重点了呃)

Spring的Validation组件使用起来十分简洁,只需要简单的两步

  • 在需要校验的接口参数前加上@Valid或者是@Validated 注解

    @RestController
    public class OrderController {
    
        @PostMapping("/unifiedOrder")
        public String unifiedOrder(@Validated @RequestBody OrderModel order) {
            if (order.getPfMchId() != null && order.getSubMchId() != null) {
                // TO DO SOMETHING
                return "SUCCESS";
            } else {
                return "平台商户号与子商户号不能同时为空";
            }
        }
    
    }
    
  • 在需要校验的字段上添加校验规则及提示信息

    @Data
    public class OrderModel {
    
        /**
         * 订单Id
         */
        @NotNull(message = "订单Id不能为空")
        private String orderId;
    
        /**
         * 订单金额
         */
        @NotNull(message = "订单金额不能为空")
        private String money;
    
        /**
         * 支付方式
         */
        @NotNull(message = "支付方式不能为空")
        private String payType;
    
        /**
         * 平台商户号
         */
        private String pfMchId;
    
        /**
         * 子商户号
         */
       private String subMchId;
    }    
    

做完这两步后,一个简单的入参校验就已经完成了。如果@NotNull修饰的字段为null值,那么后端服务器将会抛出BindException参数绑定异常,json类型入参则抛出MethodArgumentNotValidException异常,两种异常内部都包含着所有不符合规则的字段提示信息,此时可以由全局异常处理器捕获到此异常并进行异常响应(不清楚全局异常处理器怎样使用的可以参考我之前的文章)。

执行结果分析

PostMan发起请求,后端服务器抛出的MethodArgumentNotValidException异常被默认异常处理器DefaultHandlerExceptionResolver拦截,然后打印了相关异常信息

Validation常见的校验注解

  • @NotNull :该字段不允许为null值
  • @NotEmpty:该字段不允许为null值或空值,此注解同样适用于校验集合不允许为空
  • @Null:该注解与@NotNull正好相反,标识该字段必须为Null
  • @Pattern:通过正则表达式进行匹配,若该值无法匹配成功则抛出异常
  • @Max:通常使用在数字类型字段,标识该字段最大取值
  • @Min:通常使用在数字类型字段,标识该字段最小取值
  • @Lenth:标识该字段长度范围

自定义参数校验注解

我们会发现Validation提供的注解大多时候只能满足一些简单的校验场景,稍微复杂一点的场景就不适用于此规则了,例如最常见的一些接口规则有:多选一必填(Or)、只允许某些值中的一个(In)、多个字段不能同时上送(Mutex)等,这个时候我们可以通过自定义注解来完成相关参数的校验。还是拿刚才的栗子来讲,我们可以通过定义一个@Or自定义注解实现"pfMchId与subMchId二选一必填"的校验规则

接口改造如下

@PostMapping("/unifiedOrder")
public String unifiedOrder(@Validated @RequestBody OrderModel order) {
        // TO DO SOMETHING
        return "SUCCESS";
}

创建自定义注解Or

@Target({ElementType.TYPE})
@Repeatable(List.class)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = InVerifyHandler.class)
public @interface Or {

    String[] fields() default {};

    String message() default "Invalid ID!";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

    
    @Target({ElementType.TYPE})
    @Retention(RetentionPolicy.RUNTIME)
    @interface List{
        Or[] value();
    }

}
  • @Target({ElementType.TYPE}):标识该注解的作用域(作用Class上)

  • @Repeatable(List.class):标志该注解可以在同一对象上重复声明

  • @Retention(RetentionPolicy.RUNTIME):标识该注解的生命周期(保留至运行时)

  • @Constraint(validatedBy = InVerifyHandler.class):标识该注解的处理类

注解处理类

@Slf4j
public class InVerifyHandler implements ConstraintValidator<Or,Object> {

    private String[] fields;

    @Override
    public void initialize(Or targetAnno) {
        fields = targetAnno.fields();
    }

    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {
        if(fields.length==0){
            return true;
        }
        try {
            for (String field : fields) {
                Field file = value.getClass().getDeclaredField(field);
                file.setAccessible(true);
                if(file.get(value)!=null){
                    return true;
                }
            }
        }catch (Exception e){
            log.error("自定义校验注解异常:",e);
        }
        return false;
    }
}

使用介绍

@Data
@Or(fields = {"pfMchId","subMchId"},message = "pfMchId、subMchId不能同时为空")
public class OrderModel {
    /**
     * 平台商户号
     */
    private String pfMchId;

    /**
     * 子商户号
     */
   private String subMchId;
}

通过Validation注解+自定义参数校验注解几乎可以覆盖绝大多数的校验场景,当然也不可能做到百分之百覆盖,一些业务场景上的校验还是有必要手动处理一下的。

本期文章就到这里啦,你学废了吗?


# spring # 全局参数校验