文章目錄
- 1.認識領域驅動設計
- 1.1 簡介
- 1.2 發展歷史
- 1.3 DDD 的興起
- 2.從一個簡單案例
- 2.1 轉賬需求
- 2.2 設計的問題
- 2.3 違反的設計原則
- 3.使用 DDD 進行重構
- 抽象數據存儲層
- 抽象第三方服務
- 抽象中間件
- 封裝業務邏輯
- 重構后的架構
- 4.小結
- 參考文獻
1.認識領域驅動設計
1.1 簡介
領域驅動設計(Domain-Driven Design,DDD)是一種復雜軟件系統建模與設計方法論。
領域驅動設計最早由程序員 Eric Evans 于 2003 年在他的同名書籍《Domain-Driven Design: Tackling Complexity in Software》中提出。
領域驅動設計可以指導我們將復雜系統進行拆分,拆分出各個子系統間的關聯以及是如何運轉的,幫助我們解決大型的復雜系統在落地中遇到的問題。
1.2 發展歷史
在復雜軟件系統設計的領域,有許多重要的著作和方法論,它們提供了設計模式、架構風格、領域驅動設計、微服務等,為軟件開發人員和架構師提供豐富的理論和實踐指導。
- 94 年 GoF 的 《Design Patterns》
這本書介紹了23種設計模式,為面向對象設計提供了標準化的解決方案。
- 99 年 Martin Fowler 的 《Refactoring》
本書強調通過重構來改善代碼的結構和可維護性,提供了多種重構技術和實例。
- 02 年 Martin Fowler《Patterns of Enterprise Application Architecture》
該書介紹了企業級應用程序的架構模式,幫助開發人員設計可擴展和可維護的企業應用程序。
- 03 年 Gregor Hohpe, Bobby Woolf 的 《Enterprise Integration Patterns》
這本書專注于企業應用中的集成模式,提供了大量的消息傳遞解決方案和設計模式。
后來軟件設計理論逐漸開始注重業務,從業務角度給出架構設計理論。
- 03 年 Eric Evans 的《Domain Driven Design》
這本書是領域驅動設計(DDD)的奠基之作,Eric Evans 在書中提出了一系列概念和原則,旨在幫助開發人員解決復雜軟件系統中的業務問題。書中強調了與業務專家的緊密合作、領域模型的創建,以及通過聚合、實體和值對象等概念來管理復雜性。
DDD 提供了一種將業務邏輯與技術實現分離的方法,使得軟件設計更具靈活性和可維護性。這本書極大地影響了軟件開發領域,特別是在設計復雜系統時。
- 13 年 Vaughn Vernon 的《Implementing DDD》
這本書基于 Eric Evans 的 DDD 理論,提供了更詳細的實施指南。Vaughn Vernon 結合了實際案例和代碼示例,介紹了如何在真實項目中應用 DDD 的原則和模式,包括聚合、領域事件、命令查詢職責分離(CQRS)等。
重要性: 本書為開發人員提供了實際的、可操作的建議,幫助他們在項目中有效地實施 DDD。它也涵蓋了現代軟件架構的相關主題,如微服務架構和事件驅動設計。
- 17 年 Uncle Bob 的《Clean Architecture》
這本書討論了軟件架構的基本原則,強調了如何構建可維護、可測試和可擴展的系統。Uncle Bob 提出了“清潔架構”的概念,強調應當將業務邏輯與外部系統(如數據庫、用戶界面等)分離,以便于維護和測試。
本書提供了一種清晰的架構設計思路,強調了關注點分離和依賴倒置等原則,適用于各種規模的項目。它是軟件架構師和開發人員在構建復雜系統時的重要參考。
1.3 DDD 的興起
DDD 在 03 年問世,一直沒有火起來,最近開始流行的原因,主要是借著微服務的東風。
Martin Fowler 于 2014 詳細闡述了微服務架構,隨后微服務架構逐漸興起。
DDD 隨著微服務架構的流行而火起來,主要是因為兩者在設計理念、服務劃分、復雜性管理、團隊協作等方面存在天然的契合。DDD 提供的業務驅動設計和領域建模方法論為微服務架構的有效實施提供了理論支持,使得開發團隊能夠更好地管理復雜性,快速響應業務變化。
隨著企業對靈活性和可擴展性的需求不斷增長,很多大型互聯網企業已經將 DDD 設計方法作為微服務的主流設計方法了。
后面伴隨微服務逐漸進入大家的視野。
2.從一個簡單案例
2.1 轉賬需求
舉一個轉賬的業務場景。
用戶可以通過 APP 轉賬給另一個賬號,且支持跨幣種轉賬。同時因為監管和對賬要求,需要記錄本次轉賬活動。
具體的實現步驟如下:
-
查詢賬戶信息: 從數據庫中獲取涉及的賬戶信息,包括轉出賬戶和轉入賬戶。
-
獲取匯率信息: 從第三方服務(如 Yahoo、XE 或其他匯率提供者)獲取當前匯率,通過開放的 HTTP 接口進行調用。
-
計算轉賬金額: 根據獲取的匯率計算需要轉出的金額,檢查轉出賬戶的余額是否足夠,并確認轉賬金額不超過每日限額。
-
執行轉賬操作: 完成轉賬,扣除相應的手續費,并將更新后的賬戶信息保存到數據庫中。
-
發送審計消息: 通過消息隊列(如 RabbitMQ)發送審計消息,以便后續進行審計和對賬。
偽代碼實現如下:
class TransferService {private yahooRateService;private rabbitMqClient;private mysqlClient;public func transfer(srcAccId, tgtAccId, amount, currency) error {// step 1 從 db 獲取數據(賬戶與余額信息)// step 2 獲取外部數據// step 3 根據獲取到的數據進行業務參數校驗// step 4 計算賬戶余額// step 5 變更賬戶余額至 DB// step 6 發送審計消息return nil}
}
一段業務代碼里經常包含了參數校驗、數據讀取存儲、調用外部服務、業務計算、發送消息等多種邏輯。
2.2 設計的問題
這種是很常見的實現代碼,短時間并沒有什么問題,并且可以滿足業務需求,快速上線但長久以往有如下幾個問題。
(1)可維護性差
可維護性 = 依賴變化時有多少代碼需要隨之改變。
應用程序的生命周期通常包括開發階段和維護階段,其中維護階段的成本往往占據了更大的比例。
開發階段: 通常占據整個生命周期成本的 20% - 30%。
維護階段: 通常占據整個生命周期成本的 70% - 80%。
依賴的變更可能會有如下情況:
- 數據結構的不穩定性
賬戶表對應在代碼中會有一個類與之對應,比如 AccModel。這里的問題是數據庫的表結構和設計是應用的外部依賴,長遠來看都有可能會改變,比如數據庫要做分片,或者變更表字段名等。
還有可能依賴的 ORM 庫的升級或者遷移至新的 ORM 庫,都會有維護成本。
- 第三方服務依賴的不確定性
第三方服務,比如匯率服務的變更。輕則API簽名變化,重則服務不可用需要尋找其他可替代的服務。在這些情況下改造和遷移成本都是巨大的。同時,外部依賴的兜底、限流、熔斷等方案都需要隨之改變。
- 中間件更換
加入今天使用 RabbitMQ 發消息,明天如果要上騰訊云用 RabbitMQ 該怎么辦?后面如果消息的序列化方式從String改為Binary又該怎么辦?如果需要消息分片該怎么改?
(2)可擴展性差
可擴展性 = 新增/變更功能時需要新增/修改多少代碼。
雖然如果業務邏輯簡單,那么面向過程的代碼實現也非常高效簡單,但是當業務功能變多時,其擴展性會變得越來越差。
如果之后要加一個轉賬到外部銀行,原來的代碼還可以復用嗎?
原來的實現面對擴展需求時,有如下問題:
- 數據來源被固定、數據格式不兼容
原有的 AccModel 是從 DB 獲取的,而跨行轉賬的數據可能需要從一個第三方服務獲取,而服務之間數據格式不太可能是兼容的,導致從數據校驗、數據讀寫、異常處理到金額計算等邏輯都要重寫。
- 業務邏輯無法復用
數據格式與數據源的不同,導致核心業務邏輯無法復用。主流程代碼會出現很多 if-else 分支,導致代碼邏輯混亂,難以維護。
- 業務邏輯和數據存儲的相互依賴
當業務邏輯增加變得越來越復雜時,新加入的邏輯很有可能需要對數據庫schema或消息格式做變更。而變更了數據格式后會導致原有的其他邏輯需要一起跟著動。
在最極端的場景下,一個新功能的增加會導致所有原有功能的重構,成本巨大。
(3)可測試性差
可測試性 = 單個測試用例執行時間 * 每個需求所需要增加的測試用例數量。
根據這個定義,上面的實現方便做單元測試嘛?
- 業務邏輯與基礎設施耦合嚴重
當代碼中強依賴了數據庫、第三方服務、中間件等外部依賴之后,想要完整跑通一個測試用例需要確保所有依賴都能跑起來,這個在項目早期是及其困難的。在項目后期也會由于各種系統的不穩定性而導致測試無法通過。
- 測試用例運行耗時長
大多數的外部依賴調用都是I/O密集型,如跨網絡調用、磁盤調用等,而這種I/O調用在測試時需要耗時很久。當一個測試用例需要花超過10秒鐘,會降低測試用例的執行頻率,這回降低代碼可靠性。
- 業務邏輯復雜
假如一段代碼中有A、B、C三個子步驟,而每個步驟有N個可能的狀態,當多個子步驟耦合度高時,為了完整覆蓋所有用例,最多需要有N * N * N個測試用例。當子步驟越多時,需要的測試用例呈指數級增長。
2.3 違反的設計原則
Uncle Bob 在其著作《Agile Software Development, Principles, Patterns, and Practices》提出了面向對象設計的 SOLID 原則。
這里至少違背了如下三個原則:
- 單一職責原則
一個類應該僅負責一個功能或任務,避免承擔多個職責,以便于維護和理解。
但是在這個案例里,類 TransferService 的設計包含了多個功能,查詢數據庫,調用第三方服務,向中間件發送消息,一個類承擔多個職責會導致類變得復雜,難以理解和維護。
- 開放封閉原則
軟件實體應對擴展開放,但對修改封閉,即可以通過擴展現有代碼而不是修改它來增加新功能。
在這個案例里的金額計算屬于可能會被修改的代碼,這個時候該邏輯應該需要被包裝成為不可修改的計算類,新功能通過計算類的拓展實現。
- 依賴倒置原則
高層模塊不應依賴于低層模塊,二者應依賴于抽象,從而減少模塊間的耦合,提高系統靈活性。
在這個案例里外部依賴都是具體的實現,比如 yahooRateService 雖然是一個接口類,但是它對應的是依賴了 Yahoo 提供的具體服務,所以也算是依賴了實現。同樣的 mysqlClient 和 rabbitMqClient 實現都屬于具體實現。
3.使用 DDD 進行重構
下面是重構前的架構:
抽象數據存儲層
新建一個 Account 實體類。
一個實體(Entity)是擁有ID的域對象,除了擁有數據之外,同時擁有行為。
class Account{private id;private user_id;private card_no;private daily_limit;
}
Account 實體類和 AccountModel 數據類的區別:
AccountModel 是單純和數據庫表做映射的數據類,每個字段對應數據庫表的一個列。這種對象叫 Data Object(貧血模型)。
Account 是領域邏輯的實體類,包含屬性,同時也包含行為,屬于實體(充血模型)。
新建賬戶存儲接口類 AccRepo,只負責實體的存儲和讀取,而 AccRepo 的實現類完成數據庫存儲的細節。通過加入 AccRepo 接口,底層的數據庫連接可以通過不同的實現類來替換。
interface AccRepo {func getById(id) Account;func saveToDb(Acc) error;
}// 具體實現
class AccRepoImpl implements AccRepo {func getById(id) Account {// ...}func saveToDb(Acc) error {// ...}
}
DAO 和 AccRepo 類的區別:
DAO 對應的是一個特定的數據庫類型的操作,相當于 SQL 的封裝。所操作的對象都是 Data Object 類,所有接口都可以根據數據庫實現的不同而改變。比如,insert 和 update 屬于數據庫專屬的操作。
AccRepo 對應的是實體對象讀取/儲存的抽象,在接口層面做統一,不關注底層實現。比如,通過 save 保存一個 Entity 對象,但至于具體是 insert 還是 update 并不關心。
通過 Account 類,避免了其他業務邏輯代碼和數據庫的直接耦合,避免了當數據庫字段變化時,大量業務邏輯也跟著變的問題。
通過 AccRepo 接口類,改變業務代碼的思維方式,讓業務邏輯不再面向數據庫編程,而是面向領域模型編程。實現了實體與 DB 的解耦。
通過 AccRepoImpl 實現類,由于其職責被單一出來,只需要關注 Account 到 AccountModel 的映射關系和 AccRepo 方法到 DAO 方法之間的映射關系。
抽象第三方服務
可以新建一個匯率類 ExRate,匯率接口類 ExRateService 和具體實現類 ExRateServiceYahoo。
// 匯率類
class ExRate {private srcCurrency;private tgtCurrency;private rate;// 根據匯率 rate 將金額轉為 tgtCurrency 幣種的金額public func exchangeTo(amount) float64;
}// 匯率接口類
interface ExRateService{func getExchangeRate(srcCurrency, tgtCurrency) ExRate;
}// 匯率實現類
class ExRateServiceYahoo implements ExRateService {//...
}
這是一種常見的設計模式叫做防腐層(Anti-Corruption Layer,ACL)。
防腐層是依賴倒置原則的一種體現:高層模塊不應該依賴底層模塊,二者都該依賴于抽象。
很多時候我們的系統會去依賴第三方系統,而被依賴的系統會有不兼容的協議或技術實現,如果對外部系統強依賴,如果第三方放生變更,我們的系統也會收到影響,那么會導致我們的系統被“腐蝕”。
這個時候,通過在系統間加入一個防腐層,能夠有效的隔離外部依賴和內部邏輯,無論外部如何變更,內部代碼可以盡可能的保持不變。
ACL 有如下好處:
- 適配器。
防腐層可以充當適配器,將不同系統的接口和數據格式轉換為適合當前系統的格式,降低了系統間的耦合度。
- 緩存
防腐層可以實現緩存機制,減少對外部服務的頻繁調用,從而提高系統性能和響應速度。
- 兜底
防腐層提供了兜底機制,能夠處理外部系統的異常和錯誤,確保當前系統的穩定性和可靠性。
- 功能開關
防腐層可以實現功能開關,允許動態啟用或禁用某些功能,從而靈活應對需求變化和系統演進。
- 易于測試
防腐層通過隔離外部依賴,使得單元測試和集成測試變得更加簡單和高效。
抽象中間件
同樣的,我們可以將具體的中間件實現與業務邏輯解耦。
可以新建一個審計消息類 AuditMsg,審計消息發送接口類 AuditMsgProducer 和具體實現類 AuditMsgProducerRabbit。
// 審計消息類
class AuditMsg {private srcAccId;private tgtAccId;private moneyAmount;public func serialize() string;public func deserialize(msg) AuditMsg;
}// 審計消息生產接口
interface AuditMsgProducer {func send(AuditMsg msg);
}// 審計消息生產接口實現類
class AuditMsgProducerRabbit implements AuditMsgProducer{// ...
}
通過對中間件抽象,使得業務邏輯依賴于抽象,而不是具體的中間件。
因為中間件通常需要有通用型,中間件的接口通常是 string 或 byte[] 類型的,導致序列化/反序列化邏輯通常和業務邏輯混雜在一起,造成膠水代碼。通過中間件的 ACL 抽象,可以減少重復的膠水代碼。
封裝業務邏輯
金額沒有 ID,是一個屬性的集合,可以設計為一個值對象(Value Object)。
class Money {private amount;private currency;
}
賬戶有 ID,有屬性,也有行為,轉入和轉出,可以設計為一個實體(Entity)。這個在上文已經完成設計,這里補充上方法。
我們發現這兩個賬號的轉出和轉入實際上是一體的,也就是說這種行為應該被封裝到一個對象中去。
class Account{private id;private user_id;private card_no;private daily_limit;// 轉出func withdraw();// 存入func deposit();
}
因為未來可能有功能上的擴展:比如增加一個扣手續費的邏輯。
這個時候在原有的 TransferService 中做并不合適,在任何一個域對象都不合適,需要有一個新的類去包含跨域對象的行為。這種對象叫做領域服務(Domain Service)。
我們可以新建一個 AccTransferService 接口,并給出一個具體的實現 AccTransferServiceImpl。
interface AccTransferService {function transfer(srcAcc, tgtAcc, money, exchageRate);
}class AccTranferServiceImpl implements AccTransferService{public func transfer(srcAcc, tgtAcc, money, exRate) {tgtMoney = exRate.exchangeTo(money);srcAcc->withdraw(money);tgtAcc->deposit(tgtMoney);}
}
原有的 TransferService 將變成:
class TransferServiceNew {private accRepo;private exRateService;private auditMsgProducer;private accTransferService;public func transfer(srcAccId, tgtAccId, amount, tgtCurrency) {// 讀取數據accRepo.getById(srcAccId)accRepo.getById(trgAccId)// 獲取外部數據exRate = exRateService.getExchangeRate()// 校驗參數// ...// 業務邏輯:轉賬accTransferService.transfer()// 發送審計消息accTransferService.send(AuditMsg)}
}
重構后的架構
按照 DDD 的理論進行重構。
重構后最底層不再是數據庫,而是領域對象:實體(Entity)、值對象(Value Object)和領域服務(Domain Service)。
這些對象不依賴任何外部服務和框架,而是純內存中的數據和操作,打包為領域層(Domain Layer)。領域層沒有任何外部依賴關系。
再其次的是負責組件編排的應用服務(Application Service),歸到應用層(Application Layer)。
但是這些服務僅僅依賴了一些抽象出來的 ACL 類和 Repository 類,而其具體實現類是通過依賴注入注進來的。Application Service、Repository、ACL 等我們歸屬為應用層。
應用層依賴領域層,但不依賴具體實現。
最后是 ACL,Repository 等的具體實現,這些實現通常依賴外部具體的技術實現和框架,所以統稱為基礎設施層(Infrastructure Layer)。Web 框架里的對象如 Controller 之類的通常也屬于基礎設施層。
如果一開始使用 DDD 作為理論指導,重新寫這段代碼,考慮到最終的依賴關系,我們可能先寫 Domain 層的業務邏輯,然后再寫 Application 層的組件編排,最后才寫每個外部依賴的具體實現。
這種架構思路和代碼組織結構就叫做 領域驅動設計(Domain Driven Design)。
4.小結
DDD 是一種軟件開發方法論,旨在通過深入理解業務領域,并將其與軟件設計相結合,來解決復雜系統的開發問題。
DDD 強調在軟件設計中聚焦于領域模型和領域邏輯,以便更好地滿足業務需求。
以 DDD 作為理論指導,我們可以設計出具有如下優點的軟件系統:
- 高可維護性
業務代碼與外部依賴解耦,當外部依賴變更時,業務代碼只用變更跟外部對接的模塊,其他業務邏輯不變。
- 高可擴展性
DDD 鼓勵將系統劃分為多個聚合和模塊,便于對系統進行擴展和重構。開發者可以在不干擾其他模塊的情況下添加新功能。
- 高可測試性
每個拆分出來的模塊都符合單一性原則,絕大部分不依賴框架,可以快速的單元測試,做到100%覆蓋。
- 代碼結構清晰
統一語言(Ubiquitous Language):DDD 強調開發團隊與業務專家之間使用統一的語言,確保代碼與業務概念一致,從而使代碼更易于理解。
合理的分層架構:DDD 通常采用分層架構,如表示層、應用層、領域層和基礎設施層,這種結構使得各個層次的職責分明,便于維護和開發。
當團隊形成規范后,可以快速的定位到相關代碼。
參考文獻
DDD 概念參考 - 領域驅動設計