前言
本以为洋洋洒洒的把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+反射让它变得更为通用,但本着职责单一原则,我并不建议这么去做。