查看原文
其他

如何正确使用 Bean Validation 进行数据校验|得物技术

洛峰 得物技术 2024-01-08

目录

一、背景

二、Bean Validation 简介

    1. 什么是 JSR ?

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

    3. Spring Validation 的产生背景

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

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

        3.3 @Validated 的内置自动化校验

        3.4 @Validated 和 @Valid 的区别

三、Bean Validation 的使用

    1. 引入 POM

    2. Bean 层面校验

        2.1 变量层面约束

        2.2 属性层面约束

        2.3 容器元素约束

        2.4 类层面约束

        2.5 嵌套约束

        2.6 手工验证 Bean 约束

    3. 方法层面校验

        3.1 函数参数约束

        3.2 函数返回值约束

        3.3 嵌套约束

        3.4 在继承中方法约束

        3.5 手工验证方法约束

    4. 分组校验

    5. 自定义校验

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

    1. Validation 的常见误解

        1.1 约束校验生效

        1.2 约束校验不生效

    2. Controller 自动执行约束校验原理

    3. Service 自动执行约束校验原理

五、总结

背景

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

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

在 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)

常用的校验注解补充:

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

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



往期回顾


1. 客服发送一条消息背后的技术和思考|得物技术
2. 硬核 JVM 压缩指针详解|得物技术
3. 得物 Redis 设计与实践
4. 再玩玩B端搭建|得物技术
5. 实时数仓投放主备链路Diff测试工具落地实践|得物技术
6. ZGC关键技术分析|得物技术



*文/洛峰

关注得物技术,每周一、三、五更新技术干货
要是觉得文章对你有帮助的话,欢迎评论转发点赞~
未经得物技术许可严禁转载,否则依法追究法律责任。


扫码添加小助手微信

如有任何疑问,或想要了解更多技术资讯,请添加小助手微信:

继续滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存