2. Bean Validation聲明式校驗方法的參數、返回值

你必須非常努力,才能幹起來毫不費力。本文已被 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;
    }

}

現在對該方法的執行,有如下約束要求:

  1. id是必傳(不爲null)且最小值爲1,但對name沒有要求
  2. 返回值不能爲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;
}

這麼寫固然是沒毛病的,但是它的弊端也非常明顯:

  1. 這類代碼沒啥營養,如果校驗邏輯稍微多點就會顯得臭長臭長的
  2. 不看你的執行邏輯,調用者無法知道你的語義。比如它並不知道id是傳還是不傳也行,沒有形成契約
  3. 代碼侵入性強

優化方案

既然學習了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方式供以使用:

  1. 基於Java EE的@Inteceptors實現
  2. 基於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;
}

同樣的,這種代碼依舊有如下三個問題:

  1. 這類代碼沒啥營養,如果校驗邏輯稍微多點就會顯得臭長臭長的
  2. 不看你的執行邏輯,調用者無法知道你的語義。比如調用者不知道返回是是否可能爲null,沒有形成契約
  3. 代碼侵入性強

優化方案

話不多說,直接上代碼。

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) {
}

約束上可以提出如下合理要求:

  1. person不能爲null
  2. 是個合法的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次的代碼能不要每次都還需要重複去驗證嗎?

✔推薦閱讀:
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章