前言
上篇文章完整的介绍了 JSR、Bean Validation、Hibernate Validator 的联系和区别,并且代码演示了如何进行基于注解的 Java Bean 校验。
但是很多时候,方法的参数只是一些简单的参数,比如 int age、String name 这些,不需要封装成 Java Bean,我们希望通过如下写法就能达到相应的约束效果:
public Person getOne(@NotNull @Min(1) Integer id, String name) { ... };
本文就来探讨如何借助 Bean Validation 实现声明式校验方法参数和返回值。
声明式除了有代码优雅、无侵入的好处之外,还有一个不可忽视的优点是:任何一个人只需要看声明就知道语义,而并不需要了解你的实现,这样使用起来也更有安全感。
版本约定
Bean Validation 1.0 版本只支持对 Java Bean 进行校验,到了 1.1 版本就已经支持对方法和构造方法校验,使用的校验器便是 1.1 版本新增的 ExecutableValidator 类:
public interface ExecutableValidator {// 方法校验:参数和返回值<T> Set<ConstraintViolation<T>> validateParameters(T object,Method method,Object[] parameterValues,Class<?>... groups);<T> Set<ConstraintViolation<T>> validateReturnValue(T object,Method method,Object returnValue,Class<?>... groups);// 构造器校验:参数和返回值<T> Set<ConstraintViolation<T>> validateConstructorParameters(Constructor<? extends T> constructor,Object[] parameterValues,Class<?>... groups);<T> Set<ConstraintViolation<T>> validateConstructorReturnValue(Constructor<? extends T> constructor,T createdObject,Class<?>... groups);}
其实我们对 Executable 这个字眼并不陌生,像 JDK 的接口 java.lang.reflect.Executable 它的两个实现便是 Method 和 Constructor,刚好和这里相呼应。
在下面的代码示例之前,先提供两个方法用于获取校验器(使用默认配置),方便后续使用:
// 用于Java Bean校验的校验器private Validator getValidator() {// 1.使用【默认配置】得到一个校验工厂,这个配置可以来自于provider SPI提供ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();// 2.得到一个校验器return validatorFactory.getValidator();}// 用于方法校验的校验器private ExecutableValidator getExecutableValidator() {return getValidator().forExecutables();}
因为 Validator 校验器是线程安全的,因此一般来说一个应用全局仅需一份即可,因此只需要初始化一次。
校验方法
比如有一个 Service 接口如下所示:
public class PersonService {public Person getOne(Integer id, String name) {return null;}}
现在我们要对 getOne 方法添加如下约束要求:
根据 getOne 方法的约束要求,我们需要对入参 id 字段做校验,如果不使用 Bean Validation,需要按照如下方式写校验逻辑:
public Person getOne(Integer id, String name) {if (id == null) {throw new IllegalArgumentException("id不能为null");}if (id < 1) {throw new IllegalArgumentException("id必须大于等于1");}return null;}
这样写可以实现约束条件,但是它也存在如下弊端:
- 这类代码没啥营养,如果校验逻辑稍微多点就会显得臭长臭长的;
- 不看你的校验逻辑,调用者无法知道你的语义,比如他不知道 id 字段是否可以为 NULL,没有形成契约;
- 代码侵入性强。
既然学习了 Bean Validation,关于校验方面的工作交给它显然更好:
public Person getOne(@NotNull @Min(1) Integer id, String name) throws NoSuchMethodException {// 校验逻辑Method currMethod = this.getClass().getMethod("getOne", Integer.class, String.class);Set<ConstraintViolation<PersonService>> validResult = getExecutableValidator().validateParameters(this, currMethod, new Object[]{id, name});if (!validResult.isEmpty()) {// 输出错误详情validResult.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValue()).forEach(System.out::println);throw new IllegalArgumentException("参数错误");}return null;}
测试代码:
@Testpublic void test2() throws NoSuchMethodException {new PersonService().getOne(0, "A哥");}
运行程序,控制台输出:
getOne.arg0 最小不能小于1: 0java.lang.IllegalArgumentException: 参数错误...
符合约束要求,只是这个 arg0 是什么?.java 文件编译成 .class 文件后,并没有把完整的参数名编译进去,所以通过反射机制获取不到参数名,需要在编译时手动指定 -parameters 选项,将参数名完整的编译到 . class 文件中。
如果你的项目是通过 maven 构建的,只需要在 pom.xml 文件中配置如下插件就可以实现将参数名完整的编译到 . class 文件中:
<plugin><artifactId>maven-compiler-plugin</artifactId><version>3.8.0</version><configuration><source>1.8</source><target>1.8</target><encoding>utf8</encoding><compilerArgs><arg>-parameters</arg></compilerArgs></configuration></plugin>
如果你用的编辑器是 IDEA,也可以通过如下配置界面进行配置:
当然推荐的方式还是通过 pom.xml 文件进行配置。
通过注解实现约束规则,成功的解决上面 3 个问题中的两个,特别是声明式约束,这对于平时开发效率的提升是很有帮助的,因为契约已形成。
此外还剩一个问题:代码侵入性强。目前校验逻辑依旧写在了方法体里面,但一聊到如何解决代码侵入问题,相信不用我说都能想到 AOP。一般来说,我们有两种 AOP 方式供以使用:
- 基于 Java EE 的 @Inteceptors 实现;
- 基于 Spring Framework 实现。
显然,前者是 Java 官方的标准技术,而后者是实际的标准,这个等到后面讲到 Bean Validation 和 Spring 整合使用的时候再实现。
校验方法返回值
相较于方法参数,返回值的校验可能很多人没听过没用过,或者接触得非常少。其实从原则上来讲,一个方法理应对其输入输出负责:有效的输入,明确的输出,这种明确就最好是有约束的。
上面的 getOne 方法题目要求返回值不能为 NULL。若通过硬编码方式校验,无非就是在 return 之前来个 if (result == null) 的判断:
public Person getOne(Integer id, String name) throws NoSuchMethodException {// ... 模拟逻辑执行,得到一个result结果,准备返回Person result = null;// 在结果返回之前校验if (result == null) {throw new IllegalArgumentException("返回结果不能为null");}return result;}
同样的,这种代码依旧有如下三个问题:
- 这类代码没啥营养,如果校验逻辑稍微多点就会显得臭长臭长的;
- 不看你的执行逻辑,调用者无法知道你的语义。比如调用者不知道返回是是否可能为 NULL,没有形成契约;
- 代码侵入性强。
话不多说,直接上代码。
public @NotNull Person getOne(@NotNull @Min(1) Integer id, String name) throws NoSuchMethodException {// 模拟逻辑执行,得到一个resultPerson result = null;// 在结果返回之前校验Method currMethod = this.getClass().getMethod("getOne", Integer.class, String.class);Set<ConstraintViolation<PersonService>> validResult = getExecutableValidator().validateReturnValue(this, currMethod, result);if (!validResult.isEmpty()) {// ... 输出错误详情validResultvalidResult.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValue()).forEach(System.out::println);throw new IllegalArgumentException("参数错误");}return result;}
测试代码:
@Testpublic void test2() throws NoSuchMethodException {// 看到没,IDEA自动帮你前面加了个NotNull@NotNull Person result = new PersonService().getOne(1, "A哥");}
运行程序,控制台输出:
getOne.<return value> 不能为null: nulljava.lang.IllegalArgumentException: 参数错误...
这里面有个小细节:当你调用 getOne 方法,让 IDEA 自动帮你填充返回值时,会自动把校验规则也添加上去,在拿到结果后,就不用再通过 if (xxx != null) 语句判断了,这就是契约编程,可以提升团队内编程效率。
校验构造方法
加餐1:Java Bean 作为入参如何校验?
如果方法参数是一个 Java Bean,你该如何使用 Bean Validation 校验呢?
@ToString@Setter@Getterpublic class Person {@NotNullpublic String name;@NotNull@Min(0)public Integer age;}
public void save(Person person) {}
提出如下校验要求:
- Person 不能为 NULL;
- 校验 Person 类中的校验规则。
对 save 方法加上校验,如下所示:
public void save(@NotNull Person person) throws NoSuchMethodException {Method currMethod = this.getClass().getMethod("save", Person.class);Set<ConstraintViolation<PersonService>> validResult = getExecutableValidator().validateParameters(this, currMethod, new Object[]{person});if (!validResult.isEmpty()) {// ... 输出错误详情validResultvalidResult.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValue()).forEach(System.out::println);throw new IllegalArgumentException("参数错误");}}
测试代码:
@Testpublic void test3() throws NoSuchMethodException {// save.arg0不能为null: null// new PersonService().save(null);new PersonService().save(new Person());}
运行程序,控制台没有输出,也就是说校验通过了。很明显,new 出来的 Person 不是一个合法的模型对象,所以可以断定没有执行模型里面的校验逻辑,为什么呢?
需要在参数前面再增加一个注解:@Valid。
public void save(@NotNull @Valid Person person) throws NoSuchMethodException { ... }
再次运行测试程序,控制台输出:
save.arg0.name 不能为null: nullsave.arg0.age 不能为null: nulljava.lang.IllegalArgumentException: 参数错误...
@Valid 注解用于验证级联的属性、方法参数或方法返回类型。比如你的属性仍旧是个 Java Bean,你想深入进入校验它里面的约束,那就在此属性头上标注此注解即可。另外,通过使用 @Valid 可以实现递归验证,因此可以标注在 List 上,对它里面的每个对象都执行校验。
加餐2:注解应该写在接口上还是实现上?
下面我们针对上面的 save 方法做个例子,提取一个接口出来,并且添加所有的校验注解:
public interface PersonInterface {void save(@NotNull @Valid Person person) throws NoSuchMethodException;}
实现类上不添加校验注解:
public class PersonService implements PersonInterface {@Overridepublic void save(Person person) throws NoSuchMethodException {... // 方法体代码同上,略}}
测试代码同上,运行程序,控制台输出:
save.arg0.name 不能为null: nullsave.arg0.age 不能为null: nulljava.lang.IllegalArgumentException: 参数错误...
总结
本文讲述的是 Bean Validation 又一经典实用场景:校验方法的参数、返回值。后面加上和 Spring 的 AOP 整合将释放出更大的能量。
另外,通过本文你应该能再次感受到契约编程带来的好处吧,总之能通过契约约定解决的就不要去硬编码,人生苦短,少编码多行乐。
这里只是提供了一个校验的方式,到目前为止,我还没有见到项目中有用 Bean Validation 校验方法参数的,除了 Controller 层,其他层貌似没有使用该方式校验,如果有朋友用到了,方便的话提供一些真实案例。
转载
打个广告,方便的话,可以关注一下 A哥(YourBatman) 的公众号。
作者:殷建卫 链接:https://www.yuque.com/yinjianwei/vyrvkf/tm02kg 来源:殷建卫 - 架构笔记 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
