SpringBoot参数校验及原理解析

目录

前言

一、基本用法

@RequestBody参数校验

@RequestParam/@PathVariable参数校验

编程式校验

二、进阶用法

自定义验证注解

多属性联合校验

嵌套校验

三、实现原理

@RequestBody参数校验实现原理

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

项目源码

附件


前言

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

一、基本用法

总共三种方式:

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

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

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
  • @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<String> photoList;
}

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

@Slf4j
@RestController
public class UserController {
    @ApiOperation("保存用户")
    @PostMapping("/save/user")
    public Result<Boolean> 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<List<UserVo>> 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<UserVo> 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<ConstraintViolation<UserVo>> validateSet = validator.validate(user);
        if (CollectionUtils.isNotEmpty(validateSet)) {
            StringBuilder errorMessage = new StringBuilder();
            for (ConstraintViolation<UserVo> 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<? extends Payload>[] payload() default {};

    @Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER})
    @Retention(RUNTIME)
    @Documented
    public @interface List {
        UniqueName[] value();
    }
}

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

  • 实现ConstraintValidator接口,并指定自定义注解<UniqueName>和验证的数据类型 <String>
  • 重写isValid方法,实现验证逻辑
@Component
public class UniqueNameValidator implements ConstraintValidator<UniqueName, String> {

    @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<UserVo> {

    @Override
    public List<Class<?>> getValidationGroups(UserVo user) {
        List<Class<?>> 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<String> 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<? extends Annotation> 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 <T> Set<ConstraintViolation<T>> validate(T object, Class<?>... groups) {
        Contracts.assertNotNull( object, MESSAGES.validatedObjectMustNotBeNull() );
        sanityCheckGroups( groups );

        @SuppressWarnings("unchecked")
        Class<T> rootBeanClass = (Class<T>) object.getClass();
        BeanMetaData<T> rootBeanMetaData = beanMetaDataManager.getBeanMetaData( rootBeanClass );

        if ( !rootBeanMetaData.hasConstraints() ) {
            return Collections.emptySet();
        }

        BaseBeanValidationContext<T>
                validationContext = getValidationContextBuilder().forValidate( rootBeanClass, rootBeanMetaData, object );

        ValidationOrder validationOrder = determineGroupValidationOrder( groups );
        BeanValueContext<?, Object> 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 <U> boolean validateConstraintsForSingleDefaultGroupElement(BaseBeanValidationContext<?> validationContext, ValueContext<U, Object> valueContext, final Map<Class<?>, Class<?>> validatedInterfaces,
                                                                        Class<? super U> clazz, Set<MetaConstraint<?>> 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<A extends Annotation> {

    protected final <V> Optional<ConstraintValidatorContextImpl> validateSingleConstraint(
            ValueContext<?, ?> valueContext,
            ConstraintValidatorContextImpl constraintValidatorContext,
            ConstraintValidator<A, V> 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<NotBlank, CharSequence> {

	/**
	 * 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<? extends Annotation> 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<ConstraintViolation<Object>> 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 <T> Set<ConstraintViolation<T>> 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

  • 6
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值