SpringBoot参数校验及原理解析
作者:mmseoamin日期:2023-12-14

目录

前言

一、基本用法

@RequestBody参数校验

@RequestParam/@PathVariable参数校验

编程式校验

二、进阶用法

自定义验证注解

多属性联合校验

嵌套校验

三、实现原理

@RequestBody参数校验实现原理

@RequestParam/@PathVariable参数校验实现原理

项目源码

附件


前言

平时服务端开发过程中,不可避免的需要对接口参数进行校验,比较常见的比如用户名不能为空、年龄必须大于0、邮箱格式要合规等等。如果通过if else去校验参数,校验代码会跟业务耦合,且显得很冗长。SpringBoot提供了一种简洁、高效的方式,通过@Validated/@Valid注解来做参数校验,大大提高了工作效率

一、基本用法

总共三种方式:

  • Controller的@RequestBody参数校验
  • Controller的@RequestParam/@PathVariable参数校验
  • 编程式校验,直接调用hibernate的validate方法

    三种方式都需要加上以下依赖。里面有所需的jakarta.validation-api和hibernate-validator包

    
        org.springframework.boot
        spring-boot-starter-validation
    
    • @RequestBody参数校验

      该方式适用于Controller中POST/PUT方法的参数校验,校验失败会抛MethodArgumentNotValidException

      1.首先在参数类的属性上声明约束注解,比如@NotBlank、@Email等

      @Data
      public class UserVo implements Serializable {
          @NotBlank(message = "名字不能为空")
          @Size(min = 2, max = 50, message = "名字长度的范围为2~50")
          private String name;
          @Email(message = "邮箱格式不对")
          private String email;
          @NotNull(message = "年龄不能为空")
          @Min(18)
          @Max(100)
          private Integer age;
          @NotEmpty(message = "照片不能为空")
          private List photoList;
      }

      2.接着在Controller方法@RequestBody旁加上@Validated注解

      @Slf4j
      @RestController
      public class UserController {
          @ApiOperation("保存用户")
          @PostMapping("/save/user")
          public Result saveUser(@RequestBody @Validated UserVo user) {
              return Result.ok();
          }
      }
      • @RequestParam/@PathVariable参数校验

        该方式适用于Controller中GET方法的参数校验,校验失败会抛ConstraintViolationException。它是通过类上加@Validated注解,方法参数前加@NotBlank等约束注解的方式来实现的,所以其它Spring Bean的方法也适用

        1.Controller类上加@Validated注解;@RequestParam/@PathVariable旁加上@NotBlank、@Max等注解

        @Slf4j
        @RestController
        @Validated
        public class UserController {
            @ApiOperation("查询用户")
            @GetMapping("/list/user")
            public Result> listUser(
                    @Min(value = 100, message = "id不能小于100") @RequestParam("id") Long id,
                    @NotBlank(message = "名称不能为空") @RequestParam("name") String name,
                    @Max(value = 90, message = "年龄不能大于90") @RequestParam("age") Integer age) {
                List list = new ArrayList<>();
                return Result.ok(list);
            }
        }
        • 编程式校验

          该方式适用于Service参数的校验,校验失败手动抛ValidationException

          1.通过@bean注解初始化Validator对象

          public class ValidatorConfig {
              @Bean
              public Validator validator() {
                  return Validation.byProvider(HibernateValidator.class)
                          .configure()
                          // 快速失败模式
                          .failFast(true)
                          .buildValidatorFactory()
                          .getValidator();
              }
          }

          2.在Service方法中调用hibernate的validate方法对参数进行校验

          @Service
          @Slf4j
          public class UserService {
              @Autowired
              private Validator validator;
              public boolean editUser(UserVo user) {
                  Set> validateSet = validator.validate(user);
                  if (CollectionUtils.isNotEmpty(validateSet)) {
                      StringBuilder errorMessage = new StringBuilder();
                      for (ConstraintViolation violation : validateSet) {
                          errorMessage.append("[").append(violation.getPropertyPath().toString()).append("]")
                                  .append(violation.getMessage()).append(";");
                      }
                      throw new ValidationException(errorMessage.toString());
                  }
                  return Boolean.TRUE;
              }
          }

          二、进阶用法

          • 自定义验证注解

            jakarta.validation-api和hibernate-validator包中内置的注解有些场景可能不支持,比如添加用户时,需要校验用户名是否重复,这时可以通过自定义注解来实现

            1.首先自定义注解

            @Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
            @Retention(RUNTIME)
            @Documented
            @Repeatable(UniqueName.List.class)
            @Constraint(validatedBy = {UniqueNameValidator.class})
            public @interface UniqueName {
                String message() default "用户名重复了";
                // 分组
                Class[] groups() default {};
                
                Class[] payload() default {};
                @Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
                @Retention(RUNTIME)
                @Documented
                public @interface List {
                    UniqueName[] value();
                }
            }

            2.接着给自定义注解添加验证器

            • 实现ConstraintValidator接口,并指定自定义注解和验证的数据类型
            • 重写isValid方法,实现验证逻辑
            @Component
            public class UniqueNameValidator implements ConstraintValidator {
                @Autowired
                private UserService userService;
                @Override
                public boolean isValid(String name, ConstraintValidatorContext context) {
                    if (StringUtils.isBlank(name)) {
                        return true;
                    }
                    UserVo user = userService.getByName(name);
                    if (user == null) {
                        return true;
                    }
                    return false;
                }
            }

            3.使用自定义注解

            @Data
            public class UserVo implements Serializable {
                @UniqueName
                private String name;
            }
            • 多属性联合校验

              当一个字段的校验依赖另一个字段的值时,需要用到多属性联合校验,或者叫分组校验。举个例子,某个系统提交用户信息时需要做校验,当性别为女时,照片信息不能为空。这时,照片信息能否为空,依赖于性别的取值。hibernate-validator提供了DefaultGroupSequenceProvider接口供我们自定义分组,具体使用如下:

              1.首先定义两个组,Boy和Girl

              public interface Boy {
              }
              public interface Girl {
              }

              2.分组逻辑实现,当性别为女时,将用户分到Girl组

              public class CustomGroupSequenceProvider implements DefaultGroupSequenceProvider {
                  @Override
                  public List> getValidationGroups(UserVo user) {
                      List> defaultGroupSequence = new ArrayList<>();
                      defaultGroupSequence.add(UserVo.class);
                      if (user != null) {
                          String sex = user.getSex();
                          if ("女".equals(sex)) {
                              defaultGroupSequence.add(Girl.class);
                          }
                      }
                      return defaultGroupSequence;
                  }
              }

              3.使用分组校验photoList字段

              • 实体类上添加@GroupSequenceProvider(CustomSequenceProvider.class)注解
              • 字段上添加@NotEmpty(message = "性别为女时照片不能为空", groups = {Girl.class})注解
              @Data
              @GroupSequenceProvider(CustomSequenceProvider.class)
              public class UserVo implements Serializable {
                  @NotBlank(message = "性别不能为空")
                  private String sex;
                  @NotEmpty(message = "性别为女时照片不能为空", groups = {Girl.class})
                  private List photoList;
              }
              • 嵌套校验

                当VO对象中存在对象属性需要校验时,可以使用嵌套校验,

                1.在对象属性上加@Valid注解

                @Data
                public class UserVo implements Serializable {
                    @Valid
                    @NotNull(message = "地址不能为空")
                    private Address address;
                }

                2.然后在内嵌对象中声明约束注解

                @Data
                public class Address implements Serializable {
                    @NotBlank(message = "地址名称不能为空")
                    private String name;
                    private String longitude;
                    private String latitude;
                }

                三、实现原理

                • @RequestBody参数校验实现原理

                  所有@RequestBody注释的参数都要经过RequestResponseBodyMethodProcessor类处理,该类主要用于解析@RequestBody注释方法的参数,以及处理@ResponseBody注释方法的返回值。其中,resolveArgument()方法是解析@RequestBody注释参数的入口

                  public class RequestResponseBodyMethodProcessor extends AbstractMessageConverterMethodProcessor {
                      
                      @Override
                      public Object resolveArgument(MethodParameter parameter, @Nullable ModelAndViewContainer mavContainer,
                                                    NativeWebRequest webRequest, @Nullable WebDataBinderFactory binderFactory) throws Exception {
                          parameter = parameter.nestedIfOptional();
                          Object arg = readWithMessageConverters(webRequest, parameter, parameter.getNestedGenericParameterType());
                          String name = Conventions.getVariableNameForParameter(parameter);
                          if (binderFactory != null) {
                              WebDataBinder binder = binderFactory.createBinder(webRequest, arg, name);
                              if (arg != null) {
                                  validateIfApplicable(binder, parameter);
                                  if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) {
                                      throw new MethodArgumentNotValidException(parameter, binder.getBindingResult());
                                  }
                              }
                              if (mavContainer != null) {
                                  mavContainer.addAttribute(BindingResult.MODEL_KEY_PREFIX + name, binder.getBindingResult());
                              }
                          }
                          return adaptArgumentIfNecessary(arg, parameter);
                      }
                  }

                  resolveArgument方法中的validateIfApplicable(binder, parameter)会对带有@valid/@validate注解的参数进行校验

                  protected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) {
                  		Annotation[] annotations = parameter.getParameterAnnotations();
                  		for (Annotation ann : annotations) {
                  			Object[] validationHints = ValidationAnnotationUtils.determineValidationHints(ann);
                  			if (validationHints != null) {
                  				binder.validate(validationHints);
                  				break;
                  			}
                  		}
                  	}
                  //会对@Validated注解或者@Valid开头的注解进行校验
                  public static Object[] determineValidationHints(Annotation ann) {
                  		Class annotationType = ann.annotationType();
                  		String annotationName = annotationType.getName();
                  		if ("javax.validation.Valid".equals(annotationName)) {
                  			return EMPTY_OBJECT_ARRAY;
                  		}
                  		Validated validatedAnn = AnnotationUtils.getAnnotation(ann, Validated.class);
                  		if (validatedAnn != null) {
                  			Object hints = validatedAnn.value();
                  			return convertValidationHints(hints);
                  		}
                  		if (annotationType.getSimpleName().startsWith("Valid")) {
                  			Object hints = AnnotationUtils.getValue(ann);
                  			return convertValidationHints(hints);
                  		}
                  		return null;
                  	}

                  Spring通过一圈适配转换后,会把参数校验逻辑落到hibernate-validator中,在ValidatorImpl#validate(T object, Class... groups)中做校验

                  public class ValidatorImpl implements Validator, ExecutableValidator {
                      @Override
                      public final  Set> validate(T object, Class... groups) {
                          Contracts.assertNotNull( object, MESSAGES.validatedObjectMustNotBeNull() );
                          sanityCheckGroups( groups );
                          @SuppressWarnings("unchecked")
                          Class rootBeanClass = (Class) object.getClass();
                          BeanMetaData rootBeanMetaData = beanMetaDataManager.getBeanMetaData( rootBeanClass );
                          if ( !rootBeanMetaData.hasConstraints() ) {
                              return Collections.emptySet();
                          }
                          BaseBeanValidationContext
                                  validationContext = getValidationContextBuilder().forValidate( rootBeanClass, rootBeanMetaData, object );
                          ValidationOrder validationOrder = determineGroupValidationOrder( groups );
                          BeanValueContext valueContext = ValueContexts.getLocalExecutionContextForBean(
                                  validatorScopedContext.getParameterNameProvider(),
                                  object,
                                  validationContext.getRootBeanMetaData(),
                                  PathImpl.createRootPath()
                          );
                          return validateInContext( validationContext, valueContext, validationOrder );
                      }
                      
                  }

                  具体校验过程在validateConstraintsForSingleDefaultGroupElement方法中,它会遍历@NotNull、@NotBlank、@Email这些约束注解,看参数是否符合限制

                  public class ValidatorImpl implements Validator, ExecutableValidator {
                      private  boolean validateConstraintsForSingleDefaultGroupElement(BaseBeanValidationContext validationContext, ValueContext valueContext, final Map, Class> validatedInterfaces,
                                           Class clazz, Set> metaConstraints, Group defaultSequenceMember) {
                          boolean validationSuccessful = true;
                          valueContext.setCurrentGroup( defaultSequenceMember.getDefiningClass() );
                          //metaConstraints是@NotNull、@NotBlank、@Email这些约束注解的集合,一个个验证
                          for ( MetaConstraint metaConstraint : metaConstraints ) {
                              final Class declaringClass = metaConstraint.getLocation().getDeclaringClass();
                              if ( declaringClass.isInterface() ) {
                                  Class validatedForClass = validatedInterfaces.get( declaringClass );
                                  if ( validatedForClass != null && !validatedForClass.equals( clazz ) ) {
                                      continue;
                                  }
                                  validatedInterfaces.put( declaringClass, clazz );
                              }
                              boolean tmp = validateMetaConstraint( validationContext, valueContext, valueContext.getCurrentBean(), metaConstraint );
                              if ( shouldFailFast( validationContext ) ) {
                                  return false;
                              }
                              validationSuccessful = validationSuccessful && tmp;
                          }
                          return validationSuccessful;
                      }
                  }

                  validator.isValid()是所有验证器的入口,包括hibernate-validator内置的,以及自定义的

                  public abstract class ConstraintTree {
                      protected final  Optional validateSingleConstraint(
                              ValueContext valueContext,
                              ConstraintValidatorContextImpl constraintValidatorContext,
                              ConstraintValidator validator) {
                          boolean isValid;
                          try {
                              @SuppressWarnings("unchecked")
                              V validatedValue = (V) valueContext.getCurrentValidatedValue();
                              isValid = validator.isValid( validatedValue, constraintValidatorContext );
                          }
                          catch (RuntimeException e) {
                              if ( e instanceof ConstraintDeclarationException ) {
                                  throw e;
                              }
                              throw LOG.getExceptionDuringIsValidCallException( e );
                          }
                          if ( !isValid ) {
                              //We do not add these violations yet, since we don't know how they are
                              //going to influence the final boolean evaluation
                              return Optional.of( constraintValidatorContext );
                          }
                          return Optional.empty();
                      }
                  }

                  以下是@NotBlank约束注解验证器的具体实现

                  public class NotBlankValidator implements ConstraintValidator {
                  	/**
                  	 * Checks that the character sequence is not {@code null} nor empty after removing any leading or trailing
                  	 * whitespace.
                  	 *
                  	 * @param charSequence the character sequence to validate
                  	 * @param constraintValidatorContext context in which the constraint is evaluated
                  	 * @return returns {@code true} if the string is not {@code null} and the length of the trimmed
                  	 * {@code charSequence} is strictly superior to 0, {@code false} otherwise
                  	 */
                  	@Override
                  	public boolean isValid(CharSequence charSequence, ConstraintValidatorContext constraintValidatorContext) {
                  		if ( charSequence == null ) {
                  			return false;
                  		}
                  		return charSequence.toString().trim().length() > 0;
                  	}
                  }
                  • @RequestParam/@PathVariable参数校验实现原理

                    该方式本质是通过类上加@Validated注解,方法参数前加@NotBlank等约束注解来实现的。底层使用的是Spring AOP,具体来说是通过MethodValidationPostProcessor动态注册AOP切面,然后使用MethodValidationInterceptor对切点方法织入增强。以下是容器启动时初始化@Validated切点,以及MethodValidationInterceptor增强

                    public class MethodValidationPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessor
                            implements InitializingBean {
                        private Class validatedAnnotationType = Validated.class;
                        @Nullable
                        private Validator validator;
                        @Override
                        public void afterPropertiesSet() {
                            Pointcut pointcut = new AnnotationMatchingPointcut(this.validatedAnnotationType, true);
                            this.advisor = new DefaultPointcutAdvisor(pointcut, createMethodValidationAdvice(this.validator));
                        }
                        
                        protected Advice createMethodValidationAdvice(@Nullable Validator validator) {
                            return (validator != null ? new MethodValidationInterceptor(validator) : new MethodValidationInterceptor());
                        }
                    }

                    具体增强逻辑在MethodValidationInterceptor中

                    public class MethodValidationInterceptor implements MethodInterceptor {
                        @Override
                        public Object invoke(MethodInvocation invocation) throws Throwable {
                            // Avoid Validator invocation on FactoryBean.getObjectType/isSingleton
                            if (isFactoryBeanMetadataMethod(invocation.getMethod())) {
                                return invocation.proceed();
                            }
                            Class[] groups = determineValidationGroups(invocation);
                            // Standard Bean Validation 1.1 API
                            ExecutableValidator execVal = this.validator.forExecutables();
                            Method methodToValidate = invocation.getMethod();
                            Set> result;
                            try {
                                result = execVal.validateParameters(
                                        invocation.getThis(), methodToValidate, invocation.getArguments(), groups);
                            }
                            catch (IllegalArgumentException ex) {
                                // Probably a generic type mismatch between interface and impl as reported in SPR-12237 / HV-1011
                                // Let's try to find the bridged method on the implementation class...
                                methodToValidate = BridgeMethodResolver.findBridgedMethod(
                                        ClassUtils.getMostSpecificMethod(invocation.getMethod(), invocation.getThis().getClass()));
                                result = execVal.validateParameters(
                                        invocation.getThis(), methodToValidate, invocation.getArguments(), groups);
                            }
                            if (!result.isEmpty()) {
                                throw new ConstraintViolationException(result);
                            }
                            Object returnValue = invocation.proceed();
                            result = execVal.validateReturnValue(invocation.getThis(), methodToValidate, returnValue, groups);
                            if (!result.isEmpty()) {
                                throw new ConstraintViolationException(result);
                            }
                            return returnValue;
                        }
                    }

                    其中execVal.validateParameters()方法是用来做参数校验的,最终会进到hibernate-validator中。后面的逻辑跟上面类似,此处就不再赘述

                    public class ValidatorImpl implements Validator, ExecutableValidator {
                        @Override
                    	public  Set> validateParameters(T object, Method method, Object[] parameterValues, Class... groups) {
                    		Contracts.assertNotNull( object, MESSAGES.validatedObjectMustNotBeNull() );
                    		Contracts.assertNotNull( method, MESSAGES.validatedMethodMustNotBeNull() );
                    		Contracts.assertNotNull( parameterValues, MESSAGES.validatedParameterArrayMustNotBeNull() );
                    		return validateParameters( object, (Executable) method, parameterValues, groups );
                    	}
                    }

                    项目源码

                    https://github.com/layfoundation/spring-param-validate

                    附件

                     jakarta.validation-api(版本2.0.1)所有注解

                    注解说明
                    @AssertFalse验证 boolean 类型值是否为 false
                    @AssertTrue验证 boolean 类型值是否为 true
                    @DecimalMax(value)验证数字的大小是否小于等于指定的值,小数存在精度
                    @DecimalMin(value)验证数字的大小是否大于等于指定的值,小数存在精度
                    @Digits(integer, fraction)验证数字是否符合指定格式
                    @Email验证字符串是否符合电子邮件地址的格式
                    @Future验证一个日期或时间是否在当前时间之后
                    @FutureOrPresent验证一个日期或时间是否在当前时间之后或等于当前时间
                    @Max(value)验证数字的大小是否小于等于指定的值
                    @Min(value)验证数字的大小是否大于等于指定的值
                    @Negative验证数字是否是负整数,0无效
                    @NegativeOrZero验证数字是否是负整数
                    @NotBlank验证字符串不能为空null或"",只能用于字符串验证
                    @NotEmpty验证对象不得为空,可用于Map和数组
                    @NotNull验证对象不为 null
                    @Null验证对象必须为 null
                    @past验证一个日期或时间是否在当前时间之前。
                    @PastOrPresent验证一个日期或时间是否在当前时间之前或等于当前时间。
                    @Pattern(value)验证字符串是否符合正则表达式的规则
                    @Positive验证数字是否是正整数,0无效
                    @PositiveOrZero验证数字是否是正整数
                    @Size(max, min)验证对象(字符串、集合、数组)长度是否在指定范围之内

                    hibernate-validator(版本6.0.17.Final)补充的常用注解

                    注解说明
                    @Length被注释的字符串的大小必须在指定的范围内
                    @Range被注释的元素必须在合适的范围内
                    @SafeHtml被注释的元素必须是安全Html
                    @URL被注释的元素必须是有效URL