你必須非常努力,才能幹起來毫不費力。本文已被 https://www.yourbatman.cn 收錄,裡面一併有Spring技術棧、MyBatis、JVM、中間件等小而美的專欄供以免費學習。關注公眾號【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 需要在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)
的判斷了呢?這就是契約編程的力量,在團隊內能指數級的提升編程效率,試試吧~
校驗構造方法
這個,呃,(⊙o⊙)…...自己動手玩玩吧,記得牢~
加餐:Java Bean作為入參如何校驗?
如果一個Java Bean當方法參數,你該如何使用Bean Validation校驗呢?
public void save(Person person) {
}
約束上可以提出如下合理要求:
- person不能為null
- 是個合法的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次的代碼能不要每次都還需要重複去驗證嗎?