前言
上篇文章完整的介绍了 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;
}
测试代码:
@Test
public void test2() throws NoSuchMethodException {
new PersonService().getOne(0, "A哥");
}
运行程序,控制台输出:
getOne.arg0 最小不能小于1: 0
java.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 {
// 模拟逻辑执行,得到一个result
Person result = null;
// 在结果返回之前校验
Method currMethod = this.getClass().getMethod("getOne", Integer.class, String.class);
Set<ConstraintViolation<PersonService>> validResult = getExecutableValidator().validateReturnValue(this, currMethod, result);
if (!validResult.isEmpty()) {
// ... 输出错误详情validResult
validResult.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValue()).forEach(System.out::println);
throw new IllegalArgumentException("参数错误");
}
return result;
}
测试代码:
@Test
public void test2() throws NoSuchMethodException {
// 看到没,IDEA自动帮你前面加了个NotNull
@NotNull Person result = new PersonService().getOne(1, "A哥");
}
运行程序,控制台输出:
getOne.<return value> 不能为null: null
java.lang.IllegalArgumentException: 参数错误
...
这里面有个小细节:当你调用 getOne 方法,让 IDEA 自动帮你填充返回值时,会自动把校验规则也添加上去,在拿到结果后,就不用再通过 if (xxx != null)
语句判断了,这就是契约编程,可以提升团队内编程效率。
校验构造方法
加餐1:Java Bean 作为入参如何校验?
如果方法参数是一个 Java Bean,你该如何使用 Bean Validation 校验呢?
@ToString
@Setter
@Getter
public class Person {
@NotNull
public 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()) {
// ... 输出错误详情validResult
validResult.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValue()).forEach(System.out::println);
throw new IllegalArgumentException("参数错误");
}
}
测试代码:
@Test
public 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: null
save.arg0.age 不能为null: null
java.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 {
@Override
public void save(Person person) throws NoSuchMethodException {
... // 方法体代码同上,略
}
}
测试代码同上,运行程序,控制台输出:
save.arg0.name 不能为null: null
save.arg0.age 不能为null: null
java.lang.IllegalArgumentException: 参数错误
...
总结
本文讲述的是 Bean Validation 又一经典实用场景:校验方法的参数、返回值。后面加上和 Spring 的 AOP 整合将释放出更大的能量。
另外,通过本文你应该能再次感受到契约编程带来的好处吧,总之能通过契约约定解决的就不要去硬编码,人生苦短,少编码多行乐。
这里只是提供了一个校验的方式,到目前为止,我还没有见到项目中有用 Bean Validation 校验方法参数的,除了 Controller 层,其他层貌似没有使用该方式校验,如果有朋友用到了,方便的话提供一些真实案例。
转载
打个广告,方便的话,可以关注一下 A哥(YourBatman) 的公众号。
作者:殷建卫 链接:https://www.yuque.com/yinjianwei/vyrvkf/tm02kg 来源:殷建卫 - 架构笔记 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。