1. 程式人生 > >2. Bean Validation宣告式校驗方法的引數、返回值

2. Bean Validation宣告式校驗方法的引數、返回值

> 你必須非常努力,才能幹起來毫不費力。本文已被 [**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 constructor, Object[] parameterValues, Class... groups); Set> validateConstructorReturnValue(Constructor 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)