解決多欄位聯合邏輯校驗問題【享學Spring MVC】
每篇一句
不要像祥林嫂一樣,天天抱怨著生活,日日思考著辭職。得罪點說一句:“淪落”到要跟這樣的人共事工作,難道自己身上就沒有原因?
前言
本以為洋洋灑灑的把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,手動邀請你入群一起