解決多字段聯合邏輯校驗問題【享學Spring MVC】
- 2019 年 10 月 3 日
- 筆記
每篇一句
不要像祥林嫂一樣,天天抱怨着生活,日日思考着辭職。得罪點說一句:「淪落」到要跟這樣的人共事工作,難道自己身上就沒有原因?
前言
本以為洋洋洒洒的把Java/Spring數據(綁定)校驗這塊說了這麼多,基本已經算完結了。但今天中午一位熱心小夥伴在使用Bean Validation做數據校驗時上遇到了一個稍顯特殊的case,由於此校驗場景也比較常見,因此便有了本文對數據校驗補充。
關於Java/Spring中的數據校驗,我有理由堅信你肯定遇到過這樣的場景需求:在對JavaBean進行校驗時,b屬性的校驗邏輯是依賴於a屬性的值的;換個具象的例子說:當且僅當屬性a的值=xxx時,屬性b的校驗邏輯才生效。這也就是我們常說的多字段聯合校驗邏輯~
因為這個校驗的case比較常見,因此促使了我記錄本文的動力,因為它會變得有意義和有價值。當然對此問題有的小夥伴說可以自己用if else來處理呀,也不是很麻煩。本文的目的還是希望對數據校驗一以貫之的做到更清爽、更優雅、更好擴展而努力。
需要有一點堅持:既然用了
Bean Validation去簡化校驗,那就(最好)不要用得四不像,遇到問題就解決問題~
熱心網友問題描述
為了更真實的還原問題場景,我貼上聊天截圖如下:

待校驗的請求JavaBean如下:

校需求描述簡述如下:

這位網友描述的真實生產場景問題,這也是本文講解的內容所在。
雖然這是在Spring MVC條件的下使用的數據校驗,但按照我的習慣為了更方便的說明問題,我會把此部分功能單摘出來,說清楚了方案和原理,再去實施解決問題本身(文末)~
方案和原理
對於單字段的校驗、級聯屬性校驗等,通過閱讀我的系列文章,我有理由相信小夥伴們都能駕輕就熟了的。本文給出一個最簡單的例子簡單"複習"一下:
@Getter @Setter @ToString public class Person { @NotNull private String name; @NotNull @Range(min = 10, max = 40) private Integer age; @NotNull @Size(min = 3, max = 5) private List<String> hobbies; // 級聯校驗 @Valid @NotNull private Child child; }
測試:
public static void main(String[] args) { Person person = new Person(); person.setName("fsx"); person.setAge(5); person.setHobbies(Arrays.asList("足球","籃球")); person.setChild(new Child()); Set<ConstraintViolation<Person>> result = Validation.buildDefaultValidatorFactory().getValidator().validate(person); // 對結果進行遍歷輸出 result.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValue()).forEach(System.out::println); }
運行,打印輸出:
child.name 不能為null: null age 需要在10和40之間: 5 hobbies 個數必須在3和5之間: [足球,籃球]
結果符合預期,(級聯)校驗生效。
通過使用
@Valid可以實現遞歸驗證,因此可以標註在List上,對它裏面的每個對象都執行校驗
問題來了,針對上例,現在我有如下需求:
- 若20 <= age < 30,那麼
hobbies的size需介於1和2之間 - 若30 <= age < 40,那麼
hobbies的size需介於3和5之間 -
age其餘值,
hobbies無校驗邏輯實現方案
Hibernate Validator提供了非標準的@GroupSequenceProvider註解。本功能提供根據當前對象實例的狀態,動態來決定加載那些校驗組進入默認校驗組。
為了實現上面的需求達到目的,我們需要藉助Hibernate Validation提供給我們的DefaultGroupSequenceProvider接口來處理。
// 該接口定義了:動態Group序列的協定 // 要想它生效,需要在T上標註@GroupSequenceProvider註解並且指定此類為處理類 // 如果`Default`組對T進行驗證,則實際驗證的實例將傳遞給此類以確定默認組序列(這句話特別重要 下面用例子解釋) public interface DefaultGroupSequenceProvider<T> { // 合格方法是給T返回默認的組(多個)。因為默認的組是Default嘛~~~通過它可以自定指定 // 入參T object允許在驗證值狀態的函數中動態組合默認組序列。(非常強大) // object是待校驗的Bean。它可以為null哦~(Validator#validateValue的時候可以為null) // 返回值表示默認組序列的List。它的效果同@GroupSequence定義組序列,尤其是列表List必須包含類型T List<Class<?>> getValidationGroups(T object); }
注意:
- 此接口Hibernate並沒有提供實現
- 若你實現請必須提供一個空的構造函數以及保證是線程安全的
按步驟解決多字段組合驗證的邏輯:
1、自己實現DefaultGroupSequenceProvider接口(處理Person這個Bean)
public class PersonGroupSequenceProvider implements DefaultGroupSequenceProvider<Person> { @Override public List<Class<?>> getValidationGroups(Person bean) { List<Class<?>> defaultGroupSequence = new ArrayList<>(); defaultGroupSequence.add(Person.class); // 這一步不能省,否則Default分組都不會執行了,會拋錯的 if (bean != null) { // 這塊判空請務必要做 Integer age = bean.getAge(); System.err.println("年齡為:" + age + ",執行對應校驗邏輯"); if (age >= 20 && age < 30) { defaultGroupSequence.add(Person.WhenAge20And30Group.class); } else if (age >= 30 && age < 40) { defaultGroupSequence.add(Person.WhenAge30And40Group.class); } } return defaultGroupSequence; } }
2、在待校驗的javaBean里使用@GroupSequenceProvider註解指定處理器。並且定義好對應的校驗邏輯(包括分組)
@GroupSequenceProvider(PersonGroupSequenceProvider.class) @Getter @Setter @ToString public class Person { @NotNull private String name; @NotNull @Range(min = 10, max = 40) private Integer age; @NotNull(groups = {WhenAge20And30Group.class, WhenAge30And40Group.class}) @Size(min = 1, max = 2, groups = WhenAge20And30Group.class) @Size(min = 3, max = 5, groups = WhenAge30And40Group.class) private List<String> hobbies; /** * 定義專屬的業務邏輯分組 */ public interface WhenAge20And30Group { } public interface WhenAge30And40Group { } }
測試用例同上,做出簡單修改:person.setAge(25),運行打印輸出:
年齡為:25,執行對應校驗邏輯 年齡為:25,執行對應校驗邏輯
沒有校驗失敗的消息(就是好消息),符合預期。
再修改為person.setAge(35),再次運行打印如下:
年齡為:35,執行對應校驗邏輯 年齡為:35,執行對應校驗邏輯 hobbies 個數必須在3和5之間: [足球, 籃球]
校驗成功,結果符合預期。
從此案例可以看到,通過@GroupSequenceProvider我完全實現了多字段組合校驗的邏輯,並且代碼也非常的優雅、可擴展,希望此示例對你有所幫助。
本利中的provider處理器是Person專用的,當然你可以使用Object+反射讓它變得更為通用,但本着職責單一原則,我並不建議這麼去做。
使用JSR提供的@GroupSequence註解控制校驗順序
上面的實現方式是最佳實踐,使用起來不難,靈活度也非常高。但是我們必須要明白它是Hibernate Validation提供的能力,而不費JSR標準提供的。
@GroupSequence它是JSR標準提供的註解(只是沒有provider強大而已,但也有很適合它的使用場景)
// Defines group sequence. 定義組序列(序列:順序執行的) @Target({ TYPE }) @Retention(RUNTIME) @Documented public @interface GroupSequence { Class<?>[] value(); }
顧名思義,它表示Group組序列。默認情況下,不同組別的約束驗證是無序的
在某些情況下,約束驗證的順序是非常的重要的,比如如下兩個場景:
- 第二個組的約束驗證依賴於第一個約束執行完成的結果(必須第一個約束正確了,第二個約束執行才有意義)
- 某個Group組的校驗非常耗時,並且會消耗比較大的CPU/內存。那麼我們的做法應該是把這種校驗放到最後,所以對順序提出了要求
一個組可以定義為其他組的序列,使用它進行驗證的時候必須符合該序列規定的順序。在使用組序列驗證的時候,如果序列前邊的組驗證失敗,則後面的組將不再給予驗證。
給個栗子:
public class User { @NotEmpty(message = "firstname may be empty") private String firstname; @NotEmpty(message = "middlename may be empty", groups = Default.class) private String middlename; @NotEmpty(message = "lastname may be empty", groups = GroupA.class) private String lastname; @NotEmpty(message = "country may be empty", groups = GroupB.class) private String country; public interface GroupA { } public interface GroupB { } // 組序列 @GroupSequence({Default.class, GroupA.class, GroupB.class}) public interface Group { } }
測試:
public static void main(String[] args) { User user = new User(); // 此處指定了校驗組是:User.Group.class Set<ConstraintViolation<User>> result = Validation.buildDefaultValidatorFactory().getValidator().validate(user, User.Group.class); // 對結果進行遍歷輸出 result.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValue()).forEach(System.out::println); }
運行,控制台打印:
middlename middlename may be empty: null firstname firstname may be empty: null
現象:只有Default這個Group的校驗了,序列上其它組並沒有執行校驗。更改如下:
User user = new User(); user.setFirstname("f"); user.setMiddlename("s");
運行,控制台打印:
lastname lastname may be empty: null
現象:Default組都校驗通過後,執行了GroupA組的校驗。但GroupA組校驗木有通過,GroupB組的校驗也就不執行了~
@GroupSequence提供的組序列順序執行以及短路能力,在很多場景下是非常非常好用的。
針對本例的多字段組合邏輯校驗,若想藉助@GroupSequence來完成,相對來說還是比較困難的。但是也並不是不能做,此處我提供參考思路:
- 多字段之間的邏輯、「通信」通過類級別的自定義校驗註解來實現(至於為何必須是類級別的,不用解釋吧~)
@GroupSequence用來控制組執行順序(讓類級別的自定義註解先執行)- 增加Bean級別的第三屬性來輔助校驗~
當然嘍,在實際應用中不可能使用它來解決如題的問題,所以我此處就不費篇幅了。我個人建議有興趣者可以自己動手試試,有助於加深你對數據校驗這塊的理解。
這篇文章里有說過:數據校驗註解是可以標註在Field屬性、方法、構造器以及Class類級別上的。那麼關於它們的校驗順序,我們是可控的,並不是網上有些文章所說的無法抉擇~
說明:順序只能控制在分組級別,無法控制在約束註解級別。因為一個類內的約束(同一分組內),它的順序是
Set<MetaConstraint<?>> metaConstraints來保證的,所以可以認為同一分組內的校驗器是木有執行的先後順序的(不管是類、屬性、方法、構造器…)
所以網上有說:校驗順序是先校驗字段屬性,在進行類級別校驗不實,請注意辨別。
原理解析
本文中,我藉助@GroupSequenceProvider來解決了平時開發中多字段組合邏輯校驗的痛點問題,總的來說還是使用簡單,並且代碼也夠模塊化,易於維護的。
但對於上例的結果輸出,你可能和我一樣至少有如下疑問:
- 為何必須有這一句:
defaultGroupSequence.add(Person.class) - 為何
if (bean != null)必須判空 - 為何
年齡為:35,執行對應校驗邏輯被輸出了兩次(在判空裏面還出現了兩次哦~),但校驗的失敗信息卻只有符合預期的一次
帶着問題,我從validate校驗的執行流程上開始分析:
1、入口:ValidatorImpl.validate(T object, Class<?>... groups)
ValidatorImpl: @Override public final <T> Set<ConstraintViolation<T>> validate(T object, Class<?>... groups) { Class<T> rootBeanClass = (Class<T>) object.getClass(); // 獲取BeanMetaData,類上的各種信息:包括類上的Group序列、針對此類的默認分組List們等等 BeanMetaData<T> rootBeanMetaData = beanMetaDataManager.getBeanMetaData( rootBeanClass ); ... }
2、beanMetaDataManager.getBeanMetaData(rootBeanClass)得到待校驗Bean的元信息
請注意,此處只傳入了Class,並沒有傳入Object。這是為啥要加
!= null判空的核心原因(後面你可以看到傳入的是null)。
BeanMetaDataManager: public <T> BeanMetaData<T> getBeanMetaData(Class<T> beanClass) { ... // 會調用AnnotationMetaDataProvider來解析約束註解元數據信息(當然還有基於xml/Programmatic的,本文略) // 注意:它會遞歸處理父類、父接口等拿到所有類的元數據 // BeanMetaDataImpl.build()方法,會new BeanMetaDataImpl(...) 這個構造函數裏面做了N多事 // 其中就有和我本例有關的defaultGroupSequenceProvider beanMetaData = createBeanMetaData( beanClass ); }
3、new BeanMetaDataImpl( ... )構建出此Class的元數據信息(本例為Person.class)
BeanMetaDataImpl: public BeanMetaDataImpl(Class<T> beanClass, List<Class<?>> defaultGroupSequence, // 如果沒有配置,此時候defaultGroupSequence一般都為null DefaultGroupSequenceProvider<? super T> defaultGroupSequenceProvider, // 我們自定義的處理此Bean的provider Set<ConstraintMetaData> constraintMetaDataSet, // 包含父類的所有屬性、構造器、方法等等。在此處會分類:按照屬性、方法等分類處理 ValidationOrderGenerator validationOrderGenerator) { ... //對constraintMetaDataSet進行分類 // 這個方法就是篩選出了:所有的約束註解(比如6個約束註解,此處長度就是6 當然包括了字段、方法等上的各種。。。) this.directMetaConstraints = getDirectConstraints(); // 因為我們Person類有defaultGroupSequenceProvider,所以此處返回true // 除了定義在類上外,還可以定義全局的:給本類List<Class<?>> defaultGroupSequence此字段賦值 boolean defaultGroupSequenceIsRedefined = defaultGroupSequenceIsRedefined(); // 這是為何我們要判空的核心:看看它傳的啥:null。所以不判空的就NPE了。這是第一次調用defaultGroupSequenceProvider.getValidationGroups()方法 List<Class<?>> resolvedDefaultGroupSequence = getDefaultGroupSequence( null ); ... // 上面拿到resolvedDefaultGroupSequence 分組信息後,會放到所有的校驗器里去(包括屬性、方法、構造器、類等等) // so,默認組序列還是灰常重要的(注意:默認組可以有多個哦~~~) } @Override public List<Class<?>> getDefaultGroupSequence(T beanState) { if (hasDefaultGroupSequenceProvider()) { // so,getValidationGroups方法里請記得判空~ List<Class<?>> providerDefaultGroupSequence = defaultGroupSequenceProvider.getValidationGroups( beanState ); // 最重要的是這個方法:getValidDefaultGroupSequence對默認值進行分析~~~ return getValidDefaultGroupSequence( beanClass, providerDefaultGroupSequence ); } return defaultGroupSequence; } private static List<Class<?>> getValidDefaultGroupSequence(Class<?> beanClass, List<Class<?>> groupSequence) { List<Class<?>> validDefaultGroupSequence = new ArrayList<>(); boolean groupSequenceContainsDefault = false; // 標誌位:如果解析不到Default這個組 就拋出異常 // 重要 if (groupSequence != null) { for ( Class<?> group : groupSequence ) { // 這就是為何我們要`defaultGroupSequence.add(Person.class)`這一句的原因所在~~~ 因為需要Default生效~~~ if ( group.getName().equals( beanClass.getName() ) ) { validDefaultGroupSequence.add( Default.class ); groupSequenceContainsDefault = true; } // 意思是:你要添加Default組,用本類的Class即可,而不能顯示的添加Default.class哦~ else if ( group.getName().equals( Default.class.getName() ) ) { throw LOG.getNoDefaultGroupInGroupSequenceException(); } else { // 正常添加進默認組 validDefaultGroupSequence.add( group ); } } } // 若找不到Default組,就拋出異常了~ if ( !groupSequenceContainsDefault ) { throw LOG.getBeanClassMustBePartOfRedefinedDefaultGroupSequenceException( beanClass ); } return validDefaultGroupSequence; }
到這一步,還僅僅在初始化BeanMetaData階段,就執行了一次(首次)defaultGroupSequenceProvider.getValidationGroups(null),所以判空是很有必要的。並且把本class add進默認組也是必須的(否則報錯)~
到這裡BeanMetaData<T> rootBeanMetaData創建完成,繼續validate()的邏輯~
4、determineGroupValidationOrder(groups)從調用者指定的分組裡確定組序列(組的執行順序)
ValidatorImpl: @Override public final <T> Set<ConstraintViolation<T>> validate(T object, Class<?>... groups) { ... BeanMetaData<T> rootBeanMetaData = beanMetaDataManager.getBeanMetaData( rootBeanClass ); ... ... // 準備好ValidationContext(持有rootBeanMetaData和object實例) // groups是調用者傳進來的分組數組(對應Spring MVC中指定的Group信息~) ValidationOrder validationOrder = determineGroupValidationOrder(groups); ... // 準備好ValueContext(持有rootBeanMetaData和object實例) // 此時還是Bean級別的,開始對此bean執行校驗 return validateInContext( validationContext, valueContext, validationOrder ); } private ValidationOrder determineGroupValidationOrder(Class<?>[] groups) { Collection<Class<?>> resultGroups; // if no groups is specified use the default if ( groups.length == 0 ) { resultGroups = DEFAULT_GROUPS; } else { resultGroups = Arrays.asList( groups ); } // getValidationOrder()主要邏輯描述。此時候resultGroups 至少也是個[Default.class] // 1、如果僅僅只是一個Default.class,那就直接return // 2、遍歷所有的groups。(指定的Group必須必須是接口) // 3、若遍歷出來的group標註有`@GroupSequence`註解,特殊處理此序列(把序列里的分組們添加進來) // 4、普通的Group,那就new Group( clazz )添加進`validationOrder`里。並且遞歸插入(因為可能存在父接口的情況) return validationOrderGenerator.getValidationOrder( resultGroups ); }
到這ValidationOrder(實際為DefaultValidationOrder)保存着調用者調用validate()方法時傳入的Groups們。分組序列@GroupSequence在此時會被解析。
到了validateInContext( ... )就開始拿着這些Groups分組、元信息開始對此Bean進行校驗了~
5、validateInContext( ... )在上下文(校驗上下文、值上下文、指定的分組裡)對此Bean進行校驗
ValidatorImpl: private <T, U> Set<ConstraintViolation<T>> validateInContext(ValidationContext<T> validationContext, ValueContext<U, Object> valueContext, ValidationOrder validationOrder) { if ( valueContext.getCurrentBean() == null ) { // 兼容整個Bean為null值 return Collections.emptySet(); } // 如果該Bean頭上標註了(需要defaultGroupSequence處理),那就特殊處理一下 // 本例中我們的Person肯定為true,可以進來的 BeanMetaData<U> beanMetaData = valueContext.getCurrentBeanMetaData(); if ( beanMetaData.defaultGroupSequenceIsRedefined() ) { // 注意此處又調用了beanMetaData.getDefaultGroupSequence()這個方法,這算是二次調用了 // 此處傳入的Object喲~這就解釋了為何在判空裏面的 `年齡為:xxx`被打印了兩次的原因 // assertDefaultGroupSequenceIsExpandable方法是個空方法(默認情況下),可忽略 validationOrder.assertDefaultGroupSequenceIsExpandable( beanMetaData.getDefaultGroupSequence( valueContext.getCurrentBean() ) ); } // ==============下面對於執行順序,就很重要了=============== // validationOrder裝着的是調用者指定的分組(解析分組序列來保證順序~~~) // 需要特別注意:光靠指定分組,是無序的(不能保證校驗順序的) 所以若指定多個分組需要小心求證 Iterator<Group> groupIterator = validationOrder.getGroupIterator(); // 按照調用者指定的分組(順序),一個一個的執行分組校驗。 while ( groupIterator.hasNext() ) { Group group = groupIterator.next(); valueContext.setCurrentGroup(group.getDefiningClass()); // 設置當前正在執行的分組 // 這個步驟就稍顯複雜了,也是核心的邏輯之一。大致過程如下: // 1、拿到該Bean的BeanMetaData // 2、若defaultGroupSequenceIsRedefined()=true 本例Person標註了provder註解,所以有指定的分組序列的 // 3、根據分組序列的順序,挨個執行分組們(對所有的約束MetaConstraint都順序執行分組們) // 4、最終完成所有的MetaConstraint的校驗,進而完成此部分所有的字段、方法等的校驗 validateConstraintsForCurrentGroup( validationContext, valueContext ); if ( shouldFailFast( validationContext ) ) { return validationContext.getFailingConstraints(); } } ... // 和上面一樣的代碼,校驗validateCascadedConstraints // 繼續遍歷序列:和@GroupSequence相關了 Iterator<Sequence> sequenceIterator = validationOrder.getSequenceIterator(); ... // 校驗上下文的錯誤消息:它會把本校驗下,所有的驗證器上下文ConstraintValidatorContext都放一起的 // 注意:所有的校驗註解之間的上下文ConstraintValidatorContext是完全獨立的,無法互相訪問通信 return validationContext.getFailingConstraints(); }
that is all. 到這一步整個校驗就完成了,若不快速失敗,默認會拿到所有校驗失敗的消息。
真正執行isValid的方法在這裡:
public abstract class ConstraintTree<A extends Annotation> { ... protected final <T, V> Set<ConstraintViolation<T>> validateSingleConstraint( ValidationContext<T> executionContext, // 它能知道所屬類 ValueContext<?, ?> valueContext, ConstraintValidatorContextImpl constraintValidatorContext, ConstraintValidator<A, V> validator) { boolean isValid; // 解析出value值 V validatedValue = (V) valueContext.getCurrentValidatedValue(); // 把value值交給校驗器的isValid方法去校驗~~~ isValid = validator.isValid(validatedValue,constraintValidatorContext); ... if (!isValid) { // 校驗沒通過就使用constraintValidatorContext校驗上下文來生成錯誤消息 // 使用上下文是因為:畢竟錯誤消息可不止一個啊~~~ // 當然此處藉助了executionContext的方法~~~內部其實調用的是constraintValidatorContext.getConstraintViolationCreationContexts()這個方法而已 return executionContext.createConstraintViolations(valueContext, constraintValidatorContext); } } }
至於上下文ConstraintValidatorContext怎麼來的,是new出來的:new ConstraintValidatorContextImpl( ... ),每個字段的一個校驗註解對應一個上下文(一個屬性上可以標註多個約束註解哦~),所以此上下文是有很強的隔離性的。
ValidationContext<T> validationContext和ValueContext<?, Object> valueContext它哥倆是類級別的,直到ValidatorImpl.validateMetaConstraints方法開始一個一個約束器的校驗~
自定義註解中只把
ConstraintValidatorContext上下文給調用者使用,而並沒有給validationContext和valueContext,我個人覺得這個設計是不夠靈活的,無法方便的實現dependOn的效果~
解決網友的問題
我把這部分看似是本文最重要的引線放到最後,是因為我覺得我的描述已經解決這一類問題,而不是只解決了這一個問題。
回到文首截圖中熱心網友反應的問題,只要你閱讀了本文,我十分堅信你已經有辦法去使用Bean Validation優雅的解決了。如果各位沒有意見,此處我就略了~
總結
本文講述了使用@GroupSequenceProvider來解決多字段聯合邏輯校驗的這一類問題,這也許是曾經很多人的開發痛點,希望本文能幫你一掃之前的障礙,全面擁抱Bean Validation吧~
本文我也傳達了一個觀點:相信流行的開源東西的優秀,不是非常極端的case,深入使用它能解決你絕大部分的問題的。
相關閱讀
【小家Spring】@Validated和@Valid的區別?教你使用它完成Controller參數校驗(含級聯屬性校驗)以及原理分析
【小家Spring】Bean Validation完結篇:你必須關注的邊邊角角(約束級聯、自定義約束、自定義校驗器、國際化失敗消息…)
【小家Java】深入了解數據校驗:Java Bean Validation 2.0(JSR303、JSR349、JSR380)Hibernate-Validation 6.x使用案例
知識交流
==The last:如果覺得本文對你有幫助,不妨點個讚唄。當然分享到你的朋友圈讓更多小夥伴看到也是被作者本人許可的~==
若對技術內容感興趣可以加入wx群交流:Java高工、架構師3群。
若群二維碼失效,請加wx號:fsx641385712(或者掃描下方wx二維碼)。並且備註:"java入群" 字樣,會手動邀請入群
若有圖裂問題/排版問題,請點擊:原文鏈接-原文鏈接-原文鏈接
==若對Spring、SpringBoot、MyBatis等源碼分析感興趣,可加我wx:fsx641385712,手動邀請你入群一起飛==
