從接觸領域驅動設計的初學階段,到實現一個舊系統改造到DDD模型,再到按DDD規範落地的3個的項目。對於領域驅動模型設計研發,從開始的各種疑惑到吸收各種先進的理念,目前在技術實施這一塊已經基本比較成熟。在既往經驗中總結了一些在開發中遇到的技術問題和解決方案進行分享。 ...
1. 引言
從接觸領域驅動設計的初學階段,到實現一個舊系統改造到DDD模型,再到按DDD規範落地的3個的項目。對於領域驅動模型設計研發,從開始的各種疑惑到吸收各種先進的理念,目前在技術實施這一塊已經基本比較成熟。在既往經驗中總結了一些在開發中遇到的技術問題和解決方案進行分享。
因為DDD的建模理論及方法論有比較成熟的教程,如《領域驅動設計》,這裡我對DDD的理論部分只做簡要回顧,如果需要瞭解DDD建模和基礎的理論知識,請移步相關書籍進行學習。本文主要針對我們團隊在DDD落地實踐中的一些技術點進行分享。
2. 理論回顧
理論部分只做部分提要,關於DDD建模及基礎知識相關,可參考 Eric Evans 的《領域驅動設計》一書及其它理論書籍,這裡只做部分內容摘抄。
2.1.1 名詞
領域及劃分:領域、子域、核心域、通用域、支撐域,限界上下文;
模型:聚合、聚合根、實體、值對象;
實體
是指描述了領域中唯一的且可持續變化的抽象模型,有ID標識,有生命周期,有狀態(用值對象來描述狀態),實體通過ID進行區分;
每個實體對象都有唯一的 ID。我們可以對一個實體對象進行多次修改,修改後的數據和原來的數據可能會大不相同。比如商品是商品上下文的一個實體,通過唯一的商品 ID 來標識,不管這個商品的數據如何變化,商品的 ID 一直保持不變,它始終是同一個商品。
在 DDD 里,這些實體類通常採用充血模型,與這個實體相關的所有業務邏輯都在實體類的方法中實現。
聚合根
聚合根是實體,是一個根實體,聚合根的ID全局唯一標識,聚合根下麵的實體的ID在聚合根內唯一即可;
聚合根是聚合還原和保存的唯一入口,聚合的還原應該保證完整性即整存整取;
聚合設計的原則
聚合是用來封裝真正的不變性,而不是簡單的將對象組合在一起;
聚合應儘量設計的小,主要因為業務決定聚合,業務改變聚合,儘可能小的拆分,可以避免重構,重新拆分
聚合之間的關聯通過ID,而不是對象引用;
聚合內強一致性,聚合之間最終一致性;
值對象
值對象的核心本質是值,與是否有複雜類型無關,值對象沒有生命周期,通過兩個值對象的值是否相同區分是否是同一個值對象;
值對象應該設計為只讀模式, 如果任一屬性發生變化,應該重新構建一個新的值對象而不是改變原來值對象的屬性;
領域事件
在事件風暴過程中,會識別出命令、業務操作、實體等,此外還有事件。比如當業務人員的描述中出現類似“當完成…後,則…”,“當發生…時,則…”等模式時,往往可將其用領域事件來實現。領域事件表示在領域中發生的事件,它會導致進一步的業務操作。如電商中,支付完成後觸發的事件,會導致生成訂單、扣減庫存等操作。
在一次事務中,最多只能更改一個聚合的狀態。如何一個業務操作涉及多個聚合狀態的更改,可以採用領域事件的方式,實現聚合之間的解耦;在聚合根和跨上下文之間實現最終一致性。聚合內數據強一致性,聚合之間數據最終一致性。
事件的生成和發佈:構建的事件應包含事件ID、時間戳、事件類型、事件源等基本屬性,以便事件可以無歧義地在不同上下文間傳播;此外事件還應包含具體的業務數據。
領域事件為已發生的事務,具有隻讀,不可變更性。一般接收消息為非同步監聽,處理的後續處理需要考慮時序和重覆發送的問題。
2.1.2 聚合根、實體、值對象的區別?
從標識的角度:
聚合根具有全局的唯一標識,而實體只有在聚合內部有唯一的本地標識,值對象沒有唯一標識;
從是否只讀的角度:
聚合根除了唯一標識外,其他所有狀態信息都理論上可變;實體是可變的;值對象是只讀的;
從生命周期的角度:
聚合根有獨立的生命周期,實體的生命周期從屬於其所屬的聚合,實體完全由其所屬的聚合根負責管理維護;值對象無生命周期可言,因為只是一個值;
2.2 建模方法
2.2.1 事件風暴
事件⻛暴法類似頭腦⻛暴,簡單來說就是誰在何時基於什麼做了什麼,產⽣了什麼,影響了什麼事情。
在事件風暴的過程中,領域專家會和設計、開發人員一起建立領域模型,在領域建模的過程中會形成通用的業務術語和用戶故事。事件風暴也是一個項目團隊統一語言的過程。
2.2.2 用戶故事
用戶故事在軟體開發過程中被作為描述需求的一種表達形式,並著重描述角色(誰要用這個功能)、功能(需要完成什麼樣子的功能)和價值(為什麼需要這個功能,這個功能帶來什麼樣的價值)。
例:
作為一個“網站管理員”,我想要“統計每天有多少人訪問了我的網站”,以便於“我的贊助商瞭解我的網站會給他們帶來什麼收益。
通過用戶故事分析會形成一個個的領域對象,這些領域對象對應領域模型的業務對象,每一個業務對象和領域對象都有通用的名詞術語,並且一一映射。
2.2.3 統一語言
在事件風暴和用戶故事梳理過程及日常討論中,會有越來越多的名詞冒出來,這個時候,需要團隊成員統一意見,形成名詞字典。在後續的討論和描述中,使用統一的名稱名詞來指代模型中的對象、屬性、狀態、事件、用例等信息。
可以用Excel或者線上文檔等方式記錄存儲,標註名稱,描述和提取時間和參與人等信息。
代碼模型設計的時侯就要建立領域對象和代碼對象的一一映射,從而保證業務模型和代碼模型的一致,實現業務語言與代碼語言的統一。
2.2.4 領域劃分及建模
DDD 內核的代碼模型來源於領域模型,每個代碼模型的代碼對象跟領域對象一一對應。
通過UML類圖(通過顏色標註區分聚合根、實體、值對象等)、用例圖、時序圖完成軟體模型設計。
2.3 整潔架構(洋蔥架構)
整潔架構(Clean Architecture)是由Bob大叔在2012年提出的一個架構模型,顧名思義,是為了使架構更簡潔。
整潔架構最主要原則是依賴原則,它定義了各層的依賴關係,越往裡,依賴越低,代碼級別越高。外圓代碼依賴只能指向內圓,內圓不知道外圓的任何事情。一般來說,外圓的聲明(包括方法、類、變數)不能被內圓引用。同樣的,外圓使用的數據格式也不能被內圓使用。
整潔架構各層主要職能如下:
-
Entities:實現領域內核心業務邏輯,它封裝了企業級的業務規則。一個 Entity 可以是一個帶方法的對象,也可以是一個數據結構和方法集合。一般我們建議創建充血模型。
-
Use Cases:實現與用戶操作相關的服務組合與編排,它包含了應用特有的業務規則,封裝和實現了系統的所有用例。
-
Interface Adapters:它把適用於 Use Cases 和 entities 的數據轉換為適用於外部服務的格式,或把外部的數據格式轉換為適用於 Use Casess 和 entities 的格式。
-
Frameworks and Drivers:這是實現所有前端業務細節的地方,UI,Tools,Frameworks 等以及資料庫等基礎設施。
3. 落地實踐
3.1 概述
在整個DDD開發過程中,除了建模方法和理論的學習,實際技術落地還會遇到很多問題。在多個項目的不斷開發演進過程中,循序漸進的總結了很多經驗和小技巧,用於解決過往的缺憾和不足。走向DDD的路有千萬條,這些只是其中的一些可選方案,如有紕漏還請指正。
3.2 工程示例簡介
目前我們採用的是內核整體分離,如下圖所示。
b2b-baseproject-kernel 內核模塊說明
其中: b2b-baseproject-kernel 為內核的Maven工程示例, b2b-baseproject-center為讀寫服務彙總的中心對外服務工程示例。
圖3-1 kernel基礎工程示例
內核Maven工程模塊說明:
1. b2b-baseproject-kernel-common 常用工具類,常量等,不對外SDK暴露;
2. b2b-baseproject-kernel-export 內核對外暴露的信息,為常量,枚舉等,可直接讓外部SDK依賴並對外,減少通用知識重覆定義(可選);
3. b2b-baseproject-kernel-dto 數據傳輸層,方便app層和domain層共用數據傳輸對象,不對外SDK暴露;
4. b2b-baseproject-kernel-ext-sdk 擴展點;(可選,不需要可直接移除)
5. b2b-baseproject-kernel-domain 領域層等(也可以不劃分子模塊,按需劃分即可);
(b2b-baseproject-kernel-domain-common 通用領域,主要為一些通用值對象;
(b2b-baseproject-kernel-domain-ctxmain 核心領域模型,可自行調整名稱;
6. b2b-baseproject-kernel-read-app 讀服務應用層;(可選,不需要可直接移除)
7. b2b-baseproject-kernel-app 寫服務應用層;
b2b-baseproject-center 實現模塊說明
圖3-2 center基礎工程示例
center Maven工程模塊說明:
對外SDK
1. b2b-baseproject-sdk 對外sdk工程;
1.1 b2b-baseproject-base-sdk 基礎sdk;
1.2 b2b-baseproject-core-sdk 寫服務sdk;
1.3 b2b-baseproject-svr-sdk 讀服務sdk;
基礎設施
2. b2b-baseproject-center-common 常用工具類,常量等;
3. b2b-baseproject-center-infrastructure 基礎設施實現層;
(b2b-baseproject-center-dao 基礎設施層的資料庫訪問層,也可不分,直接融合到infrastructure);
(b2b-baseproject-center-es 基礎設施層的ES訪問層,也可不分,直接融合到infrastructure);
center服務層
4. b2b-baseproject-center-service center的業務服務層;
接入層
5. b2b-baseproject-center-provider 服務接入實現;
springboot啟動
6. b2b-baseproject-center-bootstrap springboot應用啟動層;
備註:對外SDK主要考慮適配CQRS原則,將讀寫分為兩個單獨的module, 如果感覺麻煩,也可以合併為一個SDK對外,用不同的分包隔離即可。
內核和實現的關聯
使用內核和具體實現應用分離的劃分是因為前期因為有商業化衍生出了多版本開發。當然目前架構組是不建議一個內核多套實現的,而是建議一個內核加上一個主版本實現。避免因為多版本實現造成分裂,徒增開發和維護成本,改為採用配置和擴展點來滿足差異化需求。
目前我們開發只保持一個主版本,但是工程繼續使用內核分離的方式,即一個內核+一個主版本實現。
優點:
內核和實現代碼完全隔離,得到一個比較乾凈存粹的內核;
雖萬不得已不建議多版本實現,但是萬一要支持多版本,可以直接復用內核;
某種意義上,是一種更合理的分離,保證了內核和實現版本的分離,各自關註各自模塊的核心問題;
缺點:
- 聯調成本增加,每次改完需要本地install 或者推送到遠程Maven倉庫;
基於以上原因,對於小工程不必做以上分離,直接在一個Maven工程中進行依賴開發即可 ,從很多示例教程也是推薦如此。
CQRS(命令與查詢職責分離)
CQRS 就是讀寫分離,讀寫分離的主要目的是為了提高查詢性能,同時達到讀、寫解耦。而 DDD 和 CQRS 結合,可以分別對讀和寫建模。
查詢模型是一種非標準化數據模型,它不反映領域行為,只用於數據查詢和顯示;命令模型執行領域行為,在領域行為執行完成後通知查詢模型。
命令模型如何通知到查詢模型呢?如果查詢模型和領域模型共用數據源,則可以省卻這一步;如果沒有共用數據源,可以藉助於發佈訂閱的消息模式通知到查詢模型,從而達到數據最終一致性。
Martin 在 blog 中指出:CQRS 適用於極少數複雜的業務領域,如果不是很適合反而會增加複雜度;另一個適用場景是為了獲取高性能的查詢服務。
對於寫少讀多的共用類通用數據服務(如主數據類應用)可以採用讀寫分離架構模式。單數據中心寫入數據,通過發佈訂閱模式將數據副本分發到多數據中心。通過查詢模型微服務,實現多數據中心數據共用和查詢。
領域與讀模型的聯繫與差異
領域模型(以聚合根為唯一入口)是承載本體變更的核心,其是對業務模型的根本建模。若聚合根為每一個普通的人體,聚合根主鍵就是身份證ID。假設人人生而自由,不受人控制,那麼當一個人接受到合理命令後進行自我屬性變更,然後對外發送信息。
而視圖層是人體和社會信息的投影,就如我們的教育情況,職業情況,健康情況等一樣。是對某個時刻對本體信息的投影。
視圖因為基於消息傳播的特性,我們的很多視圖可能是延遲或者不一致的。事例:
1. 你已經陽了,而你的健康碼還是綠碼;
2. 你已經結婚,而戶口本還是未婚;
3. 你的結婚證上聚合了你配偶的信息;
現實世界的不一致已經給我們帶來了很多麻煩和困擾,對於IT系統來說也是一樣。視圖的實時更新總是令人神往,但是在分散式系統中面臨諸多挑戰。而為了消除領域模型變更後各種視圖層的延遲和不一致,就需要在消息傳播和更新時機上做一些優化。但是在業務處理上,還是需要容忍一定程度的延遲和不一致,因為分散式系統是很難做到100%的準實時和一致性的。
3.3 問題及解決方案
3.3.1 領域資源註冊中心
背景
一般來講,領域模型不持有倉庫也不不持有其他服務,是一個比較。這就造成領域模型在做一些驗證的時候,僅能進行記憶體態的驗證。對於rpc服務,以及涉及一些重覆性驗證的情況,就顯得無能為力。為了更好的解決這個問題,我們採用了領域模型註冊中心,採用一個單例的類來持有這些服務;
那我們在領域模型中,從資料庫重新載入回來的領域模型,不需要通過spring進行數據封裝,就可以直接使用所依賴的服務。
基於此,這些服務必須是無狀態的,通過輸入領域模型完成數據服務。
/**
* 租戶註冊中心
*
* @author david
* @date 12/12/22
*/
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@Getter
@Setter
public class TenantRegistry {
/**
* 倉庫
*/
private TenantRepository tenantRepository;
/**
* 單例
*/
private static TenantRegistry INSTANCE = new TenantRegistry();
/**
* 獲取單例
*
* @return
*/
public static TenantRegistry getInstance() {
return INSTANCE;
}
}
在領域模型進行數據保存的時候,可用獲取倉庫或者驗證服務進行數據驗證。
/**
* 保存數據
*/
public void save() {
this.validate();
TenantRepository tenantRepository = TenantRegistry.getInstance().getTenantRepository();
tenantRepository.save(this);
}
3.3.2 內核模塊化
一般來講,主站因為服務的客戶量廣,需求多樣,導致功能及依賴服務也會很龐大。然後在進行商業化部署的時候,往往只需要其中10%~50%的能力,如果在部署的時候,全量的服務和領域模型載入意味著需要配置相關的底層資源和依賴,否則可能啟動異常。
內核能力模塊化就顯得尤為重要,目前我們主要利用spring的條件載入實現內核模塊化。如下:
/**
* 租戶構建工廠
*
* @author david
*/
@Component
@ConditionalOnExpression("${b2b.baseproject.kernel.ability.tenant:true}")
public class TenantInfoFactory {
}
/**
* 租戶應用服務實現
*
* @author david
*/
@Service
@ConditionalOnExpression("${b2b.baseproject.kernel.ability.tenant:true}")
public class TenantAppServiceImpl implements TenantAppService {
}
//其它相關資源類似,通過@ConditionalOnExpression("${b2b.baseproject.kernel.ability.tenant:true}") 進行動態開關;
這樣在applicaiton.yml 配置相關能力的true/false, 就可以實現相關能力的按需載入,當然這是強依賴spring的基礎能力情況下。
//appliciaton.yml 配置
b2b:
baseproject:
kernel:
ability:
tenant: true
dict: true
scene: true
可選進一步優化依賴
條件載入使用了spring的註解,某種意義上導致內核和spring進行了耦合。然而,項目中總有終極DDD患者,希望內核中最好連spring的依賴也去掉。這個時候,可以將spring的裝配專門抽取到一個Maven的module作為starter,由這個starter負責spring的相關的註入和依賴進行適配。對於模塊化載入配置,可以繼續沿用conditional的配置,本質上差異不大。
3.3.3 倉庫層diff實踐(可選項)
本案例僅在使用關係型資料庫,且為了提升更新時性能場景適用。如果能偏向於採用支持事務的NoSQL資料庫,那麼本實踐可直接略過。
如果不是受制於關係型資料庫的更加流行的制約,在面向DDD開發之後,大家可能更偏向於NoSQL資料庫,可以將領域對象以聚合根的為整體進行整存整取,這樣可以大大的降低倉庫層存取持久化數據的開發量。而現狀是大部分項目都依賴於關係型資料庫,故而很多數據依然存在複雜的資料庫存儲關係。
如果聚合根下關聯多個實體,那麼在更新的時候,比較簡潔的方式是整體覆蓋,即使數據行沒有發生變更。有時候為了提升資料庫更新的性能,就需要按需更新,這時候就需要追蹤實體對象是否發生變更。
對實體對象的變更追蹤有兩個方式:
A -> 保存更新前快照,使用反射工具深度對比值是否變更;
B -> 使用RecordLog 作為數據狀態跟蹤;
在過往項目中,A/B方案均採用過,A方案的代碼侵入較少,但是需要保留更新前完整快照,使用反射情況下性能會略有影響。 B方案不需要保持更新前完整快照, 也不用反射,但是需要在需要diff的實體對象中增加RecordLog值對象標記數據是新增、修改、或者未變更。
目前我們主要採用B方案,在涉及實體變更的入口方法,順便調用RecordLog的更新方法,這樣在倉庫層既可以判斷是新增、修改、還是沒有發生變更。倉庫層在執行保存的時候,則可用通過recordLog值對象的creating, updating判斷數據的狀態。
/**
* 日誌值對象,用於記錄數據日誌信息
*
* @author david
* @date 2020-08-24
*/
@Getter
@Setter
@ToString
@ValueObject
public class RecordLog implements Serializable, RecordLogCompatible {
/**
* 創建人
*/
private String creator;
/**
* 操作人
*/
private String operator;
/**
* 併發版本號,不一定以第三方傳入的為準
*/
private Integer concurrentVersion;
/**
* 創建時間,不一定以第三方傳入的為準
*/
private Date created;
/**
* 修改時間, 不一定以第三方傳入的為準
*/
private Date modified;
/**
* 創建中
*/
private transient boolean creating;
/**
* 修改中
*/
private transient boolean updating;
/**
* 創建時構建
*
* @param creator
* @return
*/
public static RecordLog buildWhenCreating(String creator) {
return buildWhenCreating(creator, new Date());
}
/**
* 創建時構建,傳入創建時間
*
* @param creator
* @param createTime
* @return
*/
public static RecordLog buildWhenCreating(String creator, Date createTime) {
RecordLog recordLog = new RecordLog();
recordLog.creator = creator;
recordLog.created = createTime;
recordLog.modified = createTime;
recordLog.operator = creator;
recordLog.concurrentVersion = 1;
recordLog.creating = true;
return recordLog;
}
/**
* 更新
*
* @param operator
*/
public void update(String operator) {
setOperator(operator);
setModified(new Date());
setUpdating(true);
concurrentVersion++;
}
}
// 實體變更的時候,需要同步標記recordLog
public class TenantInfo implements AggregateRoot<TenantIdentifier> {
/**
* 失效數據
*
* @param operator
*/
public void invalid(String operator) {
setStatus(StatusEnum.NO);
recordLog.update(operator);
}
/**
* 發佈
*
* @param operator
*/
public void publish(String operator) {
setBusinessStatus(TenantBusinessStatusEnum.PUBLISH);
recordLog.update(operator);
}
/**
* 保存到倉庫
*
* @param tenantInfo
*/
@Override
@Transactional
public void save(TenantInfo tenantInfo) {
TenantInfoPO tenantInfoPO = TenantInfoAssembler.convertToPO(tenantInfo);
RecordLog recordLog = tenantInfo.getRecordLog();
//創建diff判斷
if (recordLog.isCreating()) {
tenantInfoMapper.insert(tenantInfoPO);
} else if (recordLog.isUpdating()) { //更新diff判斷
UpdateWrapper<TenantInfoPO> updateWrapper = new UpdateWrapper<>();
updateWrapper.lambda().eq(TenantInfoPO::getTenantId, tenantInfoPO.getTenantId());
tenantInfoMapper.update(tenantInfoPO, updateWrapper);
}
//將領域事件轉換為taskPo, 併在一個事務之中保存到資料庫,以便保證最終被消費
tenantInfo.publish(localTaskEventFactory.buildEventPersistenceAdapter(event -> TaskAssembler.tenantEventToTaskPO(event)));
}
3.3.4 讀服務設計
一個完整的領域服務,只是寫入沒有讀取是不夠的,只寫不讀會出現信息黑洞,導致領域變更無法被外部感知和使用。如前面所述,讀服務是面向視圖的,其需要的是容易檢索(索引服務),寬表(冗餘關聯信息),摘要信息。且讀服務不對源數據進行修改,無需進行加鎖更註重響應快速。
目前內核能相對標準化的讀服務,主要針對聚合根進行基本的詳情檢索,如通過聚合根主鍵返回基本視圖信息、列表檢索等;其他個性化定製化的查詢參數和響應結果可以依據需求自行設計和擴展,如果是比較定製的查詢服務,可以不必落地到內核之中。
在b2b-baseproject-kernel工程的 read-app 模塊中,我們定義了讀服務的介面和約束返回對象,則在實現的center工程中,主要實現底層的讀倉庫和SDK接入層即可(可通過ES, 關係型資料庫, redis 等來提供底層的檢索服務)。
讀服務介面:
/**
* 租戶應用查詢服務
*
* @author david
**/
public interface TenantInfoQueryService {
/**
* 通過租戶code查詢
*
* @param req
* @return
*/
TenantConstraint getTenantByCode(GetTenantByCodeReq req);
}
/**
* 通過租戶編碼查詢租戶信息請求
*
* @author david
*/
@Setter
@Getter
@ToString
public class GetTenantByCodeReq implements Serializable, Verifiable {
/**
* 租戶編碼
*/
private String tenantCode;
@Override
public void validate() {
Validate.notEmpty(tenantCode, CodeDetailEnum.TENANT);
}
}
/**
* 示例租戶讀服務約束介面
*
* @author david
* @date 4/15/22
*/
public interface TenantConstraint extends RecordLogCompatible {
/**
* 租戶id
*/
Long getTenantId();
/**
* 租戶id,編碼
*/
Integer getTenantCode();
// ...
}
/**
* 租戶應用查詢服務內核實現
*
* @author david
**/
@Service
public class TenantInfoQueryServiceImpl implements TenantInfoQueryService {
//租戶讀倉庫
@Resource
private TenantReadRepo tenantReadRepo;
/**
* 通過租戶id查詢
*
* @param req
* @return
*/
@Override
public TenantConstraint getTenantByCode(GetTenantByCodeReq req) {
req.validate();
return tenantReadRepo.getTenantByCode(req.getTenantCode());
}
//...
}
3.3.5 領域事件發佈
如果不依賴binlog和事務性消息組件, 為了保證領域事件一定被髮送出去,就需要依賴本地事務表。我們將領域對象保存和領域事件發佈任務記錄在一個事務中得以執行。在領域事件推送消息中間件MQ中,在資料庫保存完畢後,先主動發送一次(容許失敗),如果發送失敗再等待定時調度掃描事件表重新發送。如下圖所示:
一般情況下,領域事件都是在業務操作的時候產生,此時我們將領域事件暫存到註冊中心。待入庫的時候,在一個事務包裹中進行保存。發佈者如下所示,如果聚合根需要使用此發佈者事件註冊服務,只需要實現此Publisher介面即可。因為內部使用了WeakHashMap 作為容器,如果當前對象不再被應用,之前註冊的事件列表會被自動回收掉。
/**
* 描述:發佈者介面
*
*/
public interface Publisher {
/**
* 容器
*/
Map<Object, List<DomainEvent>> container = Collections.synchronizedMap(new WeakHashMap<>());
/**
* 註冊事件
*
* @param domainEvent
*/
default void register(DomainEvent domainEvent) {
List<DomainEvent> domainEvents = container.get(this);
if (Objects.isNull(domainEvents)) {
domainEvents = Lists.newArrayListWithCapacity(2);
container.put(this, domainEvents);
}
domainEvents.add(domainEvent);
}
/**
* 獲取事件列表
*
* @return
*/
default List<DomainEvent> getEventList() {
return container.get(this);
}
// 更多代碼...略
}
簡化方案
如果一些簡單的應用,不需要使用MQ消息隊列進行事件中轉,也可以將本地事件表的發送狀態作為任務處理狀態。這樣可以簡化一些網路開銷,如在一個應用內,藉助guava的EventBus組件完成消息發佈-訂閱機制。即簡化為:訂閱處理器如果全部執行成功,才更新消息表為已發送(也可以認為是已執行)。
在實際開發中,實際上我們很多領域事件都是基於此簡化方案進行處理的,因領域事件的部分處理功能簡單,使用簡化方案能節省很多開發時間和代碼量。
3.3.6 SAGA事務
概述
採用DDD之後,雖然還是可以從應用層採用基礎的事務性編程保證本地資料庫的事務性。然而當處於微服務架構模式,我們的業務常常需要多個跨應用的微服務協同,採用事務進行一致性保證就顯得鞭長莫及。
即使不採用DDD編程, 我們過往已經開始採用Binlog(MySQL的主從同步機制)或者事務性消息來實現最終一致性。在越來越流行的微服務架構趨勢下(應用資源的分散式特性),通過傳統的事務ACID(atomicity、consistency、isolation、durability)保證一致性已經很難,現在我們通過犧牲原子性(atomicity)和隔離性(Isolation),轉而通過保證CD來實現最終一致性。
解決分散式事務,有許多技術方案如:兩階段提交(XA)、TCC、SAGA。
關於分散式事務方案的優缺點,有很多論文和技術文章,為什麼選擇SAGA ,正如 Chris Richardson在《微服務架構設計模式》中所述:
XA對中間件要求很高,跨系統的微服務更是讓XA鞭長莫及;XA和分散式應用天生不匹配;
TCC 對每一個參與方需要實現(Try-confirm-cancel)三步,侵入性較大;
SAGA是一種在微服務架構中維護數據一致性的機制,它可以避免分散式事務帶來的問題。通過非同步消息來協調一系列本地事務,從而維護多個服務直接的數據一致性;
SAGA理論部分, 可以參考:分散式事務:SAGA模式和Pattern: Saga
SAGA 理論
1987年普林斯頓大學的Hector Garcia-Molina和Kenneth Salem發表了一篇Paper Sagas,講述的是如何處理long lived transaction(長活事務)。Saga是一個長活事務可被分解成可以交錯運行的子事務集合。其中每個子事務都是一個保持資料庫一致性的真實事務。 論文地址:sagas
Saga的組成
-
每個Saga由一系列sub-transaction Ti 組成; (每個Ti是保證原子性提交);
-
每個Ti 都有對應的補償動作Ci,補償動作用於撤銷Ti造成的結果; (Ti如果驗證邏輯且只讀,可以為空補償,即不需要補償);
-
每一個Ti操作在分散式系統中,要求保證冪等性(可重覆請求而不產生臟數據);
Saga的執行順序有兩種:
-
T1, T2, T3, ..., Tn (理想狀態,直接成功);
-
T1, T2, ..., Tj, Cj,..., C2, C1,其中0 < j < n (向前恢復模式,一般為業務失敗);
-
Saga補償示例: 如果在一個事務處理中,Ti為發郵件, Saga不會先保存草稿等事務提交時再發送,而是立刻發送完成。 如果任務最終執行失敗, Ti已發出的郵件將無法撤銷,Ci操作是補發一封郵件進行撤銷說明。
SAGA有兩種主要的模式,協同式、編排式。
A 事件協同式SAGA(Event choreography)
把Saga的決策和執行順序邏輯分佈在Saga的每個參與方中,他們通過相互發消息的方式來溝通。
在事件編排方法中,第一個服務執行一個事務,然後發佈一個事件,該事件被一個或多個服務進行監聽,這些服務再執行本地事務併發布(或不發佈)新的事件。當最後一個服務執行本地事務並且不發佈任何事件時,意味著分散式事務結束,或者它發佈的事件沒有被任何 Saga 參與者聽到都意味著事務結束。
① 優點:
-
避免中央協調器單點故障風險;
-
當涉及的步驟較少服務開發簡單,容易實現;
② 缺點:
-
服務之間存在迴圈依賴的風險;
-
當涉及的步驟較多,服務間關係混亂,難以追蹤調測;
-
參與方需要彼此感知上下耦合關聯性,無法做到服務單元化;
B 命令編排式SAGA(Order Orchestrator)
中央協調器(Orchestrator,簡稱 OSO)以命令/回覆的方式與每項服務進行通信,全權負責告訴每個參與者該做什麼以及什麼時候該做什麼。
① 優點:
-
服務之間關係簡單,避免服務間迴圈依賴,因為 Saga 協調器會調用 Saga 參與者,但參與者不會調用協調器。
-
程式開發簡單,只需要執行命令/回覆(其實回覆消息也是一種事件消息),降低參與者的複雜性。
-
易維護擴展,在添加新步驟時,事務複雜性保持線性,回滾更容易管理,更容易實施和測試。
② 缺點:
-
中央協調器處理邏輯容易變得龐大複雜,導致難以維護。
-
存在協調器單點故障風險。
命令編排式SAGA示例—— 非訂單聚合提票開票申請
Saga在發票開票申請的案例如下所示,提票申請被拆分為2個主要的SAGA協調器。
① 在接收到【母申請單已經創建事件】即觸發生成協調器1調度——開票申請SAGA協調器, 用於參數驗證、訂單鎖定、占用應開金額和數量、最後按開票規則拆分為多個子申請單(一個子申請單對一張實際的發票)。在多個子申請單完成創建後, 會發佈【子申請單已創建】事件。
② 在接收到【子申請單已經創建事件】即觸發生成協調器2調度——子申請單提票SAGA協調器, 用於子申請單預占流水記錄、提交財務開票、接收財務狀態同步子申請單狀態。
使用編排式Saga, 對每一個步驟的調用也不一定是同步的,也可以發送處理請求後掛起協調處理器,等待非同步消息通知。通過消息中間件如MQ收到某個步驟的處理結果消息,然後再恢復協調器的繼續調度。假設Saga事務的每個步驟都是非同步的,那麼編排式協調器和事件協調器就非常類同,唯一的好處是整個業務處理的消息收發均要通過Saga協調器作為中樞。當前在哪一步驟,下一步要做什麼可以由SAGA協調器統一支配。
對於一個比較複雜的長活事務,從業務的完整性和排查問題的方便性考慮,我們推薦使用Saga編排式事務來收斂業務的調度複雜度,以免在消息發送接收網路中迷失。編排式事務有時候類似一個狀態機,當前任務執行到哪個步驟,哪個狀態能夠被保存和複原,且條理性更加清晰。
在編排式Saga事務中,我們需要使用到eventSource類似的事件記錄,以便記錄每一個步驟的執行情況和部分上下文信息。除了手動建表之外(目前我們採用的方案),也有很多成熟的框架可供選擇,如:alibaba的seata,微服務架構設計模式推薦的eventuate 。
風險:
當然在使用saga中,還需要考慮隔離性缺失帶來的風險,尤其是在交易和金融環節。這不是saga能直接解決的問題,這需要通過語義鎖(未提交數據加欄位鎖,防止臟讀)、交換式更新、版本文件、重讀值等方案進行處理。
4. 參考資料
4.1 參考書籍
Domain-Driven Design《領域驅動設計》--Eric Evans
MicroServices Patterns《微服務架構設計模式》 -- Chirs Richardson
《DDD 實戰課》 -- 歐創新
_4.2_網路資料
DDD(Domain-Driven Design)領域驅動設計在互聯網業務開發中的實踐
https://www.jianshu.com/p/91bfc4f21caa
https://www.jianshu.com/p/4a0d89dd7c20
https://my.oschina.net/lxd6825/blog/5485465
作者:京東零售 張世彬
來源:京東雲開發者社區 轉載請註明來源