1. 程式人生 > >6. 自定義容器型別元素驗證,類級別驗證(多欄位聯合驗證)

6. 自定義容器型別元素驗證,類級別驗證(多欄位聯合驗證)

> 今天搬磚不狠,明天地位不穩。本文已被 [**https://www.yourbatman.cn**](https://www.yourbatman.cn) 收錄,裡面一併有Spring技術棧、MyBatis、JVM、中介軟體等小而美的**專欄**供以免費學習。關注公眾號【**BAT的烏托邦**】逐個擊破,深入掌握,拒絕淺嘗輒止。 [TOC] ![](https://img-blog.csdnimg.cn/20201025205032222.jpg) # ✍前言 你好,我是YourBatman。 本文是上篇文章的續篇,個人建議可先花3分鐘移步上篇文章瀏覽一下:[5. Bean Validation宣告式驗證四大級別:欄位、屬性、容器元素、類](https://mp.weixin.qq.com/s/6_7gZ9jmQcDSRiARO6D-yw) 很多人說Bean Validation只能驗證單屬性(單欄位),但我卻說它能完成99.99%的Bean驗證,不信你可繼續閱讀本文,能否解你疑惑。 ## 版本約定 - Bean Validation版本:`2.0.2` - Hibernate Validator版本:`6.1.5.Final` # ✍正文 本文接上文敘述,繼續介紹Bean Validation宣告式驗證四大級別中的:容器元素驗證(自定義容器型別)以及類級別驗證(也叫多欄位聯合驗證)。 據我瞭解,很多小夥伴對這部分內容並不熟悉,遇到類似場景往往被迫只能是**一半BV驗證 + 一半事務指令碼驗證**的方式,顯得洋不洋俗不俗。 本文將給出具體案例場景,然後統一使用BV來解決資料驗證問題,希望可以幫助到你,給予參考之作用。 ## 自定義容器型別元素驗證 通過[上文](https://mp.weixin.qq.com/s/6_7gZ9jmQcDSRiARO6D-yw)我們已經知道了Bean Validation是可以對形如List、Set、Map這樣的容器型別**裡面的元素**進行驗證的,內建支援的容器雖然能cover大部分的使用場景,但不免有的場景依舊不能覆蓋,而且這個可能還非常常用。 譬如我們都不陌生的方法返回值容器`Result`,結構形如這樣(最簡形式,僅供參考): ```java @Data public final class Result implements Serializable { private boolean success = true; private T data = null; private String errCode; private String errMsg; } ``` Controller層用它包裝(裝載)資料data,形如這樣: ```java @GetMapping("/room") Result room() { ... } public class Room { @NotNull public String name; @AssertTrue public boolean finished; } ``` 這個時候希望對`Result`裡面的`Room`進行合法性驗證:藉助BV進行宣告式驗證而非硬編碼。希望這麼寫就可以了:`Result<@Notnull @Valid LoggedAccountResp>`。顯然,預設情況下即使這樣聲明瞭約束註解也是無效的,畢竟Bean Validation根本就“不認識”Result這個“容器”,更別提驗證其元素了。 好在Bean Validation對此提供了擴充套件點。下面我將一步一步的來對此提供實現,讓驗證優雅再次起來。 - 自定義一個可以從`Result`裡提取出T值的`ValueExtractor`值提取器 Bean Validation允許我們對**自定義容器**元素型別進行支援。通過前面這篇文章:[4. Validator校驗器的五大核心元件,一個都不能少](https://mp.weixin.qq.com/s/jzOv67ZTSx2rByj0aeUTgw) 知道要想支援自定義的容器型別,需要註冊一個自定義的`ValueExtractor`用於值的提取。 ```java /** * 在此處新增備註資訊 * * @author yourbatman * @site https://www.yourbatman.cn * @date 2020/10/25 10:01 * @see Result */ public class ResultValueExtractor implements ValueExtractor> { @Override public void extractValues(Result originalValue, ValueReceiver receiver) { receiver.value(null, originalValue.getData()); } } ``` - 將此自定義的值提取器註冊進驗證器Validator裡,並提供測試程式碼: 把Result作為一個Filed欄位裝進Java Bean裡: ```java public class ResultDemo { public Result<@Valid Room> roomResult; } ``` 測試程式碼: ```java public static void main(String[] args) { Room room = new Room(); room.name = "YourBatman"; Result result = new Result<>(); result.setData(room); // 把Result作為屬性放進去 ResultDemo resultDemo = new ResultDemo(); resultDemo.roomResult = result; // 註冊自定義的值提取器 Validator validator = ValidatorUtil.obtainValidatorFactory() .usingContext() .addValueExtractor(new ResultValueExtractor()) .getValidator(); ValidatorUtil.printViolations(validator.validate(resultDemo)); } ``` 執行測試程式,輸出: ```java roomResult.finished只能為true,但你的值是: false ``` 完美的實現了對Result“容器”裡的元素進行了驗證。 > 小貼士:本例是把Result作為Java Bean的屬性進行試驗的。實際上大多數情況下是把它作為**方法返回值**進行校驗。方式類似,有興趣的同學可自行舉一反三哈 在此弱弱補一句,若在Spring Boot場景下你想像這樣對`Result`提供支援,那麼你需要自行提供一個驗證器來**覆蓋掉**自動裝配進去的,可參考`ValidationAutoConfiguration`。 ## 類級別驗證(多欄位聯合驗證) 約束也可以放在**類級別**上(也就說註解標註在類上)。在這種情況下,驗證的主體不是單個屬性,而是整個物件。如果驗證依賴於物件的**幾個屬性**之間的相關性,那麼類級別約束就能搞定這一切。 這個需求場景在平時開發中也非常常見,比如此處我舉個場景案例:`Room`表示一個教室,`maxStuNum`表示該教室允許的最大學生數,`studentNames`表示教室裡面的學生們。很明顯這裡存在這麼樣一個規則:學生總數不能大於教室允許的最大值,即`studentNames.size() <= maxStuNum`。如果用事務指令碼來實現這個驗證規則,那麼你的程式碼裡肯定穿插著類似這樣的程式碼: ```java if (room.getStudentNames().size() > room.getMaxStuNum()) { throw new RuntimeException("..."); } ``` 雖然這麼做也能達到校驗的效果,但很明顯這不夠優雅。期望這種case依舊能借助Bean Validation來優雅實現,下面我來走一把。 相較於前面但欄位/屬性驗證的使用case,這個需要驗證的是**整個物件**(多個欄位)。下面呀,我給出**兩種**實現方式,供以參考。 ### 方式一:基於內建的@ScriptAssert實現 雖說Bean Validation沒有內建任何類級別的註解,但Hibernate-Validator卻對此提供了增強,彌補了其不足。`@ScriptAssert`就是HV內建的一個非常強大的、可以用於類級別驗證註解,它可以很容易的處理這種case: ```java @ScriptAssert(lang = "javascript", alias = "_", script = "_.maxStuNum >= _.studentNames.length") @Data public class Room { @Positive private int maxStuNum; @NotNull private List studentNames; } ``` > `@ScriptAssert`支援寫指令碼來完成驗證邏輯,這裡使用的是javascript(預設情況下的唯一選擇,也是預設選擇) 測試用例: ```java public static void main(String[] args) { Room room = new Room(); ValidatorUtil.printViolations(ValidatorUtil.obtainValidator().validate(room)); } ``` 執行程式,拋錯: ```java Caused by: :1 TypeError: Cannot get property "length" of null at jdk.nashorn.internal.runtime.ECMAErrors.error(ECMAErrors.java:57) at jdk.nashorn.internal.runtime.ECMAErrors.typeError(ECMAErrors.java:213) ... ``` 這個報錯意思是`_.studentNames`值為null,也就是`room.studentNames`欄位的值為null。 what?它頭上不明明標了`@NotNull`註解嗎,怎麼可能為null呢?這其實涉及到前面所講到的一個小知識點,這裡提一嘴:**所有的約束註解都會執行,不存在短路效果**(除非校驗程式拋異常),只要你敢標,我就敢執行,所以這裡為嘛報錯你懂了吧。 > 小貼士:@ScriptAssert對null值並不免疫,不管咋樣它都會執行的,因此書寫指令碼時注意判空哦 當然嘍,多個約束之間的執行也是可以排序(有序的),這就涉及到多個約束的執行順序(序列)問題,本文暫且繞過。例子種先給填上一個值,後續再專文詳解多個約束註解執行序列問題和案例剖析。 修改測試指令碼(增加一個學生,讓其不為null): ```java public static void main(String[] args) { Room room = new Room(); room.setStudentNames(Collections.singletonList("YourBatman")); ValidatorUtil.printViolations(ValidatorUtil.obtainValidator().validate(room)); } ``` 再次執行,輸出: ```java 執行指令碼表示式"_.maxStuNum >= _.studentNames.length"沒有返回期望結果,但你的值是: Room(maxStuNum=0, studentNames=[YourBatman]) maxStuNum必須是正數,但你的值是: 0 ``` 驗證結果符合預期:0(maxStuNum) < 1(studentNames.length)。 > 小貼士:若測試指令碼中增加一句`room.setMaxStuNum(1);`,那麼請問結果又如何呢? ### 方式二:自定義註解方式實現 雖說BV自定義註解前文還暫沒提到,但這並不難,因此這裡先混個臉熟,也可在閱讀到後面文章後再殺個回馬槍回來。 - 自定義一個約束註解,並且提供約束邏輯的實現 ```java @Target({TYPE, ANNOTATION_TYPE}) @Retention(RUNTIME) @Constraint(validatedBy = {ValidStudentCountConstraintValidator.class}) public @interface ValidStudentCount { String message() default "學生人數超過最大限額"; Class[] groups() default {}; Class[] payload() default {}; } ``` ```java public class ValidStudentCountConstraintValidator implements ConstraintValidator { @Override public void initialize(ValidStudentCount constraintAnnotation) { } @Override public boolean isValid(Room room, ConstraintValidatorContext context) { if (room == null) { return true; } boolean isValid = false; if (room.getStudentNames().size() <= room.getMaxStuNum()) { isValid = true; } // 自定義提示語(當然你也可以不自定義,那就使用註解裡的message欄位的值) if (!isValid) { context.disableDefaultConstraintViolation(); context.buildConstraintViolationWithTemplate("校驗失敗xxx") .addPropertyNode("studentNames") .addConstraintViolation(); } return isValid; } } ``` - 書寫測試指令碼 ```java public static void main(String[] args) { Room room = new Room(); room.setStudentNames(Collections.singletonList("YourBatman")); ValidatorUtil.printViolations(ValidatorUtil.obtainValidator().validate(room)); } ``` 執行程式,輸出: ```java maxStuNum必須是正數,但你的值是: 0 studentNames校驗失敗xxx,但你的值是: Room(maxStuNum=0, studentNames=[YourBatman]) ``` 完美,完全符合預期。 這兩種方式都可以實現類級別的驗證,它倆可以說各有優劣,主要體現在如下方面: - `@ScriptAssert`是內建就提供的,因此使用起來非常的方便和通用。但缺點也是因為過於通用,因此語義上不夠明顯,需要閱讀指令碼才知。推薦少量(非重複使用)、邏輯較為簡單時使用 - 自定義註解方式。缺點當然是“開箱使用”起來稍顯麻煩,但它的優點就是語義明確,靈活且不易出錯,即使是複雜的驗證邏輯也能輕鬆搞定 總之,若你的驗證邏輯只用一次(只一個地方使用)且簡單(比如只是簡單判斷而已),推薦使用`@ScriptAssert`更為輕巧。否則,你懂的~ # ✍總結 如果說能熟練使用Bean Validation進行欄位、屬性、容器元素級別的驗證是及格60分的話,那麼能夠使用BV解決本文中幾個場景問題的話就應該達到優秀級80分了。 本文舉例的兩個場景:`Result`和多欄位聯合驗證均屬於平時開發中比較常見的場景,如果能讓Bean Validation介入幫解決此類問題,相信對提效是很有幫助的,說不定你還能成為團隊中最靚的仔呢。 ##### ✔推薦閱讀: - [1. 不吹不擂,第一篇就能提升你對Bean Validation資料校驗的認知](https://mp.weixin.qq.com/s/g04HMhrjbvbPn1Mb9JYa5g) - [2. Bean Validation宣告式校驗方法的引數、返回值](https://mp.weixin.qq.com/s/-KeOCq2rsXCvrqD8HYHSpQ) - [3. 站在使用層面,Bean Validation這些標準介面你需要爛熟於胸](https://mp.weixin.qq.com/s/MQjXG0cg8domRtwf3ArvHw) - [4. Validator校驗器的五大核心元件,一個都不能少](https://mp.weixin.qq.com/s/jzOv67ZTSx2rByj0aeUTgw) - [5. Bean Validation宣告式驗證四大級別:欄位、屬性、容器元素、類](https://mp.weixin.qq.com/s/6_7gZ9jmQcDSRiARO6D-yw)