每篇一句
不要像祥林嫂一樣,天天抱怨着生活,日日思考着辭職。得罪點說一句:“淪落”到要跟這樣的人共事工作,難道自己身上就沒有原因?
前言
本以為洋洋灑灑的把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,手動邀請你入群一起飛