天天看點

6. 自定義容器類型元素驗證,類級别驗證(多字段聯合驗證)✍前言✍正文✍總結

今天搬磚不狠,明天地位不穩。本文已被 https://www.yourbatman.cn 收錄,裡面一并有Spring技術棧、MyBatis、JVM、中間件等小而美的專欄供以免費學習。關注公衆号【BAT的烏托邦】逐個擊破,深入掌握,拒絕淺嘗辄止。
6. 自定義容器類型元素驗證,類級别驗證(多字段聯合驗證)✍前言✍正文✍總結

✍前言

你好,我是YourBatman。

本文是上篇文章的續篇,個人建議可先花3分鐘移步上篇文章浏覽一下:

5. Bean Validation聲明式驗證四大級别:字段、屬性、容器元素、類

很多人說Bean Validation隻能驗證單屬性(單字段),但我卻說它能完成99.99%的Bean驗證,不信你可繼續閱讀本文,能否解你疑惑。

版本約定

  • Bean Validation版本:

    2.0.2

  • Hibernate Validator版本:

    6.1.5.Final

✍正文

本文接上文叙述,繼續介紹Bean Validation聲明式驗證四大級别中的:容器元素驗證(自定義容器類型)以及類級别驗證(也叫多字段聯合驗證)。

據我了解,很多小夥伴對這部分内容并不熟悉,遇到類似場景往往被迫隻能是一半BV驗證 + 一半事務腳本驗證的方式,顯得洋不洋俗不俗。 本文将給出具體案例場景,然後統一使用BV來解決資料驗證問題,希望可以幫助到你,給予參考之作用。

自定義容器類型元素驗證

通過

上文

我們已經知道了Bean Validation是可以對形如List、Set、Map這樣的容器類型裡面的元素進行驗證的,内置支援的容器雖然能cover大部分的使用場景,但不免有的場景依舊不能覆寫,而且這個可能還非常常用。

譬如我們都不陌生的方法傳回值容器

Result<T>

,結構形如這樣(最簡形式,僅供參考):

@Data
public final class Result<T> implements Serializable {

    private boolean success = true;
    private T data = null;
    
    private String errCode;
    private String errMsg;
}           

Controller層用它包裝(裝載)資料data,形如這樣:

@GetMapping("/room")
Result<Room> room() { ... }

public class Room {
    @NotNull
    public String name;
    @AssertTrue
    public boolean finished;
}           

這個時候希望對

Result<Room>

裡面的

Room

進行合法性驗證:借助BV進行聲明式驗證而非寫死。希望這麼寫就可以了:

Result<@Notnull @Valid LoggedAccountResp>

。顯然,預設情況下即使這樣聲明了限制注解也是無效的,畢竟Bean Validation根本就“不認識”Result這個“容器”,更别提驗證其元素了。

好在Bean Validation對此提供了擴充點。下面我将一步一步的來對此提供實作,讓驗證優雅再次起來。

  • 自定義一個可以從

    Result<T>

    裡提取出T值的

    ValueExtractor

    值提取器

Bean Validation允許我們對自定義容器元素類型進行支援。通過前面這篇文章:

4. Validator校驗器的五大核心元件,一個都不能少

知道要想支援自定義的容器類型,需要注冊一個自定義的

ValueExtractor

用于值的提取。

/**
 * 在此處添加備注資訊
 *
 * @author yourbatman
 * @site https://www.yourbatman.cn
 * @date 2020/10/25 10:01
 * @see Result
 */
public class ResultValueExtractor implements ValueExtractor<Result<@ExtractedValue ?>> {
    
    @Override
    public void extractValues(Result<?> originalValue, ValueReceiver receiver) {
        receiver.value(null, originalValue.getData());
    }
}           
  • 将此自定義的值提取器注冊進驗證器Validator裡,并提供測試代碼:

把Result作為一個Filed字段裝進Java Bean裡:

public class ResultDemo {
    public Result<@Valid Room> roomResult;
}           

測試代碼:

public static void main(String[] args) {
    Room room = new Room();
    room.name = "YourBatman";
    Result<Room> 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));
}           

運作測試程式,輸出:

roomResult.finished隻能為true,但你的值是: false           

完美的實作了對Result“容器”裡的元素進行了驗證。

小貼士:本例是把Result作為Java Bean的屬性進行試驗的。實際上大多數情況下是把它作為方法傳回值進行校驗。方式類似,有興趣的同學可自行舉一反三哈

在此弱弱補一句,若在Spring Boot場景下你想像這樣對

Result<T>

提供支援,那麼你需要自行提供一個驗證器來覆寫掉自動裝配進去的,可參考

ValidationAutoConfiguration

類級别驗證(多字段聯合驗證)

限制也可以放在類級别上(也就說注解标注在類上)。在這種情況下,驗證的主體不是單個屬性,而是整個對象。如果驗證依賴于對象的幾個屬性之間的相關性,那麼類級别限制就能搞定這一切。

這個需求場景在平時開發中也非常常見,比如此處我舉個場景案例:

Room

表示一個教室,

maxStuNum

表示該教室允許的最大學生數,

studentNames

表示教室裡面的學生們。很明顯這裡存在這麼樣一個規則:學生總數不能大于教室允許的最大值,即

studentNames.size() <= maxStuNum

。如果用事務腳本來實作這個驗證規則,那麼你的代碼裡肯定穿插着類似這樣的代碼:

if (room.getStudentNames().size() > room.getMaxStuNum()) {
    throw new RuntimeException("...");
}           

雖然這麼做也能達到校驗的效果,但很明顯這不夠優雅。期望這種case依舊能借助Bean Validation來優雅實作,下面我來走一把。

相較于前面但字段/屬性驗證的使用case,這個需要驗證的是整個對象(多個字段)。下面呀,我給出兩種實作方式,供以參考。

方式一:基于内置的@ScriptAssert實作

雖說Bean Validation沒有内置任何類級别的注解,但Hibernate-Validator卻對此提供了增強,彌補了其不足。

@ScriptAssert

就是HV内置的一個非常強大的、可以用于類級别驗證注解,它可以很容易的處理這種case:

@ScriptAssert(lang = "javascript", alias = "_", script = "_.maxStuNum >= _.studentNames.length")
@Data
public class Room {
    @Positive
    private int maxStuNum;
    @NotNull
    private List<String> studentNames;
}           

@ScriptAssert

支援寫腳本來完成驗證邏輯,這裡使用的是javascript(預設情況下的唯一選擇,也是預設選擇)

測試用例:

public static void main(String[] args) {
    Room room = new Room();
    ValidatorUtil.printViolations(ValidatorUtil.obtainValidator().validate(room));
}           

運作程式,抛錯:

Caused by: <eval>: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):

public static void main(String[] args) {
    Room room = new Room();
    room.setStudentNames(Collections.singletonList("YourBatman"));

    ValidatorUtil.printViolations(ValidatorUtil.obtainValidator().validate(room));
}           

再次運作,輸出:

執行腳本表達式"_.maxStuNum >= _.studentNames.length"沒有傳回期望結果,但你的值是: Room(maxStuNum=0, studentNames=[YourBatman])
maxStuNum必須是正數,但你的值是: 0           

驗證結果符合預期:0(maxStuNum) < 1(studentNames.length)。

小貼士:若測試腳本中增加一句

room.setMaxStuNum(1);

,那麼請問結果又如何呢?

方式二:自定義注解方式實作

雖說BV自定義注解前文還暫沒提到,但這并不難,是以這裡先混個臉熟,也可在閱讀到後面文章後再殺個回馬槍回來。

  • 自定義一個限制注解,并且提供限制邏輯的實作
@Target({TYPE, ANNOTATION_TYPE})
@Retention(RUNTIME)
@Constraint(validatedBy = {ValidStudentCountConstraintValidator.class})
public @interface ValidStudentCount {
    String message() default "學生人數超過最大限額";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}           
public class ValidStudentCountConstraintValidator implements ConstraintValidator<ValidStudentCount, Room> {

    @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;
    }
}           
  • 書寫測試腳本
public static void main(String[] args) {
    Room room = new Room();
    room.setStudentNames(Collections.singletonList("YourBatman"));

    ValidatorUtil.printViolations(ValidatorUtil.obtainValidator().validate(room));
}           

運作程式,輸出:

maxStuNum必須是正數,但你的值是: 0
studentNames校驗失敗xxx,但你的值是: Room(maxStuNum=0, studentNames=[YourBatman])           

完美,完全符合預期。

這兩種方式都可以實作類級别的驗證,它倆可以說各有優劣,主要展現在如下方面:

  • @ScriptAssert

    是内置就提供的,是以使用起來非常的友善和通用。但缺點也是因為過于通用,是以語義上不夠明顯,需要閱讀腳本才知。推薦少量(非重複使用)、邏輯較為簡單時使用
  • 自定義注解方式。缺點當然是“開箱使用”起來稍顯麻煩,但它的優點就是語義明确,靈活且不易出錯,即使是複雜的驗證邏輯也能輕松搞定

總之,若你的驗證邏輯隻用一次(隻一個地方使用)且簡單(比如隻是簡單判斷而已),推薦使用

@ScriptAssert

更為輕巧。否則,你懂的~

✍總結

如果說能熟練使用Bean Validation進行字段、屬性、容器元素級别的驗證是及格60分的話,那麼能夠使用BV解決本文中幾個場景問題的話就應該達到優秀級80分了。

本文舉例的兩個場景:

Result<T>

和多字段聯合驗證均屬于平時開發中比較常見的場景,如果能讓Bean Validation介入幫解決此類問題,相信對提效是很有幫助的,說不定你還能成為團隊中最靓的仔呢。

✔推薦閱讀: