2. Bean Validation宣告式校驗方法的引數、返回值
阿新 • • 發佈:2020-09-02
> 你必須非常努力,才能幹起來毫不費力。本文已被 [**https://www.yourbatman.cn**](https://www.yourbatman.cn) 收錄,裡面一併有Spring技術棧、MyBatis、JVM、中介軟體等小而美的**專欄**供以免費學習。關注公眾號【**BAT的烏托邦**】逐個擊破,深入掌握,拒絕淺嘗輒止。
[TOC]
![](https://img-blog.csdnimg.cn/20200827172656560.png#pic_center)
# ✍前言
你好,我是YourBatman。
[上篇文章](https://mp.weixin.qq.com/s/g04HMhrjbvbPn1Mb9JYa5g) 完整的介紹了JSR、Bean Validation、Hibernate Validator的聯絡和區別,並且程式碼演示瞭如何進行基於註解的Java Bean校驗,自此我們可以在Java世界進行更完美的**契約式程式設計**了,不可謂不方便。
但是你是否考慮過這個問題:很多時候,我們只是一些簡單的獨立引數(比如方法入參int age),並不需要大動干戈的弄個Java Bean裝起來,比如我希望像這樣寫達到相應約束效果:
```java
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 `:
```java
public interface ExecutableValidator {
// 方法校驗:引數+返回值
Set> validateParameters(T object,
Method method,
Object[] parameterValues,
Class>... groups);
Set> validateReturnValue(T object,
Method method,
Object returnValue,
Class>... groups);
// 構造器校驗:引數+返回值
Set> validateConstructorParameters(Constructor extends T> constructor,
Object[] parameterValues,
Class>... groups);
Set> validateConstructorReturnValue(Constructor extends T> constructor,
T createdObject,
Class>... groups);
}
```
其實我們對`Executable`這個字眼並不陌生,向JDK的介面`java.lang.reflect.Executable`它的唯二兩個實現便是Method和Constructor,剛好和這裡相呼應。
在下面的程式碼示例之前,先提供兩個方法用於獲取校驗器(使用預設配置),方便後續使用:
```java
// 用於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),宣告上約束註解:
```java
@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> result = validator.validate(person);
// 輸出校驗結果
result.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValue()).forEach(System.out::println);
}
```
執行程式,控制檯輸出:
```java
name 不能為null: null
age 需要在1和18之間: -1
```
這是最經典的應用了。那麼問題來了,如果你的方法引數就是個Java Bean,你該如何對它進行校驗呢?
> 小貼士:有的人認為把約束註解標註在屬性上,和標註在set方法上效果是一樣的,**其實不然**,你有這種錯覺全是因為Spring幫你處理了寫東西,至於原因將在後面和Spring整合使用時展開
## 校驗方法
對方法的校驗是本文的重點。比如我有個Service如下:
```java
public class PersonService {
public Person getOne(Integer id, String name) {
return null;
}
}
```
現在對該方法的執行,有如下**約束**要求:
1. id是**必傳**(不為null)且**最小值為1**,但對name沒有要求
2. 返回值不能為null
下面分為校驗方法引數和校驗返回值兩部分分別展開。
### 校驗方法引數
如上,getOne方法有兩個入參,我們需要對id這個引數做校驗。如果不使用Bean Validation的話程式碼就需要這麼寫校驗邏輯:
```java
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,關於校驗方面的工作交給更專業的它當然更加優雅:
```java
public Person getOne(@NotNull @Min(1) Integer id, String name) throws NoSuchMethodException {
// 校驗邏輯
Method currMethod = this.getClass().getMethod("getOne", Integer.class, String.class);
Set> 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;
}
```
測試程式就很簡單嘍:
```java
@Test
public void test2() throws NoSuchMethodException {
new PersonService().getOne(0, "A哥");
}
```
執行程式,控制檯輸出:
```java
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)`的判斷嘛:
```java
public Person getOne(Integer id, String name) throws NoSuchMethodException {
// ... 模擬邏輯執行,得到一個result結果,準備返回
Person result = null;
// 在結果返回之前校驗
if (result == null) {
throw new IllegalArgumentException("返回結果不能為null");
}
return result;
}
```
同樣的,這種程式碼依舊有如下三個問題:
1. 這類程式碼沒啥營養,如果校驗邏輯稍微多點就會顯得臭長臭長的
2. 不看你的執行邏輯,呼叫者無法知道你的語義。比如呼叫者不知道返回是是否可能為null,**沒有形成契約**
3. 程式碼侵入性強
#### 優化方案
話不多說,直接上程式碼。
```java
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> 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;
}
```
書寫測試程式碼:
```java
@Test
public void test2() throws NoSuchMethodException {
// 看到沒 IDEA自動幫你前面加了個notNull
@NotNull Person result = new PersonService().getOne(1, "A哥");
}
```
執行程式,控制檯輸出:
```java
getOne. 不能為null: null
java.lang.IllegalArgumentException: 引數錯誤
...
```
這裡面有個小細節:當你呼叫getOne方法,讓IDEA自動幫你填充返回值時,前面把校驗規則也給你顯示出來了,這就是**契約**。明明白白的,拿到這樣的result你是不是可以非常放心的使用,不再戰戰兢兢的啥都來個`if(xxx !=null)`的判斷了呢?這就是契約程式設計的力量,在團隊內能指數級的提升程式設計效率,試試吧~
## 校驗構造方法
這個,呃,(⊙o⊙)…...自己動手玩玩吧,記得牢~
## 加餐:Java Bean作為入參如何校驗?
如果一個Java Bean當方法引數,你該如何使用Bean Validation校驗呢?
```java
public void save(Person person) {
}
```
約束上可以提出如下合理要求:
1. person不能為null
2. 是個合法的person模型。換句話說:person裡面的那些校驗規則你都得遵守嘍
對save方法加上校驗如下:
```java
public void save(@NotNull Person person) throws NoSuchMethodException {
Method currMethod = this.getClass().getMethod("save", Person.class);
Set> 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("引數錯誤");
}
}
```
書寫測試程式:
```java
@Test
public void test3() throws NoSuchMethodException {
// save.arg0 不能為null: null
// new PersonService().save(null);
new PersonService().save(new Person());
}
```
執行程式,控制檯**沒有輸出**,也就是說校驗通過。很明顯,剛new出來的Person不是一個合法的模型物件,所以可以斷定**沒有執行**模型裡面的校驗邏輯,怎麼辦呢?難道仍要自己用Validator去用API校驗麼?
好拉,不賣關子了,這個時候就清楚大名鼎鼎的`@Valid`註解嘍,標註如下:
```java
public void save(@NotNull @Valid Person person) throws NoSuchMethodException { ... }
```
再次執行測試程式,控制檯輸出:
```java
save.arg0.name 不能為null: null
save.arg0.age 不能為null: null
java.lang.IllegalArgumentException: 引數錯誤
...
```
這才是真的完美了。
> 小貼士:`@Valid`註解用於驗證**級聯**的屬性、方法引數或方法返回型別。比如你的屬性仍舊是個Java Bean,你想深入進入校驗它裡面的約束,那就在此屬性頭上標註此註解即可。另外,通過使用@Valid可以實現**遞迴驗證**,因此可以標註在List上,對它裡面的每個物件都執行校驗
題外話一句:相信有小夥伴想問@Valid和Spring提供的@Validated有啥區別,我給的答案是:**完全不是一回事,純巧合而已**。至於為何這麼說,後面和Spring整合使用時給你講得明明白白的。
## 加餐2:註解應該寫在介面上還是實現上?
這是之前我面試時比較喜歡問的一個面試題,因為我認為這個題目的實用性還是比較大的。下面我們針對上面的save方法做個例子,提取一個接口出來,並且寫上**所有的**約束註解:
```java
public interface PersonInterface {
void save(@NotNull @Valid Person person) throws NoSuchMethodException;
}
```
子類實現,一個註解都不寫:
```java
public class PersonService implements PersonInterface {
@Override
public void save(Person person) throws NoSuchMethodException {
... // 方法體程式碼同上,略
}
}
```
測試程式也同上,為:
```java
@Test
public void test3() throws NoSuchMethodException {
// save.arg0 不能為null: null
// new PersonService().save(null);
new PersonService().save(new Person());
}
```
執行程式,控制檯輸出:
```java
save.arg0.name 不能為null: null
save.arg0.age 不能為null: null
java.lang.IllegalArgumentException: 引數錯誤
...
```
符合預期,沒有任何問題。這還沒完,還有很多組合方式呢,比如:約束註解全寫在實現類上;實現類比介面少;比介面多......
限於篇幅,文章裡對試驗過程我就不貼出來了,直接給你扔結論吧:
- 如果該方法**是介面方法**的實現,那麼可存在如下兩種case(這兩種case的公用邏輯:約束規則以介面為準,有幾個就生效幾個,沒有就沒有):
- 保持和介面方法**一毛一樣**的約束條件(極限情況:介面沒約束註解,那你也不能有)
- 實現類**一個都不寫**約束條件,結果就是接口裡有約束就有,沒約束就沒有
- 如果該方法不是介面方法的實現,那就很簡單了:該咋地就咋地
值得注意的是,在和Spring整合使用中還會涉及到一個問題:@Validated註解應該放在介面(方法)上,還是實現類(方法)上?你不妨可以自己先想想呢,答案那必然是後面分享嘍。
# ✍總結
本文講述的是Bean Validation又一經典實用場景:校驗方法的引數、返回值。後面加上和Spring的AOP整合將釋放出更大的能量。
另外,通過本文你應該能再次感受到**契約程式設計**帶來的好處吧,總之:能通過契約約定解決的就不要去硬編碼,人生苦短,少編碼多行樂。
最後,提個小問題哈:你覺得是程式碼量越多越安全,還是越少越健壯呢?被驗證過100次的程式碼能不要每次都還需要重複去驗證嗎?
##### ✔推薦閱讀:
- [1. 不吹不擂,第一篇就能提升你對Bean Validation資料校驗的認知](https://mp.weixin.qq.com/s/g04HMhrjbvbPn1Mb9JYa5g)