前言

本文是上篇文章的续篇,个人建议可先花3分钟移步上篇文章浏览一下:Bean Validation 声明式验证四大级别:字段、属性、容器元素、类

很多人说 Bean Validation 只能验证单属性(单字段),但我却说它能完成 99.99% 的 Bean 验证,不信你可继续阅读本文,能否解你疑惑。

版本约定

  • Bean Validation 版本:2.0.2
  • Hibernate Validator 版本:6.1.5.Final

    正文

本文接上文叙述,继续介绍 Bean Validation 声明式验证四大级别中的容器元素验证(自定义容器类型)以及类级别验证(也叫多字段联合验证)。


据我了解,很多小伙伴对这部分内容并不熟悉,遇到类似场景往往被迫只能是一半 Bean Validation 验证 + 一半事务脚本验证的方式,显得洋不洋俗不俗。

本文将给出具体案例场景,然后统一使用 Bean Validation 来解决数据验证问题,希望可以帮助到你,给予参考之作用。

自定义容器类型元素验证

通过上文我们已经知道了 Bean Validation 是可以对形如 List、Set、Map 这样的容器类型里面的元素进行验证的,内置的容器验证器虽然能覆盖大部分的使用场景,但不免有些场景依旧不能覆盖。

譬如我们都不陌生的方法返回值容器 Result,结构形如这样(最简形式,仅供参考):

  1. @Data
  2. public final class Result<T> implements Serializable {
  3. private boolean success = true;
  4. private T data = null;
  5. private String errCode;
  6. private String errMsg;
  7. }

Controller 层用它包装(装载)数据 data,形如这样:

  1. @GetMapping("/room")
  2. Result<Room> room() { ... }
  3. public class Room {
  4. @NotNull
  5. public String name;
  6. @AssertTrue
  7. public boolean finished;
  8. }

我们希望对 Result 里面的 Room 进行合法性验证,借助 Bean Validation 进行声明式验证而非硬编码。希望这么写就可以了 Result<@Notnull @Valid LoggedAccountResp>。显然,缺省情况下即使这样声明了约束注解也是无效的,毕竟 Bean Validation 根本就不认识 Result 这个容器,更别提验证其元素了。


好在 Bean Validation 对此提供了扩展点,下面我将一步一步的来对此提供实现。

自定义一个可以从 Result 里提取出 T 值的 ValueExtractor 值提取器。

Bean Validation 允许我们对自定义容器元素类型进行支持。要想支持自定义的容器类型,需要注册一个自定义的 ValueExtractor 用于值的提取。

  1. public class ResultValueExtractor implements ValueExtractor<Result<@ExtractedValue ?>> {
  2. @Override
  3. public void extractValues(Result<?> originalValue, ValueReceiver receiver) {
  4. receiver.value(null, originalValue.getData());
  5. }
  6. }

将此自定义的值提取器注册进验证器 Validator 里,并提供测试代码。

把 Result 作为一个 Filed 字段装进 Java Bean 里:

  1. public class ResultDemo {
  2. public Result<@Valid Room> roomResult;
  3. }

测试代码:

  1. public static void main(String[] args) {
  2. Room room = new Room();
  3. room.name = "YourBatman";
  4. Result<Room> result = new Result<>();
  5. result.setData(room);
  6. // 把Result作为属性放进去
  7. ResultDemo resultDemo = new ResultDemo();
  8. resultDemo.roomResult = result;
  9. // 注册自定义的值提取器
  10. Validator validator = ValidatorUtil.getValidatorFactory()
  11. .usingContext()
  12. .addValueExtractor(new ResultValueExtractor())
  13. .getValidator();
  14. ValidatorUtil.printViolations(validator.validate(resultDemo));
  15. }

运行测试程序,输出:

  1. roomResult.finished只能为true,但你的值是: false

完美的实现了对 Result 容器里的元素进行验证。

本例是把 Result 作为 Java Bean 的属性进行试验的,实际上大多数情况下是把它作为方法返回值进行校验,方式类似,有兴趣的同学可自行举一反三。

补一句,若在 Spring Boot 场景下,你想像这样对 Result 提供支持,那么你需要自行提供一个验证器来覆盖掉自动装配进去的,可参考 ValidationAutoConfiguration。

类级别验证(多字段联合验证)

约束也可以放在类级别上(也就说注解标注在类上)。在这种情况下,验证的主体不是单个属性,而是整个对象。如果验证依赖于对象的几个属性之间的相关性,那么类级别约束就能搞定这一切。


这个需求场景在平时开发中也非常常见,比如此处我举个场景案例:Room 表示一个教室,maxStuNum 表示该教室允许的最大学生数,studentNames 表示教室里面的学生们。很明显这里存在这么样一个规则:学生总数不能大于教室允许的最大值,即 studentNames.size() <= maxStuNum。如果用事务脚本来实现这个验证规则,那么你的代码里肯定穿插着类似这样的代码:

  1. if (room.getStudentNames().size() > room.getMaxStuNum()) {
  2. throw new RuntimeException("...");
  3. }

虽然这么做也能达到校验的效果,但很明显这不够优雅。期望这种 case 依旧能借助 Bean Validation 来优雅实现,下面我来走一把。


相较于前面字段和属性验证的使用 case,这个需要验证的是整个对象(多个字段)。下面给出两种实现方式,供以参考。

方式一:基于内置的 @ScriptAssert 实现

虽说 Bean Validation 没有内置任何类级别的注解,但 Hibernate Validator 却对此提供了增强,弥补了其不足。 @ScriptAssert 就是 Hibernate Validator 内置的一个非常强大的、可以用于类级别验证的注解,它可以很容易的处理这种 case:

  1. @ScriptAssert(lang = "javascript", alias = "_", script = "_.maxStuNum >= _.studentNames.length")
  2. @Data
  3. public class Room {
  4. @Positive
  5. private int maxStuNum;
  6. @NotNull
  7. private List<String> studentNames;
  8. }

@ScriptAssert 支持写脚本来完成验证逻辑,这里使用的是 javascript(缺省情况下的唯一选择,也是默认选择)。

测试用例:

  1. public static void main(String[] args) {
  2. Room room = new Room();
  3. ValidatorUtil.printViolations(ValidatorUtil.getValidator().validate(room));
  4. }

运行程序,抛错:

  1. Caused by: <eval>:1 TypeError: Cannot get property "length" of null
  2. at jdk.nashorn.internal.runtime.ECMAErrors.error(ECMAErrors.java:57)
  3. at jdk.nashorn.internal.runtime.ECMAErrors.typeError(ECMAErrors.java:213)
  4. ...

这个报错意思是 _.studentNames 值为 null,也就是 room.studentNames 字段的值为 null。


what?它头上不明明标了 @NotNull 注解吗,怎么可能为 null 呢?这其实涉及到前面所讲到的一个小知识点,这里提一嘴:所有的约束注解都会执行,不存在短路效果(除非校验程序抛异常),只要你敢标,我就敢执行,所以这里为嘛报错你懂了吧。

@ScriptAssert 对 null 值并不免疫,不管咋样它都会执行的,因此书写脚本时注意判空。

方式二:自定义注解方式实现

Hibernate Validator 自定义注解暂时还没有介绍,这里先混个脸熟,后面会有专文介绍。

自定义一个约束注解,并且提供约束逻辑的实现:

  1. @Target({TYPE, ANNOTATION_TYPE})
  2. @Retention(RUNTIME)
  3. @Constraint(validatedBy = {ValidStudentCountConstraintValidator.class})
  4. public @interface ValidStudentCount {
  5. String message() default "学生人数超过最大限额";
  6. Class<?>[] groups() default {};
  7. Class<? extends Payload>[] payload() default {};
  8. }
  1. public class ValidStudentCountConstraintValidator implements ConstraintValidator<ValidStudentCount, Room> {
  2. @Override
  3. public void initialize(ValidStudentCount constraintAnnotation) {
  4. }
  5. @Override
  6. public boolean isValid(Room room, ConstraintValidatorContext context) {
  7. if (room == null) {
  8. return true;
  9. }
  10. boolean isValid = false;
  11. if (room.getStudentNames().size() <= room.getMaxStuNum()) {
  12. isValid = true;
  13. }
  14. // 自定义提示语(当然你也可以不自定义,那就使用注解里的message字段的值)
  15. if (!isValid) {
  16. context.disableDefaultConstraintViolation();
  17. context.buildConstraintViolationWithTemplate("校验失败xxx")
  18. .addPropertyNode("studentNames")
  19. .addConstraintViolation();
  20. }
  21. return isValid;
  22. }
  23. }

测试脚本 :

  1. public static void main(String[] args) {
  2. Room room = new Room();
  3. room.setStudentNames(Collections.singletonList("YourBatman"));
  4. ValidatorUtil.printViolations(ValidatorUtil.getValidator().validate(room));
  5. }

运行程序,输出:

  1. maxStuNum必须是正数,但你的值是: 0
  2. studentNames校验失败xxx,但你的值是: Room(maxStuNum=0, studentNames=[YourBatman])

完美,完全符合预期。

这两种方式都可以实现类级别的验证,它俩可以说各有优劣,主要体现在如下方面:

  • @ScriptAssert 是 Hibernate Validator 内置就提供的,因此使用起来非常方便和通用。但缺点也是因为过于通用,因此语义上不够明显,需要阅读脚本才知,推荐少量(非重复使用)、逻辑较为简单时使用;
  • 自定义注解方式,缺点当然是“开箱使用”起来稍显麻烦,但它的优点就是语义明确,灵活且不易出错,即使是复杂的验证逻辑也能轻松搞定。

    总结

本文举例的两个场景:Result 和多字段联合验证均属于平时开发中比较常见的场景,如果能让 Bean Validation 介入帮解决此类问题,相信对提高效率是很有帮助的,说不定你还能成为团队中最靓的仔。

转载

打个广告,方便的话,可以关注一下 A哥(YourBatman) 的公众号。
image.png

作者:殷建卫 链接:https://www.yuque.com/yinjianwei/vyrvkf/usnqpg 来源:殷建卫 - 架构笔记 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。