你是否考慮過這個問題:很多時候,我們只是一些簡單的獨立參數(比如方法入參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 需要在1和18之間: -1
這是最經典的應用了。那么問題來了,如果你的方法參數就是個Java Bean,你該如何對它進行校驗呢?
小貼士:有的人認為把約束注解標注在屬性上,和標注在set方法上效果是一樣的,其實不然,你有這種錯覺全是因為Spring幫你處理了寫東西,至於原因將在后面和Spring整合使用時展開
校驗方法
對方法的校驗是本文的重點。比如我有個Service如下:
public class PersonService { public Person getOne(Integer id, String name) { return null; } }
現在對該方法的執行,有如下約束要求:
- id是必傳(不為null)且最小值為1,但對name沒有要求
- 返回值不能為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; }
這么寫固然是沒毛病的,但是它的弊端也非常明顯:
- 這類代碼沒啥營養,如果校驗邏輯稍微多點就會顯得臭長臭長的
- 不看你的執行邏輯,調用者無法知道你的語義。比如它並不知道id是傳還是不傳也行,沒有形成契約
- 代碼侵入性強
優化方案
既然學習了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(0, "A哥"); }
運行程序,控制台輸出:
getOne.arg0 最小不能小於1: 0 java.lang.IllegalArgumentException: 參數錯誤 ...
完美的符合預期。不過,arg0是什么鬼?如果你有興趣可以自行加上編譯參數-parameters
再運行試試,有驚喜哦~
通過把約束規則用注解寫上去,成功的解決上面3個問題中的兩個,特別是聲明式約束解決問題3,這對於平時開發效率的提升是很有幫助的,因為契約已形成。
此外還剩一個問題:代碼侵入性強。是的,相比起來校驗的邏輯依舊寫在了方法體里面,但一聊到如何解決代碼侵入問題,相信不用我說都能想到AOP。一般來說,我們有兩種AOP方式供以使用:
- 基於Java EE的@Inteceptors實現
- 基於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; }
同樣的,這種代碼依舊有如下三個問題:
- 這類代碼沒啥營養,如果校驗邏輯稍微多點就會顯得臭長臭長的
- 不看你的執行邏輯,調用者無法知道你的語義。比如調用者不知道返回是是否可能為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 = 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)
的判斷了呢?這就是契約編程的力量,在團隊內能指數級的提升編程效率,試試吧~