33

2. Bean Validation声明式校验方法的参数、返回值

 3 years ago
source link: http://mp.weixin.qq.com/s?__biz=MzI0MTUwOTgyOQ%3D%3D&%3Bmid=2247490236&%3Bidx=1&%3Bsn=49cc947f10c71e7303142195ac2d830b
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
ue26NbY.png!mobile

点击上方“ BAT的乌托邦 ”,选择“ 设为星标

后台回复“ 专栏 ”,开启 专栏模式 学习

✍前言

你好,我是YourBatman。

上篇文章 完整的介绍了JSR、Bean Validation、Hibernate Validator的联系和区别,并且代码演示了如何进行基于注解的Java Bean校验,自此我们可以在Java世界进行更完美的 「契约式编程」 了,不可谓不方便。

但是你是否考虑过这个问题:很多时候,我们只是一些简单的独立参数(比如方法入参int age),并不需要大动干戈的弄个Java Bean装起来,比如我希望像这样写达到相应约束效果:

public @NotNull 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

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 obtainValidator() {
// 1、使用【默认配置】得到一个校验工厂 这个配置可以来自于provider、SPI提供
ValidatorFactory validatorFactory = Validation.buildDefaultValidatorFactory();
// 2、得到一个校验器
return validatorFactory.getValidator();
}

// 用于方法校验的校验器
private ExecutableValidator obtainExecutableValidator() {
return obtainValidator().forExecutables();
}

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

校验Java Bean

先来回顾下对Java Bean的校验方式。书写JavaBean和校验程序(全部使用JSR标准API),声明上约束注解:

@ToString
@Setter
@Getter
public class Person {

@NotNull
public String name;
@NotNull
@Min(0)
public Integer age;
}

@Test
public void test1() {
Validator validator = obtainValidator();

Person person = new Person();
person.setAge(-1);
Set<ConstraintViolation<Person>> result = validator.validate(person);

// 输出校验结果
result.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValue()).forEach(System.out::println);
}

运行程序,控制台输出:

name 不能为null: null
age 需要在118之间: -1

这是最经典的应用了。那么问题来了,如果你的方法参数就是个Java Bean,你该如何对它进行校验呢?

小贴士:有的人认为把约束注解标注在属性上,和标注在set方法上效果是一样的, 「其实不然」 ,你有这种错觉全是因为Spring帮你处理了写东西,至于原因将在后面和Spring整合使用时展开

校验方法

对方法的校验是本文的重点。比如我有个Service如下:

public class PersonService {

public Person getOne(Integer id, String name) {
return null;
}

}

现在对该方法的执行,有如下 「约束」 要求:

  1. id是 「必传」 (不为null)且 「最小值为1」 ,但对name没有要求

  2. 返回值不能为null

下面分为校验方法参数和校验返回值两部分分别展开。

校验方法参数

如上,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;
}

这么写固然是没毛病的,但是它的弊端也非常明显:

  1. 这类代码没啥营养,如果校验逻辑稍微多点就会显得臭长臭长的

  2. 不看你的执行逻辑,调用者无法知道你的语义。比如它并不知道id是传还是不传也行, 「没有形成契约」

  3. 代码侵入性强

优化方案

既然学习了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 = obtainExecutableValidator().validateParameters(this, currMethod, new Object[]{id, name});
if (!validResult.isEmpty()) {
// ... 输出错误详情validResult
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(1, "A哥");
}

运行程序,控制台输出:

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

「完美」的符合预期。不过,arg0是什么鬼?如果你有兴趣可以自行加上编译参数 -parameters 再运行试试,有惊喜哦~

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

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

  1. 基于Java EE的@Inteceptors实现

  2. 基于Spring Framework实现

显然,前者是Java官方的标准技术,而后者是 「实际的」 标准,所以这个小问题先mark下来,等到后面讲到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;
}

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

  1. 这类代码没啥营养,如果校验逻辑稍微多点就会显得臭长臭长的

  2. 不看你的执行逻辑,调用者无法知道你的语义。比如它并不知道id是传还是不传也行, 「没有形成契约」

  3. 代码侵入性强

优化方案

话不多说,直接上代码。

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 = obtainExecutableValidator().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自动帮你填充返回值时,前面把校验规则也给你显示出来了,这就是 「契约」 。明明白白的,拿到这样的result你是不是可以非常放心的使用,不再战战兢兢的啥都来个 if(xxx !=null) 的判断了呢?这就是契约编程的力量,在团队内能指数级的提升编程效率,试试吧~

校验构造方法

这个,呃,(⊙o⊙)…...自己动手玩玩吧,记得牢~

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

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

public void save(Person person) {
}

约束上可以提出如下合理要求:

  1. person不能为null

  2. 是个合法的person模型。换句话说:person里面的那些校验规则你都得遵守喽

对save方法加上校验如下:

public void save(@NotNull Person person) throws NoSuchMethodException {
Method currMethod = this.getClass().getMethod("save", Person.class);
Set<ConstraintViolation<PersonService>> validResult = obtainExecutableValidator().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不是一个合法的模型对象,所以可以断定 「没有执行」 模型里面的校验逻辑,怎么办呢?难道仍要自己用Validator去用API校验麽?

好拉,不卖关子了,这个时候就清楚大名鼎鼎的 @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上,对它里面的每个对象都执行校验

题外话一句:相信有小伙伴想问@Valid和Spring提供的@Validated有啥区别,我给的答案是: 「完全不是一回事,纯巧合而已」 。至于为何这么说,后面和Spring整合使用时给你讲得明明白白的。

加餐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 {
... // 方法体代码同上,略
}

}

测试程序也同上,为:

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

运行程序,控制台输出:

save.arg0.name 不能为null: null
save.arg0.age 不能为null: null

java.lang.IllegalArgumentException: 参数错误
...

符合预期,没有任何问题。这还没完,还有很多组合方式呢,比如:约束注解全写在实现类上;实现类比接口少;比接口多......

限于篇幅,文章里对试验过程我就不贴出来了,直接给你扔结论吧:

  • 如果该方法 「是接口方法」 的实现,那么可存在如下两种case(这两种case的公用逻辑:约束规则以接口为准,有几个就生效几个,没有就没有):

    • 保持和接口方法 「一毛一样」 的约束条件(极限情况:接口没约束注解,那你也不能有)

    • 实现类 「一个都不写」 约束条件,结果就是接口里有约束就有,没约束就没有

  • 如果该方法不是接口方法的实现,那就很简单了:该咋地就咋地

值得注意的是,在和Spring整合使用中还会涉及到一个问题:@Validated注解应该放在接口(方法)上,还是实现类(方法)上?你不妨可以自己先想想呢,答案那必然是后面分享喽。

✍总结

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

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

最后,提个小问题哈:你觉得是代码量越多越安全,还是越少越健壮呢?被验证过100次的代码能不要每次都还需要重复去验证吗?

✔推荐阅读:

NVFzUvI.gif!mobile

关注A哥,开启专栏式学习

zuiU3eN.jpg!mobilej6JNJzV.png!mobile

扫码关注后,回复“专栏”进入更多Spring专栏学习

右侧为私人微信(加好友备注:Java入群)

个人站点 (本文已收录) :https://www.yourbatman.cn


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK