點選上方“芋道原始碼”,選擇“置頂公眾號”
技術文章第一時間送達!
原始碼精品專欄
本文主要基於 Eureka 1.8.X 版本
-
1. 概述
-
2. 應用集合一致性雜湊碼
-
2.1 計算公式
-
2.2 合理性
-
3. Eureka-Client 發起增量獲取
-
3.1 合併應用集合
-
4. Eureka-Server 接收全量獲取
-
3.1 接收全量獲取請求
-
3.2 最近租約變更記錄佇列
-
3.3 快取讀取
-
666. 彩蛋
1. 概述
本文主要分享 Eureka-Client 向 Eureka-Server 獲取增量註冊資訊的過程。
前置閱讀:《Eureka 原始碼解析 —— 應用實體註冊發現(六)之全量獲取》
FROM 《深度剖析服務發現元件Netflix Eureka》
Eureka-Client 獲取註冊資訊,分成全量獲取和增量獲取。預設配置下,Eureka-Client 啟動時,首先執行一次全量獲取進行本地快取註冊資訊,而後每 30 秒增量獲取掃清本地快取( 非“正常”情況下會是全量獲取 )。
本文重點在於增量獲取。
推薦 Spring Cloud 書籍:
-
請支援正版。下載盜版,等於主動編寫低階 BUG 。
-
程式猿DD —— 《Spring Cloud微服務實戰》
-
周立 —— 《Spring Cloud與Docker微服務架構實戰》
-
兩書齊買,京東包郵。
推薦 Spring Cloud 影片:
-
Java 微服務實踐 – Spring Boot
-
Java 微服務實踐 – Spring Cloud
-
Java 微服務實踐 – Spring Boot / Spring Cloud
2. 應用集合一致性雜湊碼
Applications.appsHashCode
,應用集合一致性雜湊碼。
增量獲取註冊的應用集合( Applications ) 時,Eureka-Client 會獲取到:
-
Eureka-Server 近期變化( 註冊、下線 )的應用集合
-
Eureka-Server 應用集合一致性雜湊碼
Eureka-Client 將變化的應用集合和本地快取的應用集合進行合併後進行計算本地的應用集合一致性雜湊碼。若兩個雜湊碼相等,意味著增量獲取成功;若不相等,意味著增量獲取失敗,Eureka-Client 重新和 Eureka-Server 全量獲取應用集合。
Eureka 比較應用集合一致性雜湊碼,和日常我們透過雜湊碼比較兩個物件是否相等類似。
2.1 計算公式
appsHashCode = ${status}_${count}_
-
使用每個應用實體狀態(
status
) + 數量(count
)拼接出一致性雜湊碼。若數量為 0 ,該應用實體狀態不進行拼接。狀態以字串大小排序。 -
舉個例子,8 個 UP ,0 個 DOWN ,則
appsHashCode = UP_8_
。8 個 UP ,2 個 DOWN ,則appsHashCode = DOWN_2_UP_8_
。 -
實現程式碼如下:
// Applications.java
public String getReconcileHashCode() {
// 計數集合 key:應用實體狀態
TreeMapinstanceCountMap = new TreeMap ();
populateInstanceCountMap(instanceCountMap);
// 計算 hashcode
return getReconcileHashCode(instanceCountMap);
} -
計數那塊程式碼,使用 Integer 即可,無需使用 AtomicInteger 。
-
呼叫
#populateInstanceCountMap()
方法,計算每個應用實體狀態的數量。實現程式碼如下:// Applications.java
public void populateInstanceCountMap(MapinstanceCountMap) {
for (Application app : this.getRegisteredApplications()) {
for (InstanceInfo info : app.getInstancesAsIsFromEureka()) {
// 計數
AtomicInteger instanceCount = instanceCountMap.computeIfAbsent(info.getStatus().name(),
k -> new AtomicInteger(0));
instanceCount.incrementAndGet();
}
}
}
public ListgetRegisteredApplications() {
return new ArrayList(this.applications);
}
// Applications.java
public ListgetInstancesAsIsFromEureka() {
synchronized (instances) {
return new ArrayList(this.instances);
}
} -
呼叫
#getReconcileHashCode()
方法,計算hashcode
。實現程式碼如下:public static String getReconcileHashCode(Map
instanceCountMap) {
StringBuilder reconcileHashCode = new StringBuilder(75);
for (Map.EntrymapEntry : instanceCountMap.entrySet()) {
reconcileHashCode.append(mapEntry.getKey()).append(STATUS_DELIMITER) // status
.append(mapEntry.getValue().get()).append(STATUS_DELIMITER); // count
}
return reconcileHashCode.toString();
}
2.2 合理性
本小節,建議你理解完全文後,再回到此處
本小節,建議你理解完全文後,再回到此處
本小節,建議你理解完全文後,再回到此處
筆者剛看完應用集合一致性雜湊演演算法的計算公式,處於一臉懵逼的狀態。這麼精簡的方式真的能夠校驗出資料的一致性麼?不曉得有多少讀者跟筆者有一樣的疑惑。下麵我們來論證該演演算法的合理性( 一本正經的胡說八道 )。
一致性雜湊值透過狀態 + 數量來計算,那麼是不是可能狀態總數是一樣多,實際分佈在不同的應用?那麼我們列舉模型如下:
UP | |
---|---|
應用A | m |
應用B | n |
如果此時應用A 下線了 c 個原應用實體,應用B 註冊了 c 個信應用實體,那麼處於 UP 狀態的數量仍然是 m + n 個。
-
正常情況下,Eureka-Client 從 Eureka-Server 獲取到完整的增量變化併合並,此時應用情況如下表格所示,兩者是一致的,一致性雜湊演演算法合理。
UP (server) | UP (client) | |
---|---|---|
應用A | m – c | m – c |
應用B | n + c | n + c |
-
異常情況下【1】,變更記錄佇列全部過期。那 Eureka-Client 從 Eureka-Server 獲取到空的增量變化併合並,此時應用情況如下表格所示,兩者應用是不相同的, 一致性雜湊值卻是相等的,一致性雜湊演演算法不合理。
UP (server) | UP (client) | |
---|---|---|
應用A | m – c | m |
應用B | n + c | n |
-
異常情況下【2】,變更記錄佇列部分過期,例如應用A 和 應用B 都剩餘 w 條變更記錄。那 Eureka-Client 從 Eureka-Server 獲取到部分的增量變化併合並,兩者應用是不相同的,此時應用情況如下表格所示,一致性雜湊值卻是相等的,一致性雜湊演演算法不合理。
UP (server) | UP (client) | |
---|---|---|
應用A | m – c | m – w |
應用B | n + c | n + w |
What ? 從異常情況【1】【2】可以看到,一致性雜湊演演算法竟然是不合理的,那麼我們手動來做一次最精簡的實驗。實驗如下:
-
模擬場景:異常情況【1】,m = n = c = 1 。簡單粗暴。
-
特別配置
-
eureka.retentionTimeInMSInDeltaQueue = 1
,變更記錄佇列每條記錄存活時長 1 ms。用以實現 Eureka-Client 請求不到完整的增量變化。 -
eureka.deltaRetentionTimerIntervalInMs = 1
,變更記錄佇列每條記錄過期定時任務執行頻率 1 ms。用以實現 Eureka-Client 請求不到完整的增量變化。 -
eureka.shouldUseReadOnlyResponseCache = false
,禁用響應快取的只讀快取。用以避免等待快取掃清。 -
eureka.waitTimeInMsWhenSyncEmpty = 1
, -
實驗過程
-
00:00 啟動 Eureka-Server
-
00:30 啟動應用A ,向 Eureka-Server 註冊
-
01:00 啟動 Eureka-Client ,向 Eureka-Server 獲取註冊資訊,等待獲取到應用A
-
01:30 關閉應用A 。立即啟動應用B ,向 Eureka-Server 註冊
-
等待 5 分鐘,Eureka-Client 無法獲取到應用B
-
此時應用情況如下表格所示,兩者應用是不相同的,一致性雜湊值卻是相等的,一致性雜湊演演算法不合理。
UP (server) | UP (client) | |
---|---|---|
應用A | 0 | 1 |
應用B | 1 | 0 |
?結論?
當然排除掉特別極端的場景,Eureka-Client 從 Eureka-Server 因為網路異常導致一直同步不到增量變化,又恰好應用關閉和開啟滿足狀態統計數量。另外,變更記錄佇列記錄過期時長為 300 秒,增量獲取頻率為 30 秒,獲取的次數有 10 次左右。所以,應用集合一致性雜湊碼在絕大多數場景是合理的。筆者的YY,解決這個極小場景有如下方式:
-
第一種,修改計算公式
appsHashCode = MD5(${app_name}_${instance_id}_${status}_${count}_)
,增加對應用名和應用實體編號敏感。 -
第二種,每 N 分鐘進行一次全量獲取註冊資訊。
ps :筆者懷著忐忑的心寫完了這個小節,如果有不合理的地方,又或者有不同觀點的胖友,歡迎一起探討。謝謝。
TODO[0027][反思]:應用集合一致性雜湊演演算法。
3. Eureka-Client 發起增量獲取
在 《Eureka 原始碼解析 —— 應用實體註冊發現(六)之全量獲取》「2.4 發起獲取註冊資訊」 裡,呼叫 DiscoveryClient#getAndUpdateDelta(...)
方法,增量獲取註冊資訊,並掃清本地快取,實現程式碼如下:
1: private void getAndUpdateDelta(Applications applications) throws Throwable {
2: long currentUpdateGeneration = fetchRegistryGeneration.get();
3:
4: // 增量獲取註冊資訊
5: Applications delta = null;
6: EurekaHttpResponse httpResponse = eurekaTransport.queryClient.getDelta(remoteRegionsRef.get());
7: if (httpResponse.getStatusCode() == Status.OK.getStatusCode()) {
8: delta = httpResponse.getEntity();
9: }
10:
11: if (delta == null) {
12: // 增量獲取為空,全量獲取
13: logger.warn("The server does not allow the delta revision to be applied because it is not safe. "
14: + "Hence got the full registry.");
15: getAndStoreFullRegistry();
16: } else if (fetchRegistryGeneration.compareAndSet(currentUpdateGeneration, currentUpdateGeneration + 1)) {
17: logger.debug("Got delta update with apps hashcode {}", delta.getAppsHashCode());
18: String reconcileHashCode = "";
19: if (fetchRegistryUpdateLock.tryLock()) {
20: try {
21: // 將變化的應用集合和本地快取的應用集合進行合併
22: updateDelta(delta);
23: // 計算本地的應用集合一致性雜湊碼
24: reconcileHashCode = getReconcileHashCode(applications);
25: } finally {
26: fetchRegistryUpdateLock.unlock();
27: }
28: } else {
29: logger.warn("Cannot acquire update lock, aborting getAndUpdateDelta");
30: }
31: // There is a diff in number of instances for some reason
32: if (!reconcileHashCode.equals(delta.getAppsHashCode()) // 一致性雜湊值不相等
33: || clientConfig.shouldLogDeltaDiff()) { //
34: reconcileAndLogDifference(delta, reconcileHashCode); // this makes a remoteCall
35: }
36: } else {
37: logger.warn("Not updating application delta as another thread is updating it already");
38: logger.debug("Ignoring delta update with apps hashcode {}, as another thread is updating it already", delta.getAppsHashCode());
39: }
40: }
-
第 4 至 9 行 :請求增量獲取註冊資訊,實現程式碼如下:
// AbstractJerseyEurekaHttpClient.java
@Override
public EurekaHttpResponsegetDelta(String... regions) {
return getApplicationsInternal("apps/delta", regions);
} -
呼叫
AbstractJerseyEurekaHttpClient#getApplicationsInternal(...)
方法,GET 請求 Eureka-Server 的apps/detla
介面,引數為regions
,傳回格式為 JSON ,實現增量獲取註冊資訊。 -
第 11 至 15 行 :增量獲取失敗,呼叫
#getAndStoreFullRegistry()
方法,全量獲取註冊資訊,並設定到本地快取。該方法在 《Eureka 原始碼解析 —— 應用實體註冊發現(六)之全量獲取》「2.4.1 全量獲取註冊資訊,並設定到本地快取」 有詳細解析。 -
第 16 至 35 行 :處理增量獲取的結果。
-
第 33 行 :配置
eureka.printDeltaFullDiff
,是否列印增量和全量差異。預設值 :false
。從目前程式碼實現上來看,暫時沒有生效。註意 :開啟該引數會導致每次增量獲取後又發起全量獲取,不要開啟。 -
第 16 行 :TODO[0025] :併發更新的情況???
-
第 19 行 :TODO[0025] :併發更新的情況???
-
第 21 行 :呼叫
#updateDelta(...)
方法,將變化的應用集合和本地快取的應用集合進行合併。 -
第 31 至 35 行 :一致性雜湊值不相等,呼叫
#reconcileAndLogDifference()
方法,全量獲取註冊資訊,並設定到本地快取,和#getAndStoreFullRegistry()
基本類似。
3.1 合併應用集合
呼叫 #updateDelta(...)
方法,將變化的應用集合和本地快取的應用集合進行合併。實現程式碼如下:
1: private void updateDelta(Applications delta) {
2: int deltaCount = 0;
3: for (Application app : delta.getRegisteredApplications()) { // 迴圈增量(變化)應用集合
4: for (InstanceInfo instance : app.getInstances()) {
5: Applications applications = getApplications();
6: // TODO[0009]:RemoteRegionRegistry
7: String instanceRegion = instanceRegionChecker.getInstanceRegion(instance);
8: if (!instanceRegionChecker.isLocalRegion(instanceRegion)) {
9: Applications remoteApps = remoteRegionVsApps.get(instanceRegion);
10: if (null == remoteApps) {
11: remoteApps = new Applications();
12: remoteRegionVsApps.put(instanceRegion, remoteApps);
13: }
14: applications = remoteApps;
15: }
16:
17: ++deltaCount;
18: if (ActionType.ADDED.equals(instance.getActionType())) { // 新增
19: Application existingApp = applications.getRegisteredApplications(instance.getAppName());
20: if (existingApp == null) {
21: applications.addApplication(app);
22: }
23: logger.debug("Added instance {} to the existing apps in region {}", instance.getId(), instanceRegion);
24: applications.getRegisteredApplications(instance.getAppName()).addInstance(instance);
25: } else if (ActionType.MODIFIED.equals(instance.getActionType())) { // 修改
26: Application existingApp = applications.getRegisteredApplications(instance.getAppName());
27: if (existingApp == null) {
28: applications.addApplication(app);
29: }
30: logger.debug("Modified instance {} to the existing apps ", instance.getId());
31:
32: applications.getRegisteredApplications(instance.getAppName()).addInstance(instance);
33: } else if (ActionType.DELETED.equals(instance.getActionType())) { // 刪除
34: Application existingApp = applications.getRegisteredApplications(instance.getAppName());
35: if (existingApp == null) {
36: applications.addApplication(app);
37: }
38: logger.debug("Deleted instance {} to the existing apps ", instance.getId());
39: applications.getRegisteredApplications(instance.getAppName()).removeInstance(instance);
40: }
41: }
42: }
43: logger.debug("The total number of instances fetched by the delta processor : {}", deltaCount);
44:
45: getApplications().setVersion(delta.getVersion());
46: // 過濾、打亂應用集合
47: getApplications().shuffleInstances(clientConfig.shouldFilterOnlyUpInstances());
48:
49: // TODO[0009]:RemoteRegionRegistry
50: for (Applications applications : remoteRegionVsApps.values()) {
51: applications.setVersion(delta.getVersion());
52: applications.shuffleInstances(clientConfig.shouldFilterOnlyUpInstances());
53: }
54: }
-
第 6 至 15 行 :TODO[0009]:RemoteRegionRegistry
-
第 18 至 24 行 :新增( ADDED )應用實體時,呼叫
Application#addInstance(...)
方法,實現程式碼如下:// Application.java
public void addInstance(InstanceInfo i) {
// 新增到 應用實體對映
instancesMap.put(i.getId(), i);
synchronized (instances) {
// 移除原有實體
instances.remove(i);
// 新增新實體
instances.add(i);
// 設定 isDirty ,目前只用於 `#toString()` 方法列印,無業務邏輯
isDirty = true;
}
}
// InstanceInfo.java
@Override
public int hashCode() { // 只使用 ID 計算 hashcode
String id = getId();
return (id == null) ? 31 : (id.hashCode() + 31);
}
@Override
public boolean equals(Object obj) { // 只對比 ID
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
InstanceInfo other = (InstanceInfo) obj;
String id = getId();
if (id == null) {
if (other.getId() != null) {
return false;
}
} else if (!id.equals(other.getId())) {
return false;
}
return true;
} -
第 25 至 32 行 :修改( MODIFIED )應用實體時,同樣呼叫
Application#addInstance(...)
方法。 -
第 33 至 40 行 :刪除( DELETED )應用實體時,呼叫
Application#removeInstance(...)
方法,實現程式碼如下:public void removeInstance(InstanceInfo i) {
removeInstance(i, true);
}
private void removeInstance(InstanceInfo i, boolean markAsDirty) {
// 移除 應用實體對映
instancesMap.remove(i.getId());
synchronized (instances) {
// 移除 應用實體
instances.remove(i);
if (markAsDirty) {
// 設定 isDirty ,目前只用於 `#toString()` 方法列印,無業務邏輯
isDirty = true;
}
}
} -
第 47 行 :呼叫
Applications#shuffleInstances(...)
方法,根據配置eureka.shouldFilterOnlyUpInstances = true
( 預設值 :true
) 過濾只保留狀態為開啟( UP )的應用實體,並隨機打亂應用實體順序。打亂後,實現呼叫應用服務的隨機性。程式碼比較易懂,點選連結檢視方法實現。 -
第 49 至 53 行 :TODO[0009]:RemoteRegionRegistry
4. Eureka-Server 接收全量獲取
3.1 接收全量獲取請求
com.netflix.eureka.resources.ApplicationsResource
,處理所有應用的請求操作的 Resource ( Controller )。
接收增量獲取請求,對映 ApplicationsResource#getContainers()
方法。
-
和 《Eureka 原始碼解析 —— 應用實體註冊發現(六)之全量獲取》「3.1 接收全量獲取請求」 類似,就不重覆囉嗦啦。
-
點選 連結 檢視該方法的帶中文註釋程式碼。
3.2 最近租約變更記錄佇列
AbstractInstanceRegistry.recentlyChangedQueue
,最近租約變更記錄佇列。實現程式碼如下:
// AbstractInstanceRegistry.java
/**
* 最近租約變更記錄佇列
*/
private ConcurrentLinkedQueue recentlyChangedQueue = new ConcurrentLinkedQueue();
/**
* 最近租約變更記錄
*/
private static final class RecentlyChangedItem {
/**
* 最後更新時間戳
*/
private long lastUpdateTime;
/**
* 租約
*/
private Lease leaseInfo;
public RecentlyChangedItem(Lease lease) {
this.leaseInfo = lease;
lastUpdateTime = System.currentTimeMillis();
}
public long getLastUpdateTime() {
return this.lastUpdateTime;
}
public Lease getLeaseInfo() {
return this.leaseInfo;
}
}
-
當應用實體註冊、下線、狀態變更時,建立最近租約變更記錄( RecentlyChangedItem ) 到佇列。
-
後臺任務定時順序掃描佇列,當
lastUpdateTime
超過一定時長後進行移除。實現程式碼如下:// AbstractInstanceRegistry.java
this.deltaRetentionTimer.schedule(getDeltaRetentionTask(),
serverConfig.getDeltaRetentionTimerIntervalInMs(),
serverConfig.getDeltaRetentionTimerIntervalInMs());
private TimerTask getDeltaRetentionTask() {
return new TimerTask() {@Override
public void run() {
Iteratorit = recentlyChangedQueue.iterator();
while (it.hasNext()) {
if (it.next().getLastUpdateTime() < System.currentTimeMillis() - serverConfig.getRetentionTimeInMSInDeltaQueue()) {
it.remove();
} else {
break;
}
}
}};
} -
配置
eureka.deltaRetentionTimerIntervalInMs
, 移除佇列裡過期的租約變更記錄的定時任務執行頻率,單位:毫秒。預設值 :30 * 1000 毫秒。 -
配置
eureka.retentionTimeInMSInDeltaQueue
,租約變更記錄過期時長,單位:毫秒。預設值 : 3 * 60 * 1000 毫秒。
3.3 快取讀取
在 《Eureka 原始碼解析 —— 應用實體註冊發現(六)之全量獲取》「3.3 快取讀取」 裡,在 #generatePayload()
方法裡,呼叫 AbstractInstanceRegistry#getApplicationDeltas(...)
方法,獲取近期變化的應用集合,實現程式碼如下:
// AbstractInstanceRegistry.java
1: public Applications getApplicationDeltas() {
2: // 新增 增量獲取次數 到 監控
3: GET_ALL_CACHE_MISS_DELTA.increment();
4: // 初始化 變化的應用集合
5: Applications apps = new Applications();
6: apps.setVersion(responseCache.getVersionDelta().get());
7: Map applicationInstancesMap = new HashMap();
8: try {
9: // 獲取寫鎖
10: write.lock();
11: // 獲取 最近租約變更記錄佇列
12: Iterator iter = this.recentlyChangedQueue.iterator();
13: logger.debug("The number of elements in the delta queue is :" + this.recentlyChangedQueue.size());
14: // 拼裝 變化的應用集合
15: while (iter.hasNext()) {
16: Lease lease = iter.next().getLeaseInfo();
17: InstanceInfo instanceInfo = lease.getHolder();
18: Object[] args = {instanceInfo.getId(), instanceInfo.getStatus().name(), instanceInfo.getActionType().name()};
19: logger.debug("The instance id %s is found with status %s and actiontype %s", args);
20: Application app = applicationInstancesMap.get(instanceInfo.getAppName());
21: if (app == null) {
22: app = new Application(instanceInfo.getAppName());
23: applicationInstancesMap.put(instanceInfo.getAppName(), app);
24: apps.addApplication(app);
25: }
26: app.addInstance(decorateInstanceInfo(lease));
27: }
28:
29: // TODO[0009]:RemoteRegionRegistry
30: boolean disableTransparentFallback = serverConfig.disableTransparentFallbackToOtherRegion();
31: if (!disableTransparentFallback) {
32: Applications allAppsInLocalRegion = getApplications(false);
33:
34: for (RemoteRegionRegistry remoteRegistry : this.regionNameVSRemoteRegistry.values()) {
35: Applications applications = remoteRegistry.getApplicationDeltas();
36: for (Application application : applications.getRegisteredApplications()) {
37: Application appInLocalRegistry =
38: allAppsInLocalRegion.getRegisteredApplications(application.getName());
39: if (appInLocalRegistry == null) {
40: apps.addApplication(application);
41: }
42: }
43: }
44: }
45:
46: // 獲取全量應用集合,透過它計算一致性雜湊值
47: Applications allApps = getApplications(!disableTransparentFallback);
48: apps.setAppsHashCode(allApps.getReconcileHashCode());
49: return apps;
50: } finally {
51: write.unlock();
52: }
53: }
-
第 2 至 3 行 :新增增量獲取次數到監控。配合 Netflix Servo 實現監控資訊採集。
-
第 4 行 :初始化變化( 增量 )的應用集合(
apps
)。 -
第 9 行 :獲取寫鎖。在 《Eureka原始碼解析 —— 應用實體註冊發現 (九)之歲月是把萌萌的讀寫鎖》 詳細解析。
-
第 11 至 13 行 :獲取最近租約變更記錄佇列(
最近租約變更記錄佇列
)。 -
第 14 至 27 行 :拼裝變化的應用集合(
apps
)。 -
第 29 至 44 行 :TODO[0009]:RemoteRegionRegistry
-
第 46 至 48 行 :呼叫
#getApplications(...)
方法,獲取全量應用集合(allApps
),在 《Eureka 原始碼解析 —— 應用實體註冊發現(六)之全量獲取》「3.3.1 獲得註冊的應用集合」 有詳細解析。後透過allApps
計算一致性雜湊值。透過這個全量應用集合的雜湊值,Eureka-Client 獲取到增量應用集合併合併後,就可以比對啦。 -
第 51 行 :釋放寫鎖。
666. 彩蛋
在漢堡王寫完這篇熱情的部落格。為什麼用“熱情”這個字眼呢?大夏天的,竟然不開空調的!對的,沒有開空調,簡直是個小火爐。恩,不過靜心寫完這篇文章,讓我還是挺嗨皮的。
胖友,分享我的公眾號( 芋道原始碼 ) 給你的胖友可好?
目前在知識星球(https://t.zsxq.com/2VbiaEu)更新瞭如下 Dubbo 原始碼解析如下:
01. 除錯環境搭建
02. 專案結構一覽
03. API 配置(一)之應用
04. API 配置(二)之服務提供者
05. API 配置(三)之服務消費者
06. 屬性配置
07. XML 配置
08. 核心流程一覽
09. 拓展機制 SPI
10. 執行緒池
…
一共 60 篇++