由Spring應用的瑕疵談談DDD的概念與應用(二)

aoho發表於2019-04-14

上一篇文章中,通過Spring Web應用的瑕疵引出改善的措施,我們講解了領域驅動開發的相關概念和設計策略。本文主要講解領域模型的幾種型別和DDD的簡單實踐案例。

架構風格

在《實現領域驅動設計》一書中提到了幾種架構風格:六邊形架構、REST架構、CQRS 和事件驅動等。在實際使用中,落地的架構並非是純粹其中的一種,而很有可能戶將上述幾種架構風格結合起來實現。

分層架構

分層架構的一個重要原則是每層只能與位於其下方的層發生耦合。分層架構可以簡單分為兩種,即嚴格分層架構和鬆散分層架構。在嚴格分層架構中,某層只能與位於其直接下方的層發生耦合,而在鬆散分層架構中,則允許某層與它的任意下方層發生耦合。DDD分層架構中比較經典的三種模式:四層架構、五層架構和六邊形架構。

四層架構

Eric Evans在《領域驅動設計-軟體核心複雜性應對之道》這本書中提出了傳統的四層架構模式:

  • User Interface為使用者介面層(或表示層),負責向使用者顯示資訊和解釋使用者命令。這裡指的使用者可以是另一個計算機系統,不一定是使用使用者介面的人。
  • Application為應用層,定義軟體要完成的任務,並且指揮表達領域概念的物件來解決問題。這一層所負責的工作對業務來說意義重大,也是與其它系統的應用層進行互動的必要渠道。應用層要儘量簡單,不包含業務規則或者知識,而只為下一層中的領域物件協調任務,分配工作,使它們互相協作。它沒有反映業務情況的狀態,但是卻可以具有另外一種狀態,為使用者或程式顯示某個任務的進度。
  • Domain為領域層(或模型層),負責表達業務概念,業務狀態資訊以及業務規則。儘管儲存業務狀態的技術細節是由基礎設施層實現的,但是反映業務情況的狀態是由本層控制並且使用的。領域層是業務軟體的核心,領域模型位於這一層。
  • Infrastructure層為基礎實施層,向其他層提供通用的技術能力:為應用層傳遞訊息,為領域層提供持久化機制,為使用者介面層繪製螢幕元件,等等。基礎設施層還能夠通過架構框架來支援四個層次間的互動模式。

傳統的四層架構都是限定型鬆散分層架構,即Infrastructure層的任意上層都可以訪問該層(“L”型),而其它層遵守嚴格分層架構。

五層架構

五層架構是根據《DCI架構:物件導向程式設計的新構想》中提及的DCI架構模式總結而成。DCI架構(Data、Context和Interactive三層架構):

  • Data層描述系統有哪些領域概念及其之間的關係,該層專注於領域物件的確立和這些物件的生命週期管理及關係,讓程式設計師站在物件的角度思考系統,從而讓“系統是什麼”更容易被理解。
  • Context層:是儘可能薄的一層。Context往往被實現得無狀態,只是找到合適的role,讓role互動起來完成業務邏輯即可。但是簡單並不代表不重要,顯示化context層正是為人去理解軟體業務流程提供切入點和主線。
  • Interactive層主要體現在對role的建模,role是每個context中複雜的業務邏輯的真正執行者,體現“系統做什麼”。role所做的是對行為進行建模,它聯接了context和領域物件。由於系統的行為是複雜且多變的,role使得系統將穩定的領域模型層和多變的系統行為層進行了分離,由role專注於對系統行為進行建模。該層往往關注於系統的可擴充套件性,更加貼近於軟體工程實踐,在物件導向中更多的是以類的視角進行思考設計。

DCI目前廣泛被看作是對DDD的一種發展和補充,用在基於物件導向的領域建模上。五層架構的具體定義如下:

  • User Interface是使用者介面層,主要用於處理使用者傳送的Restful請求和解析使用者輸入的配置檔案等,並將資訊傳遞給Application層的介面。
  • Application層是應用層,負責多程式管理及排程、多執行緒管理及排程、多協程排程和維護業務例項的狀態模型。當排程層收到使用者介面層的請求後,委託Context層與本次業務相關的上下文進行處理。
  • Context是環境層,以上下文為單位,將Domain層的領域物件cast成合適的role,讓role互動起來完成業務邏輯。
  • Domain層是領域層,定義領域模型,不僅包括領域物件及其之間關係的建模,還包括物件的角色role的顯式建模。
  • Infrastructure層是基礎實施層,為其他層提供通用的技術能力:業務平臺,程式設計框架,持久化機制,訊息機制,第三方庫的封裝,通用演算法,等等。
六邊形架構

六邊形架構(Hexagonal Architecture),又稱為埠和介面卡風格,最早由 Alistair Cockburn 提出。在 DDD 社群得到了發展和推廣,之所以是六變形是為了突顯這是個扁平的架構,每個邊界的權重是相等的。

我們知道,經典分層架構分為三層(展現層、應用層、資料訪問層),而對於六邊形架構,可以分成另外的三層:

  • 領域層(Domain Layer):最裡面,純粹的核心業務邏輯,一般不包含任何技術實現或引用。
  • 埠層(Ports Layer):領域層之外,負責接收與用例相關的所有請求,這些請求負責在領域層中協調工作。埠層在埠內部作為領域層的邊界,在埠外部則扮演了外部實體的角色。
  • 介面卡層(Adapters Layer):埠層之外,負責以某種格式接收輸入、及產生輸出。比如,對於 HTTP 使用者請求,介面卡會將轉換為對領域層的呼叫,並將領域層傳回的響應進行封送,通過 HTTP 傳回撥用客戶端。在介面卡層不存在領域邏輯,它的唯一職責就是在外部世界與領域層之間進行技術性的轉換。介面卡能夠與埠的某個協議相關聯並使用該埠,多個介面卡可以使用同一個埠,在切換到某種新的使用者介面時,可以讓新介面與老介面同時使用相同的埠。

圖片轉自網路
圖片轉自網路

這樣做的好處是將使業務邊界更加清晰,從而獲得更好的擴充套件性,除此之外,業務複雜度和技術複雜度分離,是 DDD 的重要基礎,核心的領域層可以專注在業務邏輯而不用理會技術依賴,外部介面在被消費者呼叫的時候也不用去關心業務內部是如何實現。

REST架構

RESTful風格的架構將 資源 放在第一位,每個 資源 都有一個 URI 與之對應,可以將 資源 看著是 DDD 中的實體;RESTful 採用具有自描述功能的訊息實現無狀態通訊,提高系統的可用性;至於 資源 的哪些屬性可以公開出去,針對 資源的操作,RESTful使用HTTP協議的已有方法來實現:GET、PUT、POST和DELETE。

在 DDD 的實現中,我們可以將對外的服務設計為 RESTful 風格的服務,將實體/值物件/領域服務作為資源對外提供增刪改查服務。但是並不建議直接將實體暴露在外,一來實體的某些隱私屬性並不能對外暴露,二來某些資源獲取場景並不是一個實體就能滿足。因此我們在實際實踐過程中,在領域模型上增加了 DTO 這樣一個角色,DTO 可以組合多個實體/值物件的資源對外暴露。

CQRS

CQRS 就是平常大家在講的讀寫分離,通常讀寫分離的目的是為了提高查詢效能,同時達到讀/寫的解耦。讓 DDD 和 CQRS 結合,我們可以分別對讀和寫建模,查詢模型通常是一種非規範化資料模型,它並不反映領域行為,只是用於資料顯示;命令模型執行領域行為,且在領域行為執行完成後,想辦法通知到查詢模型。

那麼命令模型如何通知到查詢模型呢? 如果查詢模型和領域模型共享資料來源,則可以省卻這一步;如果沒有共用資料來源,則可以藉助於 訊息模式(Messaging Patterns)通知到查詢模型,從而達到最終一致性(Eventual Consistency)。

Martin 在 blog 中指出:CQRS 適用於極少數複雜的業務領域,如果不是很適合反而會增加複雜度;另一個適用場景為獲取高效能的服務。

領域模型

在上面小節講解了領域驅動設計的幾種架構風格,下面我們具體結合簡單的例項來看其中的領域模型劃分,初步分為4大類:

  1. 失血模型
  2. 貧血模型
  3. 充血模型
  4. 脹血模型

我們看看這些領域模型的具體內容,以及他們的優缺點。

失血模型

失血模型簡單來說,就是domain object只有屬性的getter/setter方法的純資料類,所有的業務邏輯完全由business object來完成(又稱TransactionScript),這種模型下的domain object被Martin Fowler稱之為“貧血的domain object”。如下:

  • 一個實體類叫做Item

    public class Item implements Serializable {   
     private Long id = null;   
     private int version;   
     private String name;   
     private User seller;   
     // ...  
     //   getter/setter方法省略不寫,避免篇幅太長   
    複製程式碼

}
```

  • 一個DAO介面類叫做ItemDao

    public interface ItemDao {   
     public Item getItemById(Long id);   
     public Collection findAll();   
     public void updateItem(Item item);   
    複製程式碼

} ```

  • 一個DAO介面實現類叫做ItemDaoHibernateImpl

    public class ItemDaoImpl implements ItemDao extends DaoSupport {   
     public Item getItemById(Long id) {   
         return (Item) getHibernateTemplate().load(Item.class, id);   
     }   
     public Collection findAll() {   
         return (List) getHibernateTemplate().find("from Item");   
     }   
     public void updateItem(Item item) {   
         getHibernateTemplate().update(item);   
     }   
    複製程式碼

} ```

  • 一個業務邏輯類叫做ItemManager(或者叫做ItemService)

    複製程式碼

public class ItemManager {
private ItemDao itemDao;
public void setItemDao(ItemDao itemDao) { this.itemDao = itemDao;}
public Bid loadItemById(Long id) {
itemDao.loadItemById(id);
}
public Collection listAllItems() {
return itemDao.findAll();
}
public Bid placeBid(Item item, User bidder, MonetaryAmount bidAmount,
Bid currentMaxBid, Bid currentMinBid) throws BusinessException {
if (currentMaxBid != null && currentMaxBid.getAmount().compareTo(bidAmount) > 0) {
throw new BusinessException("Bid too low.");
}

  // ...  
複製程式碼

} ```

以上是一個完整的失血模型的示例程式碼。在這個示例中,loadItemById、findAll 等等業務邏輯統統放在 ItemManager 中實現,而 Item 只有 getter/setter 方法。

貧血模型

簡單來說,就是 domain ojbect 包含了不依賴於持久化的領域邏輯,而那些依賴持久化的領域邏輯被分離到 Service 層。

Service(業務邏輯,事務封裝) --> DAO ---> domain object

由Spring應用的瑕疵談談DDD的概念與應用(二)
這也就是 Martin Fowler 指的 rich domain object:

  • 一個帶有業務邏輯的實體類,即domain object是Item
  • 一個DAO介面ItemDao
  • 一個DAO實現ItemDaoHibernateImpl
  • 一個業務邏輯物件ItemManager

這種模型的優點:

  1. 各層單向依賴,結構清楚,易於實現和維護
  2. 設計簡單易行,底層模型非常穩定

缺點為:

  1. domain object的部分比較緊密依賴的持久化 domain logic 被分離到Service層,顯得不夠 OO
  2. Service 層過於厚重

具體程式碼較為簡單,不再展示。

充血模型

充血模型和第二種模型差不多,所不同的就是如何劃分業務邏輯,即認為,絕大多業務邏輯都應該被放在domain object裡面(包括持久化邏輯),而Service層應該是很薄的一層,僅僅封裝事務和少量邏輯,不和DAO層打交道。

Service(事務封裝) ---> domain object <---> DAO

由Spring應用的瑕疵談談DDD的概念與應用(二)

這種模型就是把第二種模型的 domain object 和 business object 合二為一了。所以 ItemManager 就不需要了,在這種模型下面,只有三個類,他們分別是:

  • Item:包含了實體類資訊,也包含了所有的業務邏輯
  • ItemDao:持久化DAO介面類
  • ItemDaoHibernateImpl:DAO介面的實現類

在這種模型中,所有的業務邏輯全部都在Item中,事務管理也在Item中實現。 這種模型的優點:

  1. 更加符合OO的原則
  2. Service層很薄,只充當Facade的角色,不和DAO打交道。

這種模型的缺點:

  1. DAO和domain object形成了雙向依賴,複雜的雙向依賴會導致很多潛在的問題。
  2. 如何劃分Service層邏輯和domain層邏輯是非常含混的,在實際專案中,由於設計和開發人員的水平差異,可能導致整個結構的混亂無序。
  3. 考慮到Service層的事務封裝特性,Service層必須對所有的domain object的邏輯提供相應的事務封裝方法,其結果就是Service完全重定義一遍所有的domain logic,非常煩瑣,而且 Service 的事務化封裝其意義就等於把 OO 的domain logic 轉換為過程的 Service TransactionScript。

脹血模型

基於充血模型的第三個缺點,有同學提出,乾脆取消Service層,只剩下domain object和DAO兩層,在domain object的domain logic上面封裝事務。

domain object(事務封裝,業務邏輯) <---> DAO

似乎ruby on rails就是這種模型,他甚至把 domain object 和 DAO 都合併了。

該模型優點:

  1. 簡化了分層
  2. 也算符合OO

該模型缺點:

  1. 很多不是domain logic的 service 邏輯也被強行放入 domain object,引起了domain ojbect模型的不穩定
  2. domain object 暴露給web層過多的資訊,可能引起意想不到的副作用。

小結

在這四種模型當中,失血模型和脹血模型應該是不被提倡的。而貧血模型和充血模型從技術上來說,都已經是可行的了。貧血模型和充血模型哪個更加好一些?人們針對這個問題進行了曠日持久的爭論,最後仍然沒有什麼結果。雙方爭論的焦點主要在我上面加粗的兩句話上,就是領域模型是否要依賴持久層,因為依賴持久層就意味著單元測試的展開要更加困難(無法脫離框架進行測試,原文的討論中這裡專指Hibernate),領域層就更難獨立,將來也更難從應用程式中剝離出來,當然好處是業務邏輯不必混放在不同的層中,使得單一職責性體現的更好。而支持者(充血模型)認為,只要將持久層抽象出來,即可減少測試的困難性,同時適用充血模型畢竟帶來了不少開發上的便利性,除了依賴持久層這一點,擁有更多好處的充血模型仍然值得選擇。最後,誰也沒能說服誰,關於貧血模型和充血模型的選擇,更多的要靠具體的業務場景來決定,並不能說哪一種更比哪一種好。設計模式這種東西不是向來都沒有什麼定論麼。

我個人則傾向使用充血模型,因為充血模型更加像一個設計完善的系統架構,好在計算機世界裡有很多的 IOC 和 DI 框架,唯一的缺陷依賴持久層可以通過各種變通的方法繞過,隨著技術的進步,一些缺陷也會被慢慢解決。我的思路是這樣的:先將持久層抽象為介面,然後通過服務層將持久層注入到領域模型中,這樣領域模型僅僅會依賴於持久層的介面。而這個介面,可以利用現有框架的技術進行抽象。

參考

  1. 【DDD】業務建模實踐 —— 刪除帖子
  2. 貧血,充血模型的解釋以及一些經驗

相關文章