從深處去掌握資料校驗@Valid的作用(級聯校驗)
每篇一句
NBA裡有兩大笑話:一是科比沒天賦,二是詹姆斯沒技術
相關閱讀
【小家Java】深入瞭解資料校驗:Java Bean Validation 2.0(JSR303、JSR349、JSR380)Hibernate-Validation 6.x使用案例
【小家Spring】讓Controller支援對平鋪引數執行資料校驗(預設Spring MVC使用@Valid只能對JavaBean進行校驗)
【小家Spring】Spring方法級別資料校驗:@Validated + MethodValidationPostProcessor優雅的完成資料校驗動作
前言
關於Bean Validation
的基本原理篇完結之後,接下來就是小夥伴最為關心的乾貨:使用篇。
如果說要使用Bean Validation
資料校驗,我十分相信小夥伴們都能夠使用,但估計大都是有個前提的:Spring MVC
環境。我極其簡單的調查了一下,近乎99%
的人都是隻把資料校驗使用在Spring MVC
的Controller
層面的,而且幾乎90%
的人都是讓它必須和@RequestBody
一起來使用去校驗JavaBean
入參~
如果這麼去理解Bean Validation
的使用,那就有點太過於片面了,畢竟被Spring包裹起來,你其實很難去知道它真正做的事。
熟悉我文章風格的人知道,每篇文章我都會帶你領略一些不一樣的風景,本章亦不例外,會讓你知道資料校驗在Spring
分組校驗
在我的前置原理篇文章,分組校驗其實是沒太大必要說的,因為使用起來確實非常的簡單。此處還是給個分組校驗的使用案例吧:
@Getter @Setter @ToString public class Person { // 錯誤訊息message是可以自定義的 @NotNull(message = "{message} -> 名字不能為null", groups = Simple.class) public String name; @Max(value = 10, groups = Simple.class) @Positive(groups = Default.class) // 內建的分組:default public Integer age; @NotNull(groups = Complex.class) @NotEmpty(groups = Complex.class) private List<@Email String> emails; @Future(groups = Complex.class) private Date start; // 定義兩個組 Simple組和Complex組 interface Simple { } interface Complex { } }
執行分組校驗:
public static void main(String[] args) {
Person person = new Person();
//person.setName("fsx");
person.setAge(18);
// email校驗:雖然是List都可以校驗哦
person.setEmails(Arrays.asList("[email protected]", "[email protected]", "aaa.com"));
//person.setStart(new Date()); //start 需要是一個將來的時間: Sun Jul 21 10:45:03 CST 2019
//person.setStart(new Date(System.currentTimeMillis() + 10000)); //校驗通過
HibernateValidatorConfiguration configure = Validation.byProvider(HibernateValidator.class).configure();
ValidatorFactory validatorFactory = configure.failFast(false).buildValidatorFactory();
// 根據validatorFactory拿到一個Validator
Validator validator = validatorFactory.getValidator();
// 分組校驗(可以區分對待Default組、Simple組、Complex組)
Set<ConstraintViolation<Person>> result = validator.validate(person, Person.Simple.class);
//Set<ConstraintViolation<Person>> result = validator.validate(person, Person.Complex.class);
// 對結果進行遍歷輸出
result.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValue())
.forEach(System.out::println);
}
執行列印:
age 最大不能超過10: 18
name {message} -> 名字不能為null -> 名字不能為null: null
可以直觀的看到效果,此處的校驗只執行Person.Simple.class
這個Group
組上的約束~
分組約束在Spring MVC中的使用場景還是相對比較多的,但是需要注意的是:
javax.validation.Valid
沒有提供指定分組的,但是org.springframework.validation.annotation.Validated
擴充套件提供了直接在註解層面指定分組的能力
@Valid註解
我們知道JSR
提供了一個@Valid
註解供以使用,在本文之前,絕大多數小夥伴都是在Controller
中並且結合@RequestBody
一起來使用它,但在本文之後,你定會對它有個全新的認識~
==該註解用於驗證級聯的屬性、方法引數或方法返回型別。==
當驗證屬性、方法引數或方法返回型別時,將驗證物件及其屬性上定義的約束,另外:此行為是遞迴應用的。
:::為了理解@Valid
,那就得知道處理它的時機:::
MetaDataProvider
元資料提供者:約束相關元資料(如約束、預設組序列等)的Provider
。它的作用和特點如下:
- 基於不同的元資料:如xml、註解。(還有個程式設計對映) 這三種類型。對應的列舉類為:
public enum ConfigurationSource {
ANNOTATION( 0 ),
XML( 1 ),
API( 2 ); //programmatic API
}
MetaDataProvider
只返回直接為一個類配置的元資料- 它不處理從超類、介面合併的元資料(
簡單的說你@Valid放在介面處是無效的
)
public interface MetaDataProvider {
// 將**註解處理選項**歸還給此Provider配置。 它的唯一實現類為:AnnotationProcessingOptionsImpl
// 它可以配置比如:areMemberConstraintsIgnoredFor areReturnValueConstraintsIgnoredFor
// 也就說可以配置:讓免於被校驗~~~~~~(開綠燈用的)
AnnotationProcessingOptions getAnnotationProcessingOptions();
// 返回作用在此Bean上面的`BeanConfiguration` 若沒有就返回null了
// BeanConfiguration持有ConfigurationSource的引用~
<T> BeanConfiguration<? super T> getBeanConfiguration(Class<T> beanClass);
}
// 表示源於一個ConfigurationSource的一個Java型別的完整約束相關配置。 包含欄位、方法、類級別上的元資料
// 當然還包含有預設組序列上的元資料(使用較少)
public class BeanConfiguration<T> {
// 三種來源的列舉
private final ConfigurationSource source;
private final Class<T> beanClass;
// ConstrainedElement表示待校驗的元素,可以知道它會如下四個子類:
// ConstrainedField/ConstrainedType/ConstrainedParameter/ConstrainedExecutable
// 注意:ConstrainedExecutable持有的是java.lang.reflect.Executable物件
//它的兩個子類是java.lang.reflect.Method和Constructor
private final Set<ConstrainedElement> constrainedElements;
private final List<Class<?>> defaultGroupSequence;
private final DefaultGroupSequenceProvider<? super T> defaultGroupSequenceProvider;
... // 它自己並不處理什麼邏輯,引數都是通過構造器傳進來的
}
它的繼承樹:
三個實現類對應著上面所述的三種元資料型別。本文很顯然只需要關注和註解相關的:AnnotationMetaDataProvider
AnnotationMetaDataProvider
這個元資料均來自於註解的標註,然後它是Hibernate Validation
的預設configuration source
。它這裡會處理標註有@Valid
的元素~
public class AnnotationMetaDataProvider implements MetaDataProvider {
private final ConstraintHelper constraintHelper;
private final TypeResolutionHelper typeResolutionHelper;
private final AnnotationProcessingOptions annotationProcessingOptions;
private final ValueExtractorManager valueExtractorManager;
// 這是一個非常重要的屬性,它會記錄著當前Bean 所有的待校驗的Bean資訊~~~
private final BeanConfiguration<Object> objectBeanConfiguration;
// 唯一建構函式
public AnnotationMetaDataProvider(ConstraintHelper constraintHelper,
TypeResolutionHelper typeResolutionHelper,
ValueExtractorManager valueExtractorManager,
AnnotationProcessingOptions annotationProcessingOptions) {
this.constraintHelper = constraintHelper;
this.typeResolutionHelper = typeResolutionHelper;
this.valueExtractorManager = valueExtractorManager;
this.annotationProcessingOptions = annotationProcessingOptions;
// 預設情況下,它去把Object相關的所有的方法都retrieve:檢索出來放著 我比較費解這件事~~~
// 後面才發現:一切為了效率
this.objectBeanConfiguration = retrieveBeanConfiguration( Object.class );
}
// 實現介面方法
@Override
public AnnotationProcessingOptions getAnnotationProcessingOptions() {
return new AnnotationProcessingOptionsImpl();
}
// 如果你的Bean是Object 就直接返回了~~~(大多數情況下 都是Object)
@Override
@SuppressWarnings("unchecked")
public <T> BeanConfiguration<T> getBeanConfiguration(Class<T> beanClass) {
if ( Object.class.equals( beanClass ) ) {
return (BeanConfiguration<T>) objectBeanConfiguration;
}
return retrieveBeanConfiguration( beanClass );
}
}
如上可知,核心解析邏輯在retrieveBeanConfiguration()
這個私有方法上。總結一下呼叫此方法的兩個原始入口(一個構造器,一個介面方法):
ValidatorFactory.getValidator()
獲取校驗器的時候,初始化時會自己new
一個,呼叫棧如下圖:
- 呼叫
Validator.validate()
方法的時候,beanMetaDataManager.getBeanMetaData( rootBeanClass )
它會遍歷初始化時所有的metaDataProviders
(預設情況下兩個,沒有xml方式的),拿出所有的BeanConfiguration
交給BeanMetaDataBuilder
,最終構建出一個屬於此Bean的BeanMetaData
。對此有一點注意事項描述如下:
1. 處理MetaDataProvider
時會呼叫ClassHierarchyHelper.getHierarchy( beanClass )
方法,不僅僅處理本類。拿到本類自己和所有父類後,統一交給provider.getBeanConfiguration( clazz )
處理(也就是說任何一個類都會把Object類處理一遍)
retrieveBeanConfiguration()
詳情
這個方法說白了,就是從Bean裡面去檢索屬性、方法、構造器等需要校驗的ConstrainedElement項
。
private <T> BeanConfiguration<T> retrieveBeanConfiguration(Class<T> beanClass) {
// 它檢索的範圍是:clazz.getDeclaredFields() 什麼意思:就是蒐集到本類所有的欄位 包括private等等 但是不包括父類的所有欄位
Set<ConstrainedElement> constrainedElements = getFieldMetaData( beanClass );
constrainedElements.addAll( getMethodMetaData( beanClass ) );
constrainedElements.addAll( getConstructorMetaData( beanClass ) );
//TODO GM: currently class level constraints are represented by a PropertyMetaData. This
//works but seems somewhat unnatural
// 這個TODO很有意思:當前,類級約束由PropertyMetadata表示。這是可行的,但似乎有點不自然
// ReturnValueMetaData、ExecutableMetaData、ParameterMetaData、PropertyMetaData
// 總之吧:此處就是把類級別的校驗器放進來了(這個set大部分時候都是空的)
Set<MetaConstraint<?>> classLevelConstraints = getClassLevelConstraints( beanClass );
if (!classLevelConstraints.isEmpty()) {
ConstrainedType classLevelMetaData = new ConstrainedType(ConfigurationSource.ANNOTATION, beanClass, classLevelConstraints);
constrainedElements.add(classLevelMetaData);
}
// 組裝成一個BeanConfiguration返回
return new BeanConfiguration<>(ConfigurationSource.ANNOTATION, beanClass,
constrainedElements,
getDefaultGroupSequence( beanClass ), //此類上標註的所有@GroupSequence註解
getDefaultGroupSequenceProvider( beanClass ) // 此類上標註的所有@GroupSequenceProvider註解
);
}
這一步驟把該Bean上的欄位、方法等等需要校驗的項都提取出來。就拿上例中的Demo校驗Person
類來說,最終得出的BeanConfiguration
如下:(兩個)
這是直觀的結論,可以看到僅僅是一個簡單的類其實所包含的項是挺多的。
此處說一句:項是有這麼多,但是並不是每一個都需要走驗證邏輯的。因為畢竟大多數項上面並沒有約束(註解),大多數
ConstrainedElement.getConstraints()
為空嘛~
總得來說,我個人建議不能光只記憶結論,因為那很容易忘記,所以還是得稍微深入一點,讓記憶更深刻吧。那就從下面四個方面深入:
檢索Field:getFieldMetaData( beanClass )
- 拿到本類所有欄位
Field
:clazz.getDeclaredFields()
- 把每個
Field
都包裝成ConstrainedElement
存放起來~~~
1. 注意:此步驟完成了對每個Field
上標註的註解進行了儲存
檢索Method:getMethodMetaData( beanClass )
- 拿到本類所有的方法
Method
:clazz.getDeclaredMethods()
- 排除掉靜態方法和合成(isSynthetic)方法
把每個Method都轉換成一個
ConstrainedExecutable
裝著~~(ConstrainedExecutable
也是個ConstrainedElement
)。在此期間它完成了如下事(方法和構造器都複雜點,因為包含入參和返回值):
1. 找到方法上所有的註解儲存起來
2. 處理入參、返回值(包括自動判斷是作用在入參還是返回值上)檢索Constructor:getConstructorMetaData( beanClass )
完全同處理Method,略
檢索Type:getClassLevelConstraints( beanClass )
- 找打標註在此類上的所有的註解,轉換成
ConstraintDescriptor
- 對已經找到每個
ConstraintDescriptor
進行處理,最終都轉換Set<MetaConstraint<?>>
這個型別
1. 把
Set<MetaConstraint<?>>
用一個ConstrainedType
包裝起來(ConstrainedType
是個ConstrainedElement
)
==關於級聯校驗此處補充說明一點,處理Type,都會處理級聯校驗情況,並且還是遞迴處理:==
也就是這個方法(課件@Valid
在此處生效):
// type解釋:分如下N中情況
// Field為:.getGenericType() // 欄位的型別
// Method為:.getGenericReturnType() // 返回值型別
// Constructor:.getDeclaringClass() // 構造器所在類
// annotatedElement:可不一定說一定要有註解才能進來(每個欄位、方法、構造器等都能傳進來)
private CascadingMetaDataBuilder getCascadingMetaData(Type type, AnnotatedElement annotatedElement, Map<TypeVariable<?>, CascadingMetaDataBuilder> containerElementTypesCascadingMetaData) {
return CascadingMetaDataBuilder.annotatedObject( type, annotatedElement.isAnnotationPresent( Valid.class ), containerElementTypesCascadingMetaData, getGroupConversions( annotatedElement ) );
}
這裡對我們理解級聯校驗最重要的一句是:annotatedElement.isAnnotationPresent(Valid.class)
。也就是說:若元素被此註解標註了,那就證明需要對它進行級聯校驗,這就是JSR定位@Valid
的作用~
Spring提升了它???請關注後文Spring對它的應用吧~
ConstraintValidator.isValid()
呼叫處
我們知道,每個約束註解都是交給約束校驗器ConstraintValidator.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) {
...
V validatedValue = (V) valueContext.getCurrentValidatedValue();
isValid = validator.isValid( validatedValue, constraintValidatorContext );
...
// 顯然校驗不通過就返回錯誤訊息 否則返回空集合
if ( !isValid ) {
return executionContext.createConstraintViolations(valueContext, constraintValidatorContext);
}
return Collections.emptySet();
}
...
}
這個方法的呼叫,會在執行每個Group
的時候
success = metaConstraint.validateConstraint( validationContext, valueContext );
MetaConstraint
在上面檢索的時候就已經準備好了,最後通過ConstrainedElement.getConstraints
就拿到了每個元素的校驗器們,繼續呼叫
// ConstraintTree<A>
boolean validationResult = constraintTree.validateConstraints( executionContext, valueContext );
so,最終就呼叫到了isValid
這個真正做事的方法上了。
==說了這麼多,你可能還雲裡霧裡,那麼就show
一把吧:==
Demo Show
上面用一個示例校驗Person
這個JavaBean
了,但是你會發現示例中我們全都是校驗的Field
屬性。從理論裡我們知道了Bean Validation
它是有校驗方法、構造器、入參甚至遞迴校驗級聯屬性的能力的:
校驗屬性Field
略
校驗Method入參、返回值
校驗Constructor入參、返回值
既校驗入參,同時也校驗返回值
這些是不能直接使用的,需要在執行時進行校驗。具體使用可參考:【小家Spring】讓Controller支援對平鋪引數執行資料校驗(預設Spring MVC使用@Valid只能對JavaBean進行校驗)
級聯校驗
什麼叫級聯校驗,其實就是帶校驗的成員裡存在級聯物件時,也要對它完成校驗。這個在實際應用場景中是比較常見的,比如入參Person
物件中,還持有Child
物件,我們不僅僅要完成Person
的校驗,也依舊還要對Child內的屬性校驗:
@Getter
@Setter
@ToString
public class Person {
@NotNull
private String name;
@NotNull
@Positive
private Integer age;
@Valid
@NotNull
private InnerChild child;
@Getter
@Setter
@ToString
public static class InnerChild {
@NotNull
private String name;
@NotNull
@Positive
private Integer age;
}
}
校驗邏輯如下:
public static void main(String[] args) {
Person person = new Person();
person.setName("fsx");
Person.InnerChild child = new Person.InnerChild();
child.setName("fsx-son");
child.setAge(-1);
person.setChild(child); // 放進去
Validator validator = Validation.byProvider(HibernateValidator.class).configure().failFast(false)
.buildValidatorFactory().getValidator();
Set<ConstraintViolation<Person>> result = validator.validate(person);
// 輸出錯誤訊息
result.stream().map(v -> v.getPropertyPath() + " " + v.getMessage() + ": " + v.getInvalidValue())
.forEach(System.out::println);
}
執行:
child.age 必須是正數: -1
age 不能為null: null
對child.age
這個級聯屬性校驗成功~
總結
本文值得說是深入瞭解資料校驗(Bean Validation)了,對於資料校驗的基本使用一直都不是難事,特別是在Spring
環境下使用就更簡單了~
知識交流
若文章格式混亂,可點選
:原文連結-原文連結-原文連結-原文連結-原文連結
==The last:如果覺得本文對你有幫助,不妨點個讚唄。當然分享到你的朋友圈讓更多小夥伴看到也是被作者本人許可的~
==
若對技術內容感興趣可以加入wx群交流:Java高工、架構師3群
。
若群二維碼失效,請加wx號:fsx641385712
(或者掃描下方wx二維碼)。並且備註:"java入群"
字樣,會手動邀請入