前言

上篇文章完整的介绍了 JSR、Bean Validation、Hibernate Validator 的联系和区别,并且代码演示了如何进行基于注解的 Java Bean 校验。

但是很多时候,方法的参数只是一些简单的参数,比如 int age、String name 这些,不需要封装成 Java Bean,我们希望通过如下写法就能达到相应的约束效果:

  1. public Person getOne(@NotNull @Min(1) Integer id, String name) { ... };

本文就来探讨如何借助 Bean Validation 实现声明式校验方法参数和返回值。

声明式除了有代码优雅、无侵入的好处之外,还有一个不可忽视的优点是:任何一个人只需要看声明就知道语义,而并不需要了解你的实现,这样使用起来也更有安全感。

版本约定

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

    正文

Bean Validation 1.0 版本只支持对 Java Bean 进行校验,到了 1.1 版本就已经支持对方法和构造方法校验,使用的校验器便是 1.1 版本新增的 ExecutableValidator 类:

  1. public interface ExecutableValidator {
  2. // 方法校验:参数和返回值
  3. <T> Set<ConstraintViolation<T>> validateParameters(T object,
  4. Method method,
  5. Object[] parameterValues,
  6. Class<?>... groups);
  7. <T> Set<ConstraintViolation<T>> validateReturnValue(T object,
  8. Method method,
  9. Object returnValue,
  10. Class<?>... groups);
  11. // 构造器校验:参数和返回值
  12. <T> Set<ConstraintViolation<T>> validateConstructorParameters(Constructor<? extends T> constructor,
  13. Object[] parameterValues,
  14. Class<?>... groups);
  15. <T> Set<ConstraintViolation<T>> validateConstructorReturnValue(Constructor<? extends T> constructor,
  16. T createdObject,
  17. Class<?>... groups);
  18. }

其实我们对 Executable 这个字眼并不陌生,像 JDK 的接口 java.lang.reflect.Executable 它的两个实现便是 Method 和 Constructor,刚好和这里相呼应。

在下面的代码示例之前,先提供两个方法用于获取校验器(使用默认配置),方便后续使用:

  1. // 用于Java Bean校验的校验器
  2. private Validator getValidator() {
  3. // 1.使用【默认配置】得到一个校验工厂,这个配置可以来自于provider SPI提供
  4. ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
  5. // 2.得到一个校验器
  6. return validatorFactory.getValidator();
  7. }
  8. // 用于方法校验的校验器
  9. private ExecutableValidator getExecutableValidator() {
  10. return getValidator().forExecutables();
  11. }

因为 Validator 校验器是线程安全的,因此一般来说一个应用全局仅需一份即可,因此只需要初始化一次。

校验方法

比如有一个 Service 接口如下所示:

  1. public class PersonService {
  2. public Person getOne(Integer id, String name) {
  3. return null;
  4. }
  5. }

现在我们要对 getOne 方法添加如下约束要求:

  1. id 是必传(不为 NULL)且最小值为 1,但对 name 没有要求;
  2. 返回值不能为 NULL。

    校验方法参数

根据 getOne 方法的约束要求,我们需要对入参 id 字段做校验,如果不使用 Bean Validation,需要按照如下方式写校验逻辑:

  1. public Person getOne(Integer id, String name) {
  2. if (id == null) {
  3. throw new IllegalArgumentException("id不能为null");
  4. }
  5. if (id < 1) {
  6. throw new IllegalArgumentException("id必须大于等于1");
  7. }
  8. return null;
  9. }

这样写可以实现约束条件,但是它也存在如下弊端:

  1. 这类代码没啥营养,如果校验逻辑稍微多点就会显得臭长臭长的;
  2. 不看你的校验逻辑,调用者无法知道你的语义,比如他不知道 id 字段是否可以为 NULL,没有形成契约;
  3. 代码侵入性强。

既然学习了 Bean Validation,关于校验方面的工作交给它显然更好:

  1. public Person getOne(@NotNull @Min(1) Integer id, String name) throws NoSuchMethodException {
  2. // 校验逻辑
  3. Method currMethod = this.getClass().getMethod("getOne", Integer.class, String.class);
  4. Set<ConstraintViolation<PersonService>> validResult = getExecutableValidator().validateParameters(this, currMethod, new Object[]{id, name});
  5. if (!validResult.isEmpty()) {
  6. // 输出错误详情
  7. validResult
  8. .stream()
  9. .map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValue())
  10. .forEach(System.out::println);
  11. throw new IllegalArgumentException("参数错误");
  12. }
  13. return null;
  14. }

测试代码:

  1. @Test
  2. public void test2() throws NoSuchMethodException {
  3. new PersonService().getOne(0, "A哥");
  4. }

运行程序,控制台输出:

  1. getOne.arg0 最小不能小于1: 0
  2. java.lang.IllegalArgumentException: 参数错误
  3. ...

符合约束要求,只是这个 arg0 是什么?.java 文件编译成 .class 文件后,并没有把完整的参数名编译进去,所以通过反射机制获取不到参数名,需要在编译时手动指定 -parameters 选项,将参数名完整的编译到 . class 文件中。

如果你的项目是通过 maven 构建的,只需要在 pom.xml 文件中配置如下插件就可以实现将参数名完整的编译到 . class 文件中:

  1. <plugin>
  2. <artifactId>maven-compiler-plugin</artifactId>
  3. <version>3.8.0</version>
  4. <configuration>
  5. <source>1.8</source>
  6. <target>1.8</target>
  7. <encoding>utf8</encoding>
  8. <compilerArgs>
  9. <arg>-parameters</arg>
  10. </compilerArgs>
  11. </configuration>
  12. </plugin>

如果你用的编辑器是 IDEA,也可以通过如下配置界面进行配置:
image.png
当然推荐的方式还是通过 pom.xml 文件进行配置。

通过注解实现约束规则,成功的解决上面 3 个问题中的两个,特别是声明式约束,这对于平时开发效率的提升是很有帮助的,因为契约已形成。

此外还剩一个问题:代码侵入性强。目前校验逻辑依旧写在了方法体里面,但一聊到如何解决代码侵入问题,相信不用我说都能想到 AOP。一般来说,我们有两种 AOP 方式供以使用:

  1. 基于 Java EE 的 @Inteceptors 实现;
  2. 基于 Spring Framework 实现。

显然,前者是 Java 官方的标准技术,而后者是实际的标准,这个等到后面讲到 Bean Validation 和 Spring 整合使用的时候再实现。

校验方法返回值

相较于方法参数,返回值的校验可能很多人没听过没用过,或者接触得非常少。其实从原则上来讲,一个方法理应对其输入输出负责:有效的输入,明确的输出,这种明确就最好是有约束的。

上面的 getOne 方法题目要求返回值不能为 NULL。若通过硬编码方式校验,无非就是在 return 之前来个 if (result == null) 的判断:

  1. public Person getOne(Integer id, String name) throws NoSuchMethodException {
  2. // ... 模拟逻辑执行,得到一个result结果,准备返回
  3. Person result = null;
  4. // 在结果返回之前校验
  5. if (result == null) {
  6. throw new IllegalArgumentException("返回结果不能为null");
  7. }
  8. return result;
  9. }

同样的,这种代码依旧有如下三个问题:

  1. 这类代码没啥营养,如果校验逻辑稍微多点就会显得臭长臭长的;
  2. 不看你的执行逻辑,调用者无法知道你的语义。比如调用者不知道返回是是否可能为 NULL,没有形成契约;
  3. 代码侵入性强。

话不多说,直接上代码。

  1. public @NotNull Person getOne(@NotNull @Min(1) Integer id, String name) throws NoSuchMethodException {
  2. // 模拟逻辑执行,得到一个result
  3. Person result = null;
  4. // 在结果返回之前校验
  5. Method currMethod = this.getClass().getMethod("getOne", Integer.class, String.class);
  6. Set<ConstraintViolation<PersonService>> validResult = getExecutableValidator().validateReturnValue(this, currMethod, result);
  7. if (!validResult.isEmpty()) {
  8. // ... 输出错误详情validResult
  9. validResult.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValue()).forEach(System.out::println);
  10. throw new IllegalArgumentException("参数错误");
  11. }
  12. return result;
  13. }

测试代码:

  1. @Test
  2. public void test2() throws NoSuchMethodException {
  3. // 看到没,IDEA自动帮你前面加了个NotNull
  4. @NotNull Person result = new PersonService().getOne(1, "A哥");
  5. }

运行程序,控制台输出:

  1. getOne.<return value> 不能为null: null
  2. java.lang.IllegalArgumentException: 参数错误
  3. ...

这里面有个小细节:当你调用 getOne 方法,让 IDEA 自动帮你填充返回值时,会自动把校验规则也添加上去,在拿到结果后,就不用再通过 if (xxx != null) 语句判断了,这就是契约编程,可以提升团队内编程效率。

校验构造方法

校验构造方法自己实现一下吧……

加餐1:Java Bean 作为入参如何校验?

如果方法参数是一个 Java Bean,你该如何使用 Bean Validation 校验呢?

  1. @ToString
  2. @Setter
  3. @Getter
  4. public class Person {
  5. @NotNull
  6. public String name;
  7. @NotNull
  8. @Min(0)
  9. public Integer age;
  10. }
  1. public void save(Person person) {
  2. }

提出如下校验要求:

  1. Person 不能为 NULL;
  2. 校验 Person 类中的校验规则。

对 save 方法加上校验,如下所示:

  1. public void save(@NotNull Person person) throws NoSuchMethodException {
  2. Method currMethod = this.getClass().getMethod("save", Person.class);
  3. Set<ConstraintViolation<PersonService>> validResult = getExecutableValidator().validateParameters(this, currMethod, new Object[]{person});
  4. if (!validResult.isEmpty()) {
  5. // ... 输出错误详情validResult
  6. validResult.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValue()).forEach(System.out::println);
  7. throw new IllegalArgumentException("参数错误");
  8. }
  9. }

测试代码:

  1. @Test
  2. public void test3() throws NoSuchMethodException {
  3. // save.arg0不能为null: null
  4. // new PersonService().save(null);
  5. new PersonService().save(new Person());
  6. }

运行程序,控制台没有输出,也就是说校验通过了。很明显,new 出来的 Person 不是一个合法的模型对象,所以可以断定没有执行模型里面的校验逻辑,为什么呢?

需要在参数前面再增加一个注解:@Valid。

  1. public void save(@NotNull @Valid Person person) throws NoSuchMethodException { ... }

再次运行测试程序,控制台输出:

  1. save.arg0.name 不能为null: null
  2. save.arg0.age 不能为null: null
  3. java.lang.IllegalArgumentException: 参数错误
  4. ...

@Valid 注解用于验证级联的属性、方法参数或方法返回类型。比如你的属性仍旧是个 Java Bean,你想深入进入校验它里面的约束,那就在此属性头上标注此注解即可。另外,通过使用 @Valid 可以实现递归验证,因此可以标注在 List 上,对它里面的每个对象都执行校验。

加餐2:注解应该写在接口上还是实现上?

下面我们针对上面的 save 方法做个例子,提取一个接口出来,并且添加所有的校验注解:

  1. public interface PersonInterface {
  2. void save(@NotNull @Valid Person person) throws NoSuchMethodException;
  3. }

实现类上不添加校验注解:

  1. public class PersonService implements PersonInterface {
  2. @Override
  3. public void save(Person person) throws NoSuchMethodException {
  4. ... // 方法体代码同上,略
  5. }
  6. }

测试代码同上,运行程序,控制台输出:

  1. save.arg0.name 不能为null: null
  2. save.arg0.age 不能为null: null
  3. java.lang.IllegalArgumentException: 参数错误
  4. ...

校验注解写在接口上也能实现校验功能。

总结

本文讲述的是 Bean Validation 又一经典实用场景:校验方法的参数、返回值。后面加上和 Spring 的 AOP 整合将释放出更大的能量。

另外,通过本文你应该能再次感受到契约编程带来的好处吧,总之能通过契约约定解决的就不要去硬编码,人生苦短,少编码多行乐。

这里只是提供了一个校验的方式,到目前为止,我还没有见到项目中有用 Bean Validation 校验方法参数的,除了 Controller 层,其他层貌似没有使用该方式校验,如果有朋友用到了,方便的话提供一些真实案例。

转载

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

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