每篇一句
不要像祥林嫂一样, 天天抱怨着生活, 日日思考着辞职. 得罪点说一句:"沦落" 到要跟这样的人共事工作, 难道自己身上就没有原因?
前言
本以为洋洋洒洒的把 Java/Spring 数据 (绑定) 校验这块说了这么多, 基本已经算完结了. 但今天中午一位热心小伙伴在使用 Bean Validation 做数据校验时上遇到了一个稍显特殊的 case, 由于此校验场景也比较常见, 因此便有了本文对数据校验补充.
关于 Java/Spring 中的数据校验, 我有理由坚信你肯定遇到过这样的场景需求: 在对 JavaBean 进行校验时, b 属性的校验逻辑是依赖于 a 属性的值的; 换个具象的例子说: 当且仅当属性 a 的值 = xxx 时, 属性 b 的校验逻辑才生效. 这也就是我们常说的多字段联合校验逻辑~
因为这个校验的 case 比较常见, 因此促使了我记录本文的动力, 因为它会变得有意义和有价值. 当然对此问题有的小伙伴说可以自己用 if else 来处理呀, 也不是很麻烦. 本文的目的还是希望对数据校验一以贯之的做到更清爽, 更优雅, 更好扩展而努力.
需要有一点坚持: 既然用了 Bean Validation 去简化校验, 那就 (最好) 不要用得四不像, 遇到问题就解决问题~
热心网友问题描述
为了更真实的还原问题场景, 我贴上聊天截图如下:
待校验的请求 JavaBean 如下:
校需求描述简述如下:
这位网友描述的真实生产场景问题, 这也是本文讲解的内容所在.
虽然这是在 Spring MVC 条件的下使用的数据校验, 但按照我的习惯为了更方便的说明问题, 我会把此部分功能单摘出来, 说清楚了方案和原理, 再去实施解决问题本身(文末)~
方案和原理
对于单字段的校验, 级联属性校验等, 通过阅读我的系列文章, 我有理由相信小伙伴们都能驾轻就熟了的. 本文给出一个最简单的例子简单 "复习" 一下:
- @Getter
- @Setter
- @ToString
- public class Person {
- @NotNull
- private String name;
- @NotNull
- @Range(min = 10, max = 40)
- private Integer age;
- @NotNull
- @Size(min = 3, max = 5)
- private List<String> hobbies;
- // 级联校验
- @Valid
- @NotNull
- private Child child;
- }
测试:
- public static void main(String[] args) {
- Person person = new Person();
- person.setName("fsx");
- person.setAge(5);
- person.setHobbies(Arrays.asList("足球","篮球"));
- person.setChild(new Child());
- Set<ConstraintViolation<Person>> result = Validation.buildDefaultValidatorFactory().getValidator().validate(person);
- // 对结果进行遍历输出
- result.stream().map(v -> v.getPropertyPath() + "" + v.getMessage() +": " + v.getInvalidValue()).forEach(System.out::println);
- }
运行, 打印输出:
child.name 不能为 null: null
age 需要在 10 和 40 之间: 5
hobbies 个数必须在 3 和 5 之间: [足球, 篮球]
结果符合预期,(级联)校验生效.
通过使用 @Valid 可以实现递归验证, 因此可以标注在 List 上, 对它里面的每个对象都执行校验
问题来了, 针对上例, 现在我有如下需求:
若 20 <= age <30, 那么 hobbies 的 size 需介于 1 和 2 之间
若 30 <= age < 40, 那么 hobbies 的 size 需介于 3 和 5 之间
age 其余值, hobbies 无校验逻辑
实现方案
Hibernate Validator 提供了非标准的 @GroupSequenceProvider 注解. 本功能提供根据当前对象实例的状态, 动态来决定加载那些校验组进入默认校验组.
为了实现上面的需求达到目的, 我们需要借助 Hibernate Validation 提供给我们的 DefaultGroupSequenceProvider 接口来处理.
- // 该接口定义了: 动态 Group 序列的协定
- // 要想它生效, 需要在 T 上标注 @GroupSequenceProvider 注解并且指定此类为处理类
- // 如果 `Default` 组对 T 进行验证, 则实际验证的实例将传递给此类以确定默认组序列(这句话特别重要 下面用例子解释)
- public interface DefaultGroupSequenceProvider<T> {
- // 合格方法是给 T 返回默认的组(多个). 因为默认的组是 Default 嘛~~~ 通过它可以自定指定
- // 入参 T object 允许在验证值状态的函数中动态组合默认组序列.(非常强大)
- // object 是待校验的 Bean. 它可以为 null 哦~(Validator#validateValue 的时候可以为 null)
- // 返回值表示默认组序列的 List. 它的效果同 @GroupSequence 定义组序列, 尤其是列表 List 必须包含类型 T
- List<Class<?>> getValidationGroups(T object);
- }
注意:
此接口 Hibernate 并没有提供实现
若你实现请必须提供一个空的构造函数以及保证是线程安全的
按步骤解决多字段组合验证的逻辑:
1, 自己实现 DefaultGroupSequenceProvider 接口(处理 Person 这个 Bean)
- public class PersonGroupSequenceProvider implements DefaultGroupSequenceProvider<Person> {
- @Override
- public List<Class<?>> getValidationGroups(Person bean) {
- List<Class<?>> defaultGroupSequence = new ArrayList<>();
- defaultGroupSequence.add(Person.class); // 这一步不能省, 否则 Default 分组都不会执行了, 会抛错的
- if (bean != null) { // 这块判空请务必要做
- Integer age = bean.getAge();
- System.err.println("年龄为:" + age + ", 执行对应校验逻辑");
- if (age>= 20 && age <30) {
- defaultGroupSequence.add(Person.WhenAge20And30Group.class);
- } else if (age>= 30 && age <40) {
- defaultGroupSequence.add(Person.WhenAge30And40Group.class);
- }
- }
- return defaultGroupSequence;
- }
- }
2, 在待校验的 javaBean 里使用 @GroupSequenceProvider 注解指定处理器. 并且定义好对应的校验逻辑(包括分组)
- @GroupSequenceProvider(PersonGroupSequenceProvider.class)
- @Getter
- @Setter
- @ToString
- public class Person {
- @NotNull
- private String name;
- @NotNull
- @Range(min = 10, max = 40)
- private Integer age;
- @NotNull(groups = {WhenAge20And30Group.class, WhenAge30And40Group.class})
- @Size(min = 1, max = 2, groups = WhenAge20And30Group.class)
- @Size(min = 3, max = 5, groups = WhenAge30And40Group.class)
- private List<String> hobbies;
- /**
- * 定义专属的业务逻辑分组
- */
- public interface WhenAge20And30Group {
- }
- public interface WhenAge30And40Group {
- }
- }
测试用例同上, 做出简单修改: person.setAge(25), 运行打印输出:
年龄为: 25, 执行对应校验逻辑
年龄为: 25, 执行对应校验逻辑
没有校验失败的消息(就是好消息), 符合预期.
再修改为 person.setAge(35), 再次运行打印如下:
年龄为: 35, 执行对应校验逻辑
年龄为: 35, 执行对应校验逻辑
hobbies 个数必须在 3 和 5 之间: [足球, 篮球]
校验成功, 结果符合预期.
从此案例可以看到, 通过 @GroupSequenceProvider 我完全实现了多字段组合校验的逻辑, 并且代码也非常的优雅, 可扩展, 希望此示例对你有所帮助.
本利中的 provider 处理器是 Person 专用的, 当然你可以使用 Object + 反射让它变得更为通用, 但本着职责单一原则, 我并不建议这么去做.
使用 JSR 提供的 @GroupSequence 注解控制校验顺序
上面的实现方式是最佳实践, 使用起来不难, 灵活度也非常高. 但是我们必须要明白它是 Hibernate Validation 提供的能力, 而不费 JSR 标准提供的.
@GroupSequence 它是 JSR 标准提供的注解(只是没有 provider 强大而已, 但也有很适合它的使用场景)
- // Defines group sequence. 定义组序列(序列: 顺序执行的)
- @Target({ TYPE })
- @Retention(RUNTIME)
- @Documented
- public @interface GroupSequence {
- Class<?>[] value();
- }
顾名思义, 它表示 Group 组序列. 默认情况下, 不同组别的约束验证是无序的
在某些情况下, 约束验证的顺序是非常的重要的, 比如如下两个场景:
第二个组的约束验证依赖于第一个约束执行完成的结果(必须第一个约束正确了, 第二个约束执行才有意义)
某个 Group 组的校验非常耗时, 并且会消耗比较大的 CPU / 内存. 那么我们的做法应该是把这种校验放到最后, 所以对顺序提出了要求
一个组可以定义为其他组的序列, 使用它进行验证的时候必须符合该序列规定的顺序. 在使用组序列验证的时候, 如果序列前边的组验证失败, 则后面的组将不再给予验证.
给个栗子:
- public class User {
- @NotEmpty(message = "firstname may be empty")
- private String firstname;
- @NotEmpty(message = "middlename may be empty", groups = Default.class)
- private String middlename;
- @NotEmpty(message = "lastname may be empty", groups = GroupA.class)
- private String lastname;
- @NotEmpty(message = "country may be empty", groups = GroupB.class)
- private String country;
- public interface GroupA {
- }
- public interface GroupB {
- }
- // 组序列
- @GroupSequence({Default.class, GroupA.class, GroupB.class})
- public interface Group {
- }
- }
测试:
- public static void main(String[] args) {
- User user = new User();
- // 此处指定了校验组是: User.Group.class
- Set<ConstraintViolation<User>> result = Validation.buildDefaultValidatorFactory().getValidator().validate(user, User.Group.class);
- // 对结果进行遍历输出
- result.stream().map(v -> v.getPropertyPath() + "" + v.getMessage() +": " + v.getInvalidValue()).forEach(System.out::println);
- }
运行, 控制台打印:
- middlename middlename may be empty: null
- firstname firstname may be empty: null
现象: 只有 Default 这个 Group 的校验了, 序列上其它组并没有执行校验. 更改如下:
- User user = new User();
- user.setFirstname("f");
- user.setMiddlename("s");
运行, 控制台打印:
lastname lastname may be empty: null
现象: Default 组都校验通过后, 执行了 GroupA 组的校验. 但 GroupA 组校验木有通过, GroupB 组的校验也就不执行了~
@GroupSequence 提供的组序列顺序执行以及短路能力, 在很多场景下是非常非常好用的.
针对本例的多字段组合逻辑校验, 若想借助 @GroupSequence 来完成, 相对来说还是比较困难的. 但是也并不是不能做, 此处我提供参考思路:
多字段之间的逻辑,"通信" 通过类级别的自定义校验注解来实现(至于为何必须是类级别的, 不用解释吧~)
@GroupSequence 用来控制组执行顺序(让类级别的自定义注解先执行)
增加 Bean 级别的第三属性来辅助校验~
当然喽, 在实际应用中不可能使用它来解决如题的问题, 所以我此处就不费篇幅了. 我个人建议有兴趣者可以自己动手试试, 有助于加深你对数据校验这块的理解.
这篇文章里有说过: 数据校验注解是可以标注在 Field 属性, 方法, 构造器以及 Class 类级别上的. 那么关于它们的校验顺序, 我们是可控的, 并不是网上有些文章所说的无法抉择~
说明: 顺序只能控制在分组级别, 无法控制在约束注解级别. 因为一个类内的约束(同一分组内), 它的顺序是 Set<MetaConstraint<?>> metaConstraints 来保证的, 所以可以认为同一分组内的校验器是木有执行的先后顺序的(不管是类, 属性, 方法, 构造器...)
所以网上有说: 校验顺序是先校验字段属性, 在进行类级别校验不实, 请注意辨别.
原理解析
本文中, 我借助 @GroupSequenceProvider 来解决了平时开发中多字段组合逻辑校验的痛点问题, 总的来说还是使用简单, 并且代码也够模块化, 易于维护的.
但对于上例的结果输出, 你可能和我一样至少有如下疑问:
为何必须有这一句:
defaultGroupSequence.add(Person.class)
为何 if (bean != null)必须判空
为何
年龄为: 35, 执行对应校验逻辑
被输出了两次(在判空里面还出现了两次哦~), 但校验的失败信息却只有符合预期的一次
带着问题, 我从 validate 校验的执行流程上开始分析:
1, 入口: ValidatorImpl.validate(T object, Class<?>... groups)
- ValidatorImpl:
- @Override
- public final <T> Set<ConstraintViolation<T>> validate(T object, Class<?>... groups) {
- Class<T> rootBeanClass = (Class<T>) object.getClass();
- // 获取 BeanMetaData, 类上的各种信息: 包括类上的 Group 序列, 针对此类的默认分组 List 们等等
- BeanMetaData<T> rootBeanMetaData = beanMetaDataManager.getBeanMetaData( rootBeanClass );
- ...
- }
2,beanMetaDataManager.getBeanMetaData(rootBeanClass)得到待校验 Bean 的元信息
请注意, 此处只传入了 Class, 并没有传入 Object. 这是为啥要加!= null 判空的核心原因(后面你可以看到传入的是 null).
- BeanMetaDataManager:
- public <T> BeanMetaData<T> getBeanMetaData(Class<T> beanClass) {
- ...
- // 会调用 AnnotationMetaDataProvider 来解析约束注解元数据信息(当然还有基于 xml/Programmatic 的, 本文略)
- // 注意: 它会递归处理父类, 父接口等拿到所有类的元数据
- // BeanMetaDataImpl.build()方法, 会 new BeanMetaDataImpl(...) 这个构造函数里面做了 N 多事
- // 其中就有和我本例有关的 defaultGroupSequenceProvider
- beanMetaData = createBeanMetaData( beanClass );
- }
3,new BeanMetaDataImpl( ... )构建出此 Class 的元数据信息(本例为 Person.class)
- BeanMetaDataImpl:
- public BeanMetaDataImpl(Class<T> beanClass,
- List<Class<?>> defaultGroupSequence, // 如果没有配置, 此时候 defaultGroupSequence 一般都为 null
- DefaultGroupSequenceProvider<? super T> defaultGroupSequenceProvider, // 我们自定义的处理此 Bean 的 provider
- Set<ConstraintMetaData> constraintMetaDataSet, // 包含父类的所有属性, 构造器, 方法等等. 在此处会分类: 按照属性, 方法等分类处理
- ValidationOrderGenerator validationOrderGenerator) {
- ... // 对 constraintMetaDataSet 进行分类
- // 这个方法就是筛选出了: 所有的约束注解(比如 6 个约束注解, 此处长度就是 6 当然包括了字段, 方法等上的各种...)
- this.directMetaConstraints = getDirectConstraints();
- // 因为我们 Person 类有 defaultGroupSequenceProvider, 所以此处返回 true
- // 除了定义在类上外, 还可以定义全局的: 给本类 List<Class<?>> defaultGroupSequence 此字段赋值
- boolean defaultGroupSequenceIsRedefined = defaultGroupSequenceIsRedefined();
- // 这是为何我们要判空的核心: 看看它传的啥: null. 所以不判空的就 NPE 了. 这是第一次调用 defaultGroupSequenceProvider.getValidationGroups()方法
- List<Class<?>> resolvedDefaultGroupSequence = getDefaultGroupSequence( null );
- ... // 上面拿到 resolvedDefaultGroupSequence 分组信息后, 会放到所有的校验器里去(包括属性, 方法, 构造器, 类等等)
- // so, 默认组序列还是灰常重要的(注意: 默认组可以有多个哦~~~)
- }
- @Override
- public List<Class<?>> getDefaultGroupSequence(T beanState) {
- if (hasDefaultGroupSequenceProvider()) {
- // so,getValidationGroups 方法里请记得判空~
- List<Class<?>> providerDefaultGroupSequence = defaultGroupSequenceProvider.getValidationGroups( beanState );
- // 最重要的是这个方法: getValidDefaultGroupSequence 对默认值进行分析~~~
- return getValidDefaultGroupSequence( beanClass, providerDefaultGroupSequence );
- }
- return defaultGroupSequence;
- }
- private static List<Class<?>> getValidDefaultGroupSequence(Class<?> beanClass, List<Class<?>> groupSequence) {
- List<Class<?>> validDefaultGroupSequence = new ArrayList<>();
- boolean groupSequenceContainsDefault = false; // 标志位: 如果解析不到 Default 这个组 就抛出异常
- // 重要
- if (groupSequence != null) {
- for ( Class<?> group : groupSequence ) {
- // 这就是为何我们要 `defaultGroupSequence.add(Person.class)` 这一句的原因所在~~~ 因为需要 Default 生效~~~
- if ( group.getName().equals( beanClass.getName() ) ) {
- validDefaultGroupSequence.add( Default.class );
- groupSequenceContainsDefault = true;
- }
- // 意思是: 你要添加 Default 组, 用本类的 Class 即可, 而不能显示的添加 Default.class 哦~
- else if ( group.getName().equals( Default.class.getName() ) ) {
- throw LOG.getNoDefaultGroupInGroupSequenceException();
- } else { // 正常添加进默认组
- validDefaultGroupSequence.add( group );
- }
- }
- }
- // 若找不到 Default 组, 就抛出异常了~
- if ( !groupSequenceContainsDefault ) {
- throw LOG.getBeanClassMustBePartOfRedefinedDefaultGroupSequenceException( beanClass );
- }
- return validDefaultGroupSequence;
- }
到这一步, 还仅仅在初始化 BeanMetaData 阶段, 就执行了一次(首次)defaultGroupSequenceProvider.getValidationGroups(null), 所以判空是很有必要的. 并且把本 class add 进默认组也是必须的(否则报错)~
到这里 BeanMetaData<T> rootBeanMetaData 创建完成, 继续 validate()的逻辑~
4,determineGroupValidationOrder(groups)从调用者指定的分组里确定组序列(组的执行顺序)
- ValidatorImpl:
- @Override
- public final <T> Set<ConstraintViolation<T>> validate(T object, Class<?>... groups) {
- ...
- BeanMetaData<T> rootBeanMetaData = beanMetaDataManager.getBeanMetaData( rootBeanClass );
- ...
- ... // 准备好 ValidationContext(持有 rootBeanMetaData 和 object 实例)
- // groups 是调用者传进来的分组数组(对应 Spring MVC 中指定的 Group 信息~)
- ValidationOrder validationOrder = determineGroupValidationOrder(groups);
- ... // 准备好 ValueContext(持有 rootBeanMetaData 和 object 实例)
- // 此时还是 Bean 级别的, 开始对此 bean 执行校验
- return validateInContext( validationContext, valueContext, validationOrder );
- }
- private ValidationOrder determineGroupValidationOrder(Class<?>[] groups) {
- Collection<Class<?>> resultGroups;
- // if no groups is specified use the default
- if ( groups.length == 0 ) {
- resultGroups = DEFAULT_GROUPS;
- } else {
- resultGroups = Arrays.asList( groups );
- }
- // getValidationOrder()主要逻辑描述. 此时候 resultGroups 至少也是个[Default.class]
- // 1, 如果仅仅只是一个 Default.class, 那就直接 return
- // 2, 遍历所有的 groups.(指定的 Group 必须必须是接口)
- // 3, 若遍历出来的 group 标注有 `@GroupSequence` 注解, 特殊处理此序列(把序列里的分组们添加进来)
- // 4, 普通的 Group, 那就 new Group( clazz )添加进 `validationOrder` 里. 并且递归插入(因为可能存在父接口的情况)
- return validationOrderGenerator.getValidationOrder( resultGroups );
- }
到这 ValidationOrder(实际为 DefaultValidationOrder)保存着调用者调用 validate()方法时传入的 Groups 们. 分组序列 @GroupSequence 在此时会被解析.
到了 validateInContext( ... )就开始拿着这些 Groups 分组, 元信息开始对此 Bean 进行校验了~
5,validateInContext( ... )在上下文 (校验上下文, 值上下文, 指定的分组里) 对此 Bean 进行校验
- ValidatorImpl:
- private <T, U> Set<ConstraintViolation<T>> validateInContext(ValidationContext<T> validationContext, ValueContext<U, Object> valueContext, ValidationOrder validationOrder) {
- if ( valueContext.getCurrentBean() == null ) { // 兼容整个 Bean 为 null 值
- return Collections.emptySet();
- }
- // 如果该 Bean 头上标注了(需要 defaultGroupSequence 处理), 那就特殊处理一下
- // 本例中我们的 Person 肯定为 true, 可以进来的
- BeanMetaData<U> beanMetaData = valueContext.getCurrentBeanMetaData();
- if ( beanMetaData.defaultGroupSequenceIsRedefined() ) {
- // 注意此处又调用了 beanMetaData.getDefaultGroupSequence()这个方法, 这算是二次调用了
- // 此处传入的 Object 哟~ 这就解释了为何在判空里面的 ` 年龄为: xxx` 被打印了两次的原因
- // assertDefaultGroupSequenceIsExpandable 方法是个空方法(默认情况下), 可忽略
- validationOrder.assertDefaultGroupSequenceIsExpandable( beanMetaData.getDefaultGroupSequence( valueContext.getCurrentBean() ) );
- }
- // ============== 下面对于执行顺序, 就很重要了 ===============
- // validationOrder 装着的是调用者指定的分组(解析分组序列来保证顺序~~~)
- // 需要特别注意: 光靠指定分组, 是无序的(不能保证校验顺序的) 所以若指定多个分组需要小心求证
- Iterator<Group> groupIterator = validationOrder.getGroupIterator();
- // 按照调用者指定的分组(顺序), 一个一个的执行分组校验.
- while ( groupIterator.hasNext() ) {
- Group group = groupIterator.next();
- valueContext.setCurrentGroup(group.getDefiningClass()); // 设置当前正在执行的分组
- // 这个步骤就稍显复杂了, 也是核心的逻辑之一. 大致过程如下:
- // 1, 拿到该 Bean 的 BeanMetaData
- // 2, 若 defaultGroupSequenceIsRedefined()=true 本例 Person 标注了 provder 注解, 所以有指定的分组序列的
- // 3, 根据分组序列的顺序, 挨个执行分组们(对所有的约束 MetaConstraint 都顺序执行分组们)
- // 4, 最终完成所有的 MetaConstraint 的校验, 进而完成此部分所有的字段, 方法等的校验
- validateConstraintsForCurrentGroup( validationContext, valueContext );
- if ( shouldFailFast( validationContext ) ) {
- return validationContext.getFailingConstraints();
- }
- }
- ... // 和上面一样的代码, 校验 validateCascadedConstraints
- // 继续遍历序列: 和 @GroupSequence 相关了
- Iterator<Sequence> sequenceIterator = validationOrder.getSequenceIterator();
- ...
- // 校验上下文的错误消息: 它会把本校验下, 所有的验证器上下文 ConstraintValidatorContext 都放一起的
- // 注意: 所有的校验注解之间的上下文 ConstraintValidatorContext 是完全独立的, 无法互相访问通信
- return validationContext.getFailingConstraints();
- }
that is all. 到这一步整个校验就完成了, 若不快速失败, 默认会拿到所有校验失败的消息.
真正执行 isValid 的方法在这里:
- public abstract class ConstraintTree<A extends Annotation> {
- ...
- protected final <T, V> Set<ConstraintViolation<T>> validateSingleConstraint(
- ValidationContext<T> executionContext, // 它能知道所属类
- ValueContext<?, ?> valueContext,
- ConstraintValidatorContextImpl constraintValidatorContext,
- ConstraintValidator<A, V> validator) {
- boolean isValid;
- // 解析出 value 值
- V validatedValue = (V) valueContext.getCurrentValidatedValue();
- // 把 value 值交给校验器的 isValid 方法去校验~~~
- isValid = validator.isValid(validatedValue,constraintValidatorContext);
- ...
- if (!isValid) {
- // 校验没通过就使用 constraintValidatorContext 校验上下文来生成错误消息
- // 使用上下文是因为: 毕竟错误消息可不止一个啊~~~
- // 当然此处借助了 executionContext 的方法~~~ 内部其实调用的是 constraintValidatorContext.getConstraintViolationCreationContexts()这个方法而已
- return executionContext.createConstraintViolations(valueContext, constraintValidatorContext);
- }
- }
- }
至于上下文 ConstraintValidatorContext 怎么来的, 是 new 出来的: new ConstraintValidatorContextImpl( ... ), 每个字段的一个校验注解对应一个上下文(一个属性上可以标注多个约束注解哦~), 所以此上下文是有很强的隔离性的.
ValidationContext<T> validationContext 和 ValueContext<?, Object> valueContext 它哥俩是类级别的, 直到 ValidatorImpl.validateMetaConstraints 方法开始一个一个约束器的校验~
自定义注解中只把 ConstraintValidatorContext 上下文给调用者使用, 而并没有给 validationContext 和 valueContext, 我个人觉得这个设计是不够灵活的, 无法方便的实现 dependOn 的效果~
解决网友的问题
我把这部分看似是本文最重要的引线放到最后, 是因为我觉得我的描述已经解决这一类问题, 而不是只解决了这一个问题.
回到文首截图中热心网友反应的问题, 只要你阅读了本文, 我十分坚信你已经有办法去使用 Bean Validation 优雅的解决了. 如果各位没有意见, 此处我就略了~
总结
本文讲述了使用 @GroupSequenceProvider 来解决多字段联合逻辑校验的这一类问题, 这也许是曾经很多人的开发痛点, 希望本文能帮你一扫之前的障碍, 全面拥抱 Bean Validation 吧~
本文我也传达了一个观点: 相信流行的开源东西的优秀, 不是非常极端的 case, 深入使用它能解决你绝大部分的问题的.
来源: https://www.cnblogs.com/fangshixiang/p/11387438.html