写点什么

如何正确使用 Bean Validation 进行数据校验

作者:得物技术
  • 2024-01-24
    上海
  • 本文字数:10242 字

    阅读完需:约 34 分钟

如何正确使用 Bean Validation 进行数据校验

一、背景


在前后端开发过程中,数据校验是一项必须且常见的事,从展示层、业务逻辑层到持久层几乎每层都需要数据校验。如果在每一层中手工实现验证逻辑,既耗时又容易出错。



为了避免重复这些验证,通常的做法是将验证逻辑直接捆绑到领域模型中,通过元数据(默认是注解)去描述模型, 生成校验代码,从而使校验从业务逻辑中剥离,提升开发效率,使开发者更专注业务逻辑本身。



在 Spring 中,目前支持两种不同的验证方法:Spring Validation 和 JSR-303 Bean Validation,即 @Validated(org . springframework.validation.annotation.Validated)和 @Valid(javax.validation.Valid)。两者都可以通过定义模型的约束来进行数据校验,虽然两者使用类似,在很多场景下也可以相互替换,但实际上却完全不同,这些差别长久以来对我们日常使用产生了较大疑惑,本文主要梳理其中的差别、介绍 Validation 的使用及其实现原理,帮助大家在实践过程中更好使用 Validation 功能。

二、Bean Validation 简介

什么是 JSR?


JSR 是 Java Specification Requests 的缩写,意思是 Java 规范提案。是指向 JCP(Java Community Process) 提出新增一个标准化技术规范的正式请求,以向 Java 平台增添新的 API 和服务。JSR 已成为 Java 界的一个重要标准。

JSR-303 定义的是什么标准?


JSR-303 是用于 Bean Validation 的 Java API 规范,该规范是 Jakarta EE and JavaSE 的一部分,Hibernate Validator 是 Bean Validation 的参考实现。Hibernate Validator 提供了 JSR 303 规范中所有内置 Constraint 的实现,除此之外还有一些附加的 Constraint。(最新的为 JSR-380 为 Bean Validation 3.0) 453.png


常用的校验注解补充:


@NotBlank 检查约束字符串是不是 Null 还有被 Trim 的长度是否大于,只对字符串,且会去掉前后空格。


@NotEmpty 检查约束元素是否为 Null 或者是 Empty。


@Length 被检查的字符串长度是否在指定的范围内。


@Email 验证是否是邮件地址,如果为 Null,不进行验证,算通过验证。


@Range 数值返回校验。


@IdentityCardNumber 校验身份证信息。


@UniqueElements 集合唯一性校验。


@URL 验证是否是一个 URL 地址。

Spring Validation 的产生背景


上文提到 Spring 支持两种不同的验证方法:Spring Validation 和 JSR-303 Bean Validation(下文使用 @Validated 和 @Valid 替代)。


为什么会同时存在两种方式?


Spring 增加 @Validated 是为了支持分组校验,即同一个对象在不同的场景下使用不同的校验形式。比如有两个步骤用于提交用户资料,后端复用的是同一个对象,第一步验证姓名,电子邮件等字段,然后在后续步骤中的其他字段中。这时候分组校验就会发挥作用。


为什么不合入到 JSR-303 中?


之所以没有将它添加到 @Valid 注释中,是因为它是使用 Java 社区过程(JSR-303)标准化的,这需要时间,而 Spring 开发者想让人们更快地使用这个功能。


@Validated 的内置自动化校验


Spring 增加 @Validated 还有另一层原因,Bean Validation 的标准做法是在程序中手工调用 Validator 或者 ExecutableValidator 进行校验,为了实现自动化,通常通过 AOP、代理等方法拦截技术来调用。而 @Validated 注解就是为了配合 Spring 进行 AOP 拦截,从而实现 Bean Validation 的自动化执行。


@Validated 和 @Valid 的区别


@Valid 是 JSR 标准 API,@Validated 扩展了 @Valid 支持分组校验且能作为 SpringBean 的 AOP 注解,在 SpringBean 初始化时实现方法层面的自动校验。最终还是使用了 JSR API 进行约束校验。

三、Bean Validation 的使用

引入 POM


// 正常应该引入 hibernate-validator,是 JSR 的参考实现


<dependency>

<groupId>org.hibernate.validator</groupId>

<artifactId>hibernate-validator</artifactId>

</dependency>

// Spring 在 stark 中集成了,所以 hibernate-validator 可以不用引入

<dependency>

<groupId>org.springframework.boot</groupId>

<artifactId>spring-boot-starter-validation</artifactId>

</dependency>

// 正常应该引入hibernate-validator,是JSR的参考实现
<dependency> <groupId>org.hibernate.validator</groupId> <artifactId>hibernate-validator</artifactId></dependency>// Spring在stark中集成了,所以hibernate-validator可以不用引入<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId></dependency>
复制代码


Bean 层面校验

  • 变量层面约束

public class EntryApplicationInfoCmd {    /**     * 用户ID     */    @NotNull(message = "用户ID不为空")    private Long userId;
/** * 证件类型 */ @NotEmpty(message = "证件类型不为空") private String certType;}
复制代码


  • 属性层面约束


主要为了限制 Setter 方法的只读属性。属性的 Getter 方法打注释,而不是 Setter。

public class EntryApplicationInfoCmd {    public EntryApplicationInfoCmd(Long userId, String certType) {            this.userId = userId;            this.certType = certType;        }    /**     * 用户ID     */    private Long userId;
/** * 证件类型 */ private String certType; @NotNull public String getUserId() { return userId; } @NotEmpty public String getCertType() { return userId; }}
复制代码
  • 容器元素约束

public class EntryApplicationInfoCmd {    ...    List<@NotEmpty Long> categoryList;}
复制代码
  • 类层面约束

@CategoryBrandNotEmptyRecord 是自定义类层面的约束,也可以约束在构造函数上。

@CategoryBrandNotEmptyRecordpublic class EntryApplicationInfoCmd {    /**     * 用户ID     */    @NotNull(message = "用户ID不为空")    private Long userId;           List<@NotEmpty Long> categoryList;}
复制代码
  • 嵌套约束

嵌套对象需要额外使用 @Valid 进行标注(@Validate 不支持,为什么?请看产生的背景)。

public class EntryApplicationInfoCmd {    /**     *  主营品牌     */      @Valid    @NotNull     private MainBrandImagesCmd mainBrandImage;}
public class MainBrandImagesCmd { /** * 品牌名称 */ @NotEmpty private String brandName;;}
复制代码
  • 手工验证 Bean 约束

// 获取校验器ValidatorFactory factory = Validation.buildDefaultValidatorFactory();Validator validator = factory.getValidator();
// 进行bean层面校验Set<ConstraintViolation<User>> violations = validator.validate(EntryApplicationInfoCmd);// 打印校验信息for (ConstraintViolation<User> violation : violations) { log.error(violation.getMessage()); }
复制代码

方法层面校验

  • 函数参数约束

public class MerchantMainApplyQueryService {    MainApplyDetailResp detail(@NotNull(message = "申请单号不能为空") Long id) {        ...    }}
复制代码
  • 函数返回值约束

public class MerchantMainApplyQueryService {    @NotNull    @Size(min = 1)    public List<@NotNull MainApplyStandDepositResp> getStanderNewDeposit(Long id) {        //...    }}
复制代码
  • 嵌套约束

嵌套对象需要额外使用 @Valid 进行标注(@Validate 不支持)。

public class MerchantMainApplyQueryService {    public NewEntryBrandRuleCheckApiResp brandRuleCheck(@Valid @NotNull NewEntryBrandRuleCheckRequest request) {        ...    }}
public class NewEntryBrandRuleCheckRequest { @NotNull(message = "一级类目不能为空") private Long level1CategoryId;}
复制代码
  • 在继承中方法约束

Validation 的设计需要遵循里氏替换原则,无论何时使用类型 T,也可以使用 T 的子类型 S,而不改变程序的行为。即子类不能增加约束也不能减弱约束。


子类方法参数的约束与父类行为不一致(++错误例子++):

// 继承的方法参数约束不能改变,否则会导致父类子类行为不一致public interface Vehicle {
void drive(@Max(75) int speedInMph);}
public class Car implements Vehicle {
@Override public void drive(@Max(55) int speedInMph) { //... }}
复制代码

方法的返回值可以增加约束(++正确例子++):

// 继承的方法返回值可以增加约束public interface Vehicle {
@NotNull List<Person> getPassengers();}
public class Car implements Vehicle {
@Override @Size(min = 1) public List<Person> getPassengers() { //... return null; }}
复制代码
  • 手工验证方法约束


方法层面校验使用的是 ExecutableValidator。

// 获取校验器ValidatorFactory factory = Validation.buildDefaultValidatorFactory();Validator executableValidator = factory.getValidator().forExecutables();
// 进行方法层面校验MerchantMainApplyQueryService service = getService();Method method = MerchantMainApplyQueryService.class.getMethod( "getStanderNewDeposit", int.class );Object[] parameterValues = { 80 };Set<ConstraintViolation<Car>> violations = executableValidator.validateParameters( service, method, parameterValues);// 打印校验信息for (ConstraintViolation<User> violation : violations) { log.error(violation.getMessage()); }
复制代码

分组校验

不同场景复用一个 Model,采用不一样的校验方式。

public class NewEntryMainApplyRequest {    @NotNull(message = "一级类目不能为空")    private Long level1CategoryId;        @NotNull(message = "申请单ID不能为空", group = UpdateMerchantMainApplyCmd.class)    private Long applyId;        @NotEmpty(message = "审批人不能为空", group = AddMerchantMainApplyCmd.class)    private String operator;}
// 校验分组UpdateMerchantMainApplyCmd.classNewEntryMainApplyRequest request1 = new NewEntryMainApplyRequest( 29, null, "aaa");Set<ConstraintViolation<NewEntryMainApplyRequest>> constraintViolations = validator.validate( request1, UpdateMerchantMainApplyCmd.class );assertEquals("申请单ID不能为空", constraintViolations.iterator().next().getMessage());
// 校验分组AddMerchantMainApplyCmd.classNewEntryMainApplyRequest request2 = new NewEntryMainApplyRequest( 29, "12345", "");Set<ConstraintViolation<NewEntryMainApplyRequest>> constraintViolations = validator.validate( request2, AddMerchantMainApplyCmd.class );assertEquals("审批人不能为空", constraintViolations.iterator().next().getMessage());
复制代码

自定义校验

自定义注解:

@Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER})@Retention(RetentionPolicy.RUNTIME)@Constraint(validatedBy = MyConstraintValidator.class)public @interface MyConstraint {    String message();
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};}
复制代码

自定义校验器:

public class MyConstraintValidator implements ConstraintValidator<MyConstraint, Object> {    @Override    public void initialize(MyConstraint constraintAnnotation) {        }        @Override    public isValid isValid(Object value, ConstraintValidatorContext context) {         String name = (String)value;         if("xxxx".equals(name)) {             return true;         }                  return false;    }}
复制代码

使用自定义约束:

public class Test {    @MyConstraint(message = "test")    String name;}
复制代码

四、Bean Validation 自动执行以及原理


上述 2.6 和 3.5 分别实现了 Bean 和 Method 层面的约束校验,但是每次都主动调用比较繁琐,因此 Spring 在 @RestController 的 @RequestBody 注解中内置了一些自动化校验以及在 Bean 初始化中集成了 AOP 来简化编码。

Validation 的常见误解


最常见的应该就是在 RestController 中,校验 @RequestBody 指定参数的约束,使用 @Validated 或者 @Valid(++该场景下两者等价++)进行约束校验,以至于大部分人理解的 Validation 只要打个注解就可以生效,实际上这只是一种特例。很多人在使用过程中经常遇到约束校验不生效。


约束校验生效


Spring-mvc 中在 @RequestBody 后面加 @Validated、@Valid 约束即可生效。

@RestController@RequestMapping("/biz/merchant/enter")public class MerchantEnterController {    @PostMapping("/application")    // 使用@Validated    public HttpMessageResult addOrUpdateV1(@RequestBody @Validated MerchantEnterApplicationReq req){        ...    }    // 使用@Valid    @PostMapping("/application2")    public HttpMessageResult addOrUpdate2(@RequestBody @Valid MerchantEnterApplicationReq req){        ...    }}
复制代码

约束校验不生效


然而下面这个约束其实是不生效的,想要生效得在 MerchantEntryServiceImpl 类目加上 @Validated 注解。

// @Validated 不加不生效@Servicepublic class MerchantEntryService {    public Boolean applicationAddOrUpdate(@Validated MerchantEnterApplicationReq req) {        ...    }        public Boolean applicationAddOrUpdate2(@Valid MerchantEnterApplicationReq req) {        ...    }}
复制代码

那么究竟为什么会出现这种情况呢,这就需要对 Spring Validation 的注解执行原理有一定的了解。

Controller 自动执行约束校验原理


在 Spring-mvc 中,有很多拦截器对 Http 请求的出入参进行解析和转换,Validation 解析和执行也是类似,其中 RequestResponseBodyMethodProcessor 是用于解析 @RequestBody 标注的参数以及处理 @ResponseBody 标注方法的返回值的。

public class RequestResponseBodyMethodProcessor extends AbstractMessageConverterMethodProcessor {
@Override public boolean supportsParameter(MethodParameter parameter) { return parameter.hasParameterAnnotation(RequestBody.class); } // 类上或者方法上标注了@ResponseBody注解都行 @Override public boolean supportsReturnType(MethodParameter returnType) { return (AnnotatedElementUtils.hasAnnotation(returnType.getContainingClass(), ResponseBody.class) || returnType.hasMethodAnnotation(ResponseBody.class)); } // 这是处理入参封装校验的入口 @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);
// 只有存在binderFactory才会去完成自动的绑定、校验~ if (binderFactory != null) { WebDataBinder binder = binderFactory.createBinder(webRequest, arg, name); if (arg != null) { // 这里完成数据绑定+数据校验~~~~~(绑定的错误和校验的错误都会放进Errors里) validateIfApplicable(binder, parameter);
// 若有错误消息hasErrors(),并且仅跟着的一个参数不是Errors类型,Spring MVC会主动给你抛出MethodArgumentNotValidException异常 if (binder.getBindingResult().hasErrors() && isBindExceptionRequired(binder, parameter)) { throw new MethodArgumentNotValidException(parameter, binder.getBindingResult()); } } // 把错误消息放进去 证明已经校验出错误了~~~ // 后续逻辑会判断MODEL_KEY_PREFIX这个key的~~~~ if (mavContainer != null) { mavContainer.addAttribute(BindingResult.MODEL_KEY_PREFIX + name, binder.getBindingResult()); } }
return adaptArgumentIfNecessary(arg, parameter); } ...}
复制代码

约束的校验逻辑是在 RequestResponseBodyMethodProcessor.validateIfApplicable 实现的,这里同时兼容了 @Validated 和 @Valid,所以该场景下两者是等价的。

// 校验,如果合适的话。使用WebDataBinder,失败信息最终也都是放在它身上~  // 入参:MethodParameter parameterprotected void validateIfApplicable(WebDataBinder binder, MethodParameter parameter) {    // 拿到标注在此参数上的所有注解们(比如此处有@Valid和@RequestBody两个注解)    Annotation[] annotations = parameter.getParameterAnnotations();    for (Annotation ann : annotations) {        // 先看看有木有@Validated        Validated validatedAnn = AnnotationUtils.getAnnotation(ann, Validated.class);
// 这个里的判断是关键:可以看到标注了@Validated注解 或者注解名是以Valid打头的 都会有效哦 //注意:这里可没说必须是@Valid注解。实际上你自定义注解,名称只要一Valid开头都成~~~~~ if (validatedAnn != null || ann.annotationType().getSimpleName().startsWith("Valid")) { // 拿到分组group后,调用binder的validate()进行校验~~~~ // 可以看到:拿到一个合适的注解后,立马就break了~~~ // 所以若你两个主机都标注@Validated和@Valid,效果是一样滴~ Object hints = (validatedAnn != null ? validatedAnn.value() : AnnotationUtils.getValue(ann)); Object[] validationHints = (hints instanceof Object[] ? (Object[]) hints : new Object[] {hints}); binder.validate(validationHints); break; } }
复制代码

binder.validate() 的实现中使用的 org.springframework.validation.Validator 的接口,该接口的实现为 SpringValidatorAdapter。

public void validate(Object... validationHints) {    Object target = getTarget();    Assert.state(target != null, "No target to validate");    BindingResult bindingResult = getBindingResult();
for (Validator validator : getValidators()) { // 使用的org.springframework.validation.Validator,调用SpringValidatorAdapter.validate if (!ObjectUtils.isEmpty(validationHints) && validator instanceof SmartValidator) { ((SmartValidator) validator).validate(target, bindingResult, validationHints); } else if (validator != null) { validator.validate(target, bindingResult); } }}
复制代码

在 ValidatorAdapter.validate 实现中,最终调用了 javax.validation.Validator.validate,也就是说最终是调用 JSR 实现,@Validate 只是外层的包装,在这个包装中扩展的分组功能。

public class SpringValidatorAdapter {    ...    private javax.validation.Validator targetValidator;        @Override    public void validate(Object target, Errors errors) {        if (this.targetValidator != null) {           processConstraintViolations(               // 最终是调用JSR实现               this.targetValidator.validate(target), errors));        }    } }
复制代码

++targetValidator.validate 就是 javax.validation.Validator.validate 上述 2.6 Bean 层面手工验证一致。++

Service 自动执行约束校验原理


非 Controller 的 @RequestBody 注解,自动执行约束校验,是通过 MethodValidationPostProcessor 实现的,该类继承。


BeanPostProcessor, 在 Spring Bean 初始化过程中读取 @Validated 注解创建 AOP 代理(实现方式与 @Async 基本一致)。该类开头文档注解(++JSR 生效必须类层面上打上 @Spring Validated 注解++)。

/*** <p>Target classes with such annotated methods need to be annotated with Spring's* {@link Validated} annotation at the type level, for their methods to be searched for* inline constraint annotations. Validation groups can be specified through {@code @Validated}* as well. By default, JSR-303 will validate against its default group only.*/public class MethodValidationPostProcessor extends AbstractBeanFactoryAwareAdvisingPostProcessor       implements InitializingBean {
private Class<? extends Annotation> validatedAnnotationType = Validated.class;
@Nullable private Validator validator; ..... /** * 设置Validator * Set the JSR-303 Validator to delegate to for validating methods. * <p>Default is the default ValidatorFactory's default Validator. */ public void setValidator(Validator validator) { // Unwrap to the native Validator with forExecutables support if (validator instanceof LocalValidatorFactoryBean) { this.validator = ((LocalValidatorFactoryBean) validator).getValidator(); } else if (validator instanceof SpringValidatorAdapter) { this.validator = validator.unwrap(Validator.class); } else { this.validator = validator; } }
/** * Create AOP advice for method validation purposes, to be applied * with a pointcut for the specified 'validated' annotation. * @param validator the JSR-303 Validator to delegate to * @return the interceptor to use (typically, but not necessarily, * a {@link MethodValidationInterceptor} or subclass thereof) * @since 4.2 */ protected Advice createMethodValidationAdvice(@Nullable Validator validator) { // 创建了方法调用时的拦截器 return (validator != null ? new MethodValidationInterceptor(validator) : new MethodValidationInterceptor()); }
}
复制代码

真正执行方法调用时,会走到 MethodValidationInterceptor.invoke,进行约束校验。

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();        }            // Standard Bean Validation 1.1 API        ExecutableValidator execVal = this.validator.forExecutables();        ...            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);        }                ...            return returnValue;    }}
复制代码

execVal.validateParameters 就是 javax.validation.executable.ExecutableValidator.validateParameters 与上述 3.5 方法层面手工验证一致。

五、总结



参考文章:

https://docs.jboss.org/hibernate/stable/validator/reference/en-US/html_single


*文/洛峰


本文属得物技术原创,更多精彩文章请看:https://tech.dewu.com


未经得物技术许可严禁转载,否则依法追究法律责任!

发布于: 4 小时前阅读数: 3
用户头像

得物技术

关注

得物APP技术部 2019-11-13 加入

关注微信公众号「得物技术」

评论

发布
暂无评论
如何正确使用 Bean Validation 进行数据校验_数据分析_得物技术_InfoQ写作社区