Spring MVC后端参数校验


一、相关概念


1.为什么要进行统一参数校验

  通常为保证接口的安全性,后端接口需要对入参进行合法性校验,如所填参数是否为空、参数长度是否在规定范围内、手机号或邮箱是否为正确格式等。因此,不可避免在业务代码中进行手动校验,但这种方式使得代码臃肿,可读性差。在Spring中提供了统一参数校验框架,将校验逻辑与业务逻辑进行隔离,进而优雅处理这个问题,简化开发人员负担


2. 统一参数校验框架

  在Spring中,其自身不仅拥有独立的参数校验框架,还支持符合JSR303标准的框架。JSR303是java为Bean数据合法性校验提供的标准框架,通过在Bean属性上标注注解,来设置对应的校验规则,下表展示JSR303部分注解

注解说明
@Null被注释元素必须为null
@NotNull被注释元素必须不为null
@Max(value)被注释元素必须是一个数字,且值必须小于等于指定最大值
@Size(max,min)被注释元素的大小在指定范围内
@Pattern(value)被注释元素必须符合指定的正则表达式

  Spring本身并没有提供JSR303标准的实现,在使用JSR303参数校验时,需要引入具体的第三方实现。 Hibernate Validator是一个符合JSR303标准的官方参考实现,除支持以上所有标准注解外,它还支持以下的扩展注解

注解说明
@Email被注释元素必须是电子邮箱地址
@Length被注释字符串大小必须在指定范围内
@NotEmpty被注释字符串必须非空
@Range被注释元素必须在合适范围内

  当然,若以上校验注解均无法满足实际校验需求,可以通过实现ConstraintValidator接口,来自定义注解和校验规则。


3. 统一参数校验基本流程

  • 首先需要在Spring容器中创建一个LocalValidatorFactoryBean(SpringBoot会自动装配好一个LocalValidateFactoryBean)
  • 然后在已标注校验注解的入参bean对象前加上一个@Valid,表明需要被校验,当Spring MVC框架将请求参数绑定到该bean对象后,交由spring校验框架进行处理
  • 根据注解声明的校验规则进行校验,若不合法抛出javax.validation.ConstraintViolationException异常。另外也可将校验的结果保存到随后的入参中,入参必须是BindingResult或Errors类型,并通过对应方法获取到校验失败的字段及信息(需要被校验的入参bean对象和接收校验结果的对象是成对出现,它们之间不允许声明其它入参)


4. @Validated 和 @Valid 的区别

   在Spring参数校验框架中增加了一个@Validated注解(它是JSR303的一个变种,对JSR303进行一定程度的封装和扩展),@Validated和@Valid在进行基本验证功能上没有太多区别,但它们主要区别在于注解范围、分组校验、嵌套校验上有所不同

  • @Validatd注解范围可以用在类、方法和方法参数上,但是不能用在成员属性(字段)上,而@Valid可以用在类、方法、方法参数和成员属性(字段)上

  • @Validatd提供了分组校验功能,而@Valid未提供分组校验功能

  • @Validated和@Valid单独加在方法参数前,都不会自动对参数进行嵌套检验,因为@Valid可应用在成员属性上,通过添加该注解,配合@Validated或者@Valid来进行嵌套校验


二、不同类型参数校验举例


1. 简单参数校验

  1. 定义一个简单的入参类,作为用户注册时入参所绑定的bean对象

    @Data
    public class AdminVo {
    
        /**
         * id
         */
        private Long id;
    
        /**
         * 用户名
         */
        @NotBlank(message = "用户名不能为空")
        private String username;
    
        /**
         * 密码
         */
        @NotBlank(message = "密码不能为空")
        private String password;
    
        /**
         * 创建日期
         */
        @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")  //入参格式化
        private Date createTime;
    
        /**
         * 更新日期
         */
        @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
        private Date updateTime;
    }
    
  2. 定义controller类,创建一个新增用户接口

    @RestController
    public class AdminController {
      @Autowired
      AdminService adminService;
            
      @PostMapping("/add")
      public Long add(@Validated @RequestBody AdminVo adminVo) {
          Admin admin = new Admin();
          BeanUtils.copyProperties(adminVo, admin);
          Date date = new Date();
          admin.setCreateTime(date);
          admin.setUpdateTime(date);
          adminService.save(admin);
          return admin.getId();
      }
    }
    
  3. 定义一个全局异常处理类,用来接收参数校验异常的结果

    @Slf4j
    @RestControllerAdvice
    public class GlobalExceptionHandler {
          
           @ExceptionHandler(value = Exception.class)
           public R<Object> handleBadRequest(Exception e) {
                  /*
                   * 参数校验异常
                   */
                if (e instanceof BindException) {
                    BindingResult bindingResult = ((BindException) e).getBindingResult();
                      if (null != bindingResult && bindingResult.hasErrors()) {
                          //校验结果集
                          List<String> errorList = new ArrayList<>();
                          List<FieldError> errors = bindingResult.getFieldErrors();
                          if (CollectionUtil.isNotEmpty(errors)) {
                              errors.forEach(error->{
                                  errorList.add(error.getField()+" "+error.getDefaultMessage());
                              });
                          } else {
                              String msg = bindingResult.getAllErrors().get(0).getDefaultMessage();
                              errorList.add(msg);
                          }
                          return R.restResult(errorList, ApiErrorCode.FAILED);
                      }
                  }
          
                  /**
                   * 系统内部异常,打印异常栈
                   */
                  log.error("Error: handleBadRequest StackTrace : {}", e);
                  return R.failed(ApiErrorCode.FAILED);
              }
          }
    
  4. 启动服务,若请求内容均为空如{},返回如下结果

    {
      "code": -1,
      "data": [
         "username 用户名不能为空""password 密码不能为空"
      ],
      "msg": "操作失败"
    }
    


2. 嵌套校验

  1. 当入参对象包含复杂属性时需要进行嵌套校验。如用户包含多个角色,不仅需要对用户基本信息校验,还要对所填角色进行校验,修改入参对象并添加角色列表,@Valid作用于成员属性roleVoList上

    @Data
    public class AdminVo {
    
       /**
        * id
        */
       private Long id;
    
       /**
        * 用户名
        */
       @NotBlank(message = "用户名不能为空")
       private String username;
    
       /**
        * 密码
        */
       @NotBlank(message = "密码不能为空")
       private String password;
    
       /**
        * 角色
        */
       @Valid
       private List<RoleVo> roleVoList;
    
       /**
        * 创建日期
        */
       @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")  //入参格式化
       private Date createTime;
    
       /**
        * 更新日期
        */
       @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
       private Date updateTime;
    }
    
    @Data
    public class RoleVo {
       /**
        *角色id
        */
       private Long id;
    
       /**
        *角色名称
        */
       @NotBlank(message = "角色名不能为空")
       private String RoleName;
    
    }
    
  2. 启动服务,请求内容中添加角色信息roleVoList为空

    {
       "username": "wsp",
       "password": "123456",
       "roleVoList":[{}]
    }
    
    
  3. 返回如下结果

    {
        "code": -1,
        "data": [
            "roleVoList[0].RoleName 角色名不能为空"
        ],
        "msg": "操作失败"
    }
    


3. 分组校验

  1. 当多个接口的入参对象为同一个,但是校验规则不同,此时需要使用分组校验。对用户进行修改时,保证传入用户id不能为空,并指定分组类型为Update.class(不设置分组类型时,会给定一个默认分组类型为Default.class)

    @Data
    public class AdminVo {
    
        /**
         * id
         */
        @NotNull(groups = {Update.class})
        private Long id;
    
        /**
         * 用户名
         */
        @NotBlank(message = "用户名不能为空")
        private String username;
    
        /**
         * 密码
         */
        @NotBlank(message = "密码不能为空")
        private String password;
    
        /**
         * 角色
         */
        @Valid
        private List<RoleVo> roleVoList;
    
        /**
         * 创建日期
         */
        @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")  //入参格式化
        private Date createTime;
    
        /**
         * 更新日期
         */
        @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
        private Date updateTime;
    }
    
  2. 创建一个修改用户接口,定义校验分组类型为Update.class,只会找入参对象中相同分组类型的属性进行校验,若需要对其它未设置分组的属性进行校验,因为默认的分组类型为Default.class,可以定义校验的分组类型为@Validated({Update.class,Default.class}

    @PostMapping("/update")
    public Long update(@Validated({Update.class}) @RequestBody AdminVo adminVo) {
            Admin admin = new Admin();
            BeanUtils.copyProperties(adminVo, admin);
            admin.setUpdateTime(new Date());
            adminService.updateById(admin);
            return admin.getId();
        }
    
  3. 启动服务,请求内容中不填用户id

    {
        "username":"wsp",
        "password":"123456",
        "roleVoList":[{"roleName":"管理员"}]
    }
    
  4. 返回结果如下

    {
        "code": -1,
        "data": [
            "id 不能为null"
        ],
        "msg": "操作失败"
    }
    


4. 自定义校验

  1. 当基本注解无法满足实际需求时,需要自定义注解校验。如根据用户名批量删除用户,所提供@NotEmpty只能保证入参集合不能为空,无法保证集合中元素为空即用户名为空字符串的情况,首先定义一个自定义注解

    @Documented
    @Constraint(validatedBy = {NotEmptyValidatorForCollection.class})
    @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
    @Retention(RUNTIME)
    @Repeatable(List.class)
    public @interface NotEmptyForCharSequence {
    
    	String message() default "{javax.validation.constraints.NotEmpty.message}";
    
    	Class<?>[] groups() default { };
    
    	Class<? extends Payload>[] payload() default { };
    
    	/**
    	 * Defines several {@code @NotEmpty} constraints on the same element.
    	 *
    	 * @see NotEmptyForCharSequence
    	 */
    	@Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
    	@Retention(RUNTIME)
    	@Documented
    	public @interface List {
    		NotEmptyForCharSequence[] value();
    	}
    }
    
  2. 具体的校验规则由NotEmptyValidatorForCollection.class实现

    public class NotEmptyValidatorForCollection implements ConstraintValidator<NotEmptyForCharSequence, Collection> {
       public NotEmptyValidatorForCollection() {
       }
    
       public boolean isValid(Collection collection, ConstraintValidatorContext constraintValidatorContext) {
           //集合不能为空,且集合中不能为空字符串
           if (collection == null || checkEmptyElement(collection)) {
               return false;
           } else {
               return collection.size() > 0;
           }
       }
    
       private boolean checkEmptyElement(Collection<String> collection) {
           for (String s : collection) {
               if(StringUtils.isBlank(s)){
                   return true;
               }
           }
           return false;
       }
    }
    
  3. 创建一个根据用户名批量删除用户的接口,并添加自定义注解@NotEmptyForCharSequence (为保证注解生效,此接口的Contoller类上需要添加@Validated注解)

    @RestController
    @Validated
    public class AdminController {
        @Autowired
        AdminService adminService;
        
        @DeleteMapping("/batchDelete")
        public boolean batchDeleteByUserName(@RequestBody @NotEmptyForCharSequence List<String> userNameList) throws Exception {
            return adminService.remove(Wrappers.<Admin>lambdaQuery().in(Admin::getUsername,userNameList));
        }
    
    }
    
  4. 在接口方法中指定具体校验注解,校验异常默认为ConstraintViolationException.class,需要在全局异常类中捕获该异常,在全局异常类GlobalExceptionHandler.class的handleBadRequest(Exception e)方法中添加如下方法

    if (e instanceof ConstraintViolationException){
                //校验结果
                List<String> errorList = new ArrayList<>();
                Set<ConstraintViolation<?>> constraintViolations = ((ConstraintViolationException) e).getConstraintViolations();
                if (CollUtil.isNotEmpty(constraintViolations)){
                    constraintViolations.forEach(constraintViolation -> {
                        Path propertyPath = constraintViolation.getPropertyPath();
                        String message = constraintViolation.getMessage();
                        errorList.add(propertyPath.toString()+" "+message);
                    });
                }
                return R.restResult(errorList, ApiErrorCode.FAILED);
            }
    
  5. 启动服务,请求内容中部分用户为空字符串

    [
        "1"," "
    ]
    
  6. 返回结果如下

    {
        "code": -1,
        "data": [
            "batchDeleteByUserName.userNameList 不能为空"
        ],
        "msg": "操作失败"
    }
    


5. 多属性交叉校验

  1. 以上都是单个属性校验,当入参对象中多个不同属性需要进行交叉校验,可以通过脚本执行器@ScriptAssert执行脚本或者自定义注解实现。如要查询指定时间范围内所创建用户列表时,要保证入参的开始时间小于等于结束时间,定义一个条件查询对象

    @Data
    @ScriptAssert(lang = "javascript", script = "com.wsp.validate.model.AdminCondition.checkTime(_this.startTime,_this.endTime)", message = "开始日期不能大于结束日期")
    public class AdminCondition {
        /**
         * 开始日期
         */
        @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
        @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
        private Date startTime;
    
        /**
         * 结束日期
         */
        @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
        @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
        private Date endTime;
    
        //脚本校验(为静态方法)
        public static boolean checkTime(String startTime,String endTime){
           if(startTime.compareTo(endTime) > 0){
               return false;
           }
           return true;
        }
    }
    
  2. 创建一个根据用户注册日期查询用户列表的接口

    @PostMapping("/search")
    public List<Admin> searchByRangeOfDate(@Validated @RequestBody AdminCondition adminCondition){
        return adminService
               .list(Wrappers<Admin>lambdaQuery().between(Admin::getCreateTime,
                adminCondition.getStartTime(),adminCondition.getEndTime()));
    }
    
  3. 启动服务,请求内容中开始时间大于结束时间

    {
        "startTime": "2021-10-10 22:00:00",
        "endTime":"2021-10-10 21:37:00"
    }
    
  4. 返回结果如下

    {
        "code": -1,
        "data": [
            "开始日期不能大于结束日期"
        ],
        "msg": "操作失败"
    }
    
  5. 另外,还可以使用自定义注解方式来实现,首先自定义一个注解

    @Documented
    @Constraint(validatedBy = {CheckTimeIntervalValidator.class})
    @Target({TYPE, METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
    @Retention(RUNTIME)
    @Repeatable(CheckTimeInterval.List.class)
    public @interface CheckTimeInterval {
    
        String startTime() default "startTime"; //所需校验的属性为startTime
    
        String endTime() default "endTime"; //所需校验的属性为endTime
    
        String message() default "开始时间不能大于结束时间";
    
        Class<?>[] groups() default { };
    
        Class<? extends Payload>[] payload() default { };
    
        @Target({ METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE })
        @Retention(RUNTIME)
        @Documented
        public @interface List {
            CheckTimeInterval[] value();
        }
    }
    
  6. 具体校验规则由CheckTimeIntervalValidator.class实现

    public class CheckTimeIntervalValidator implements ConstraintValidator<CheckTimeInterval, Object> {
    
        private String startTime;
    
        private String endTime;
    
        @Override
        public void initialize(CheckTimeInterval constraintAnnotation) {
             this.startTime = constraintAnnotation.startTime();
             this.endTime = constraintAnnotation.endTime();
        }
    
        @Override
        public boolean isValid(Object value, ConstraintValidatorContext context) {
            if(null == value){
                return false;
            }
            //取对象属性值
            BeanWrapperImpl beanWrapper = new BeanWrapperImpl(value);
            Object start = beanWrapper.getPropertyValue(startTime);
            Object end = beanWrapper.getPropertyValue(endTime);
            if(((Date)start).compareTo((Date) end) > 0){
                return false;
            }
            return true;
        }
    }
    
  7. 创建一个根据用户注册日期查询用户列表接口,并添加@CheckTimeInterval校验注解(为保证该注解生效,Controller类上需要添加@Validated)

    @PostMapping("/search")
    public List<Admin> searchByRangeOfDate(@CheckTimeInterval @RequestBody AdminCondition adminCondition){
            return adminService.list(Wrappers.<Admin>lambdaQuery().between(Admin::getCreateTime,
                    adminCondition.getStartTime(),adminCondition.getEndTime()));
        }
    
  8. 启动服务,若请求内容中开始时间大于结束时间

    {
        "startTime": "2021-10-10 23:00:00",
        "endTime":"2021-10-10 21:37:00"
    }
    
  9. 返回结果如下

    {
        "code": -1,
        "data": [
            "searchByRangeOfDate.adminCondition 开始时间不能大于结束时间"
        ],
        "msg": "操作失败"
    }
    
  • 23
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值