如何落地業務建模(2) 實踐DDD時常見的問題


如何落地業務建模

在落地DDD時,關聯模型與軟件實現總有讓人糾結與苦惱的地方,引起這些苦惱的的主要原因是架構風格的變化。我們已經從多層單體架構時代,過渡到了雲原生分布式架構,但所采用的建模思路與編程風格並沒有徹底跟上時代的步伐。這種差異通常會以性能問題或是代碼壞味道的形式出現。
想要真正發揮出DDD的作用,就需要在不同架構風格下,找到能夠維持模型與軟件實現統一的辦法。

關聯對象

DDD中的聚合關系在具體實現中會存在一些問題。

無法封裝的數據庫開銷

聚合與聚合根是構成"富含知識的模型"的關鍵。通過聚合關系,可以將被聚合對象的集合邏輯放置在聚合或聚合根,而不是散落在外,或是放在其它無關的服務中,以避免邏輯泄露。
但在落地時,經常會遇到一個挑戰,即:這些被聚合的對象,通常都是被數據庫持久化的集合。對數據庫系統操作無法被接口抽象隔離,而將技術實現引入領域模型,則有悖領域驅動設計的理念。

比如在極客時間的例子里,要對用戶已經訂閱過的專欄進行分頁顯示,因為性能原因,不能將DB中的Subscription數據全部讀取到內存中再進行分頁,而要在查詢DB時包含分頁邏輯。
那么分頁邏輯放在哪里,才能保持模型與軟件實現的關聯呢?

  • 一種做法是為Subscription構造一個獨立的Repository對象,將分頁邏輯放在里面,但這種做法會導致邏輯泄露。因為Subscription被User聚合,那么User所擁有的Subscription的集合邏輯應該被封裝在User中。為非聚合根提供Repository是一種壞味道。
  • 那么把分頁邏輯放到User上呢,這樣其實也不合適,因為這樣會將技術實現細節引入領域邏輯中,無法保持領域邏輯的獨立。

造成上面兩難局面的根源在於:我們希望在模型中使用集合接口,並借助它封裝具體實現的細節;這基於一個前提,即內存中的集合與數據庫是等價的,都可以通過集合接口封裝。但實際情況是,我們無法忽略數據庫帶來的額外開銷,兩者並不等價。

引入關聯對象

關聯對象是將對象間的關聯關系直接建模出來,然后再通過接口與抽象的隔離,把具體技術實現細節封裝到接口的實現中。

User與Subscription間存在關聯關系,所以新增一個關聯對象來表達:

public interface IMySubscriptions : IQueryable<Subscription>
{
  ...
}

public class User
{
  private IMySubscriptions _mySubscriptions;
}

這里的關聯對象為IMySubscriptions,User是Subscription的聚合根,與之相關的邏輯通過_mySubscriptions完成,仍然在User的上下文中,沒有邏輯泄露。
然后,再通過接口與實現分離的方式,從領域對象中移除對具體技術實現的依賴,通過依賴注入的方式提供接口的具體實現:

public class MySubscriptionsDB : IMySubscriptions
或者數據來源於Restful API
public class MySubscriptionsAPI : IMySubscriptions

關聯對象實際上是通過將隱式的概念顯式化建模來解決問題的,這是面向對象技術解決問題的通則:永遠可以通過引入另一個對象解決問題。

上下文過載

上下文過載(Context Overloading)就是指領域模型中的某個對象會在多個上下文中發揮重要作用,甚至是聚合根。這會導致對象本身變得很復雜、模型僵化;還可能帶來潛在的性能問題。

因富含邏輯而產生的過大類

假設之前的極客時間例子中,模型經過擴展后,包含了三個上下文:

  1. 訂閱:用戶閱讀訂閱內容的上下文,根據訂閱關系判斷哪些內容是用戶可見的;
  2. 社交:用戶維持朋友關系的上下文,可以分享動態與信息;
  3. 訂單:用戶購買專欄的上下文,通過訂單與支付,完成對專欄的訂閱。

按照這個模型,得到的富含知識的實現為:

public class User
{
  // 社交上下文
  private List<Friendship> _friendships;
  public void Make(Friendship friendship)
  {
  }

  // 訂閱上下文
  private List<Subscription> _subscriptions;
  public void Subscribe(Subscription subscription)
  {
  }

  // 訂單上下文
  private List<Order> _orders;
  public void PlaceOrder(Order order)
  {
  }
}

這個實現的問題在於一個對象包含了不同的上下文,即壞味道:過大類,壞處有

  • 模型僵硬,想要理解這個類的行為,就必須理解所有的上下文。只有理解了上下文,才能判斷其中的代碼和行為是否合理。於是上下文的過載就變成了認知的過載,而認知的過載又會造成維護的困難,出現“看不懂、改不動”的祖傳代碼。而改不動的代碼就是改不動的模型,最終提煉知識的循環也就無法進行了;
  • 過大類還容易滋生重復代碼、引入偶然耦合造成的意外缺陷;
  • 性能問題,在不同的上下文中,需要訪問的數據也不盡相同(這個問題可以通過引入關聯對象緩解)

邏輯匯聚於上下文還是實體

上下文過載的根本症結在於:邏輯匯聚於上下文還是實體。
DDD的默認風格是匯聚於實體,類似這里的User類;而如果根據DCI范型(Data-Context-Interaction,數據-上下文-交互),則應該匯聚於顯式建模的上下文對象(Context Object)中,或者上下文中的角色對象(Role Object)中。
這樣做的原因是因為,在不同的上下文中,用戶是以不同的角色與其他對象發生交互的。User在訂閱上下文中的角色是Reader,在訂單上下文中是Buyer,在社交上下文中則是Contact。
而發生上下文過載的根源為:實體在不同的上下文中扮演的多個角色,再借由聚合關系,將不同上下文的邏輯富集於實體中,導致了上下文過載。
所以解決方案就是:針對不同上下文的角色建模,將對應的邏輯富集到角色對象中,再讓實體對象去扮演不同的角色。

通過角色對象分離不同上下文的邏輯

一種實現思路是通過裝飾器模式,構造一系列角色對象(Role Object)作為User的裝飾器:

public class Buyer
{
  private User _user;
  private List<Order> _orders;

  public Buyer(User user)
  {
    _user = user;
  }

  public void PlaceOrder(Order order)
  {
  }
}
public class Reader
{
  private User _user;
  private List<Subscription> _subscriptions;
  
  public Reader(User user)
  {
    _user = user;
  }
  
  public void Subscribe(Subscription subscription)
  {
  }
}
...

在具體的Repository實現中使用這些角色對象:

public class UserRepositoryDB : IUserRepository
{
  public User FindById(long id)
  {
    return db.ExecuteQuery(...);
  }

  public Buyer AsBuyer(User user)
  {
    return new Buyer(user, db.ExecuteQuery(...));
  }

  public Reader AsReader(User user)
  {
    return new Reader(user, db.ExecuteQuery(...));
  }
}

之后,就可以類似下面這樣獲取角色對象了:

var user = repo.FindById(1);
var buyer = repo.AsBuyer(user);
var reader = repo.AsReader(user);

使用角色對象的好處:

  • 把不同上下文中的邏輯分別富集於不同的角色對象中;解決了認知過載的問題,同時也通過封裝隔離了不同上下文的變化。
  • 從實體對象轉化到角色對象經由了顯式的方法調用,這實際上清晰地表示了上下文的切換。

但這個方案在揭示意圖、技術解耦上還做得不夠好;比如假設不是所有數據都來自數據庫,社交上下文中的朋友關系來自Restful API調用,這種情況下,將AsContact放到UserRepositoryDB就不合適了。

通過上下文對象分離不同上下文的邏輯

既然將角色轉換的邏輯放到UserRepositoryDB不合適,那么借鑒前面關聯對象的思路,將上下文直接建模出來,並通過接口隔離具體實現:

public interface IOrderContext
{
  interface IBuyer
  {
    void PlaceOrder(Order order);
  }

  IBuyer AsBuyer(User user);
}
public interface ISocialContext
{
  interface IContact
  {
    void Make(Friendship friendship);
  }

  IContact AsContact(User user);
}
public interface ISubscriptionContext
{
  interface IReader
  {
    void Subscribe(Subscription subscription);
  }

  IReader AsReader(User user);
}

然后將上下文對象的獲取放置到IUserRepository接口中,並在其實現中使用依賴注入獲取不同的上下文對象:

public interface IUserRepository
{
  User FindUserById(long id);
    
  ISubscriptionContext InSubscriptionContext();
  ISocialContext InSocialContext();
  IOrderContext InOrderContext();
}

public class UserRepositoryDB: IUserRepository
{
  //通過依賴注入獲取不同的上下文對象
  private ISubscriptionContext subscriptionContext;
  private ISocialContext socialContext;
  private IOrderContext orderContext;
  ....
}

最后的使用方式就成了:

var buyer = repo.InOrderContext().AsBuyer(user);
var reader = repo.InSubscriptionContext().AsReader(user);
var contact = repo.InSocialContext().AsContact(user);

使用上下文對象重構后得到的好處有:

  • 借由上下文的封裝,不同上下文中的技術實現可以是異構的,不管數據來自數據庫還是第三方API,這些細節都不會暴露給使用者;
  • 軟件實現、模型、統一語言更加緊密地關聯在了一起,上下文對象與界限上下文對應。
  • 更加清楚地揭示了領域知識的意圖,如下圖的領域模型:
    包含上下文的模型
    通過如下IUserRepository的定義可知,User在三個不同的上下文中扮演不同的角色。
public interface IUserRepository
{
  User FindUserById(long id);
    
  ISubscriptionContext InSubscriptionContext();
  ISocialContext InSocialContext();
  IOrderContext InOrderContext();
}

架構分層

如何組織領域邏輯與非領域邏輯,才能避免非領域邏輯對模型的污染。通常會使用分層架構來區分不同的邏輯,將不同的關注度的邏輯封裝到不同的層中,以便擴展維護,同時也能有效地控制變化的傳播。
不同層有不同的需求變化速率(Pace of changing),分層架構對變化傳播的控制,是通過層與層之間的依賴關系實現的,因為下層的修改會波及到上層。所以希望通過層來控制變化的傳播,只要所有層都單向依賴比自己更穩定的層,那么變化就不會擴散了。

DDD中的分層的問題

在DDD中通常會將系統分為四層:
四層架構

  1. 展現層(Representation Layer),負責給最終用戶展現信息,並接受用戶的輸入作為功能的觸發點。如果不是人機交互系統,用戶也可以是其他軟件系統。
  2. 應用層(Application Layer),負責支撐具體的業務或者交互流程,將業務邏輯組織為軟件的功能。
  3. 領域層(Domain Layer),核心的領域概念、信息與規則。它不隨應用層的流程、展現層的界面以及基礎設施層的能力改變而改變。
  4. 基礎設施層(Infrastructure Layer),通用的技術能力,比如數據庫、MQ等。

基礎設施層與領域層誰更穩定

在上圖的四層架構中

  • 展現層最容易改變:新的交互模式、不同的視覺模板都會導致改變;
  • 應用層的邏輯會隨着業務流程以及功能點的變化而改變,比如流程的重組與優化、新功能點的引入;
  • 領域層是核心領域概念的提取,理論上來說,如果通過知識消化完成模型的提取,那么由模型構成的領域層應該就是穩定態了,不會發生重大變化;
  • 基礎設施層的邏輯由所選擇的技術棧決定,更改技術組件、替換框架都會造成基礎設施層的變化。基礎設施層的變化頻率與所用的技術組件有關,越是核心的組件,變化就越緩慢,比如相對數據庫,緩存系統的變化頻率往往會更快。

此外,基礎設施層還可能發生不可預知的突變,比如過去的NoSQL、大數據、雲計算都曾為基礎設施層帶來過突變。而且,周圍系統生態的演化與變更也會造成影響,比如消息通知系統從短信變成微信,支付從網銀變成移動支付等等。

總之基礎設施層沒有領域層穩定,但上圖中,怎么能讓領域層依賴基礎設施層呢?

基礎設施不是層

領域模型對基礎設施的態度是非常微妙的,一方面,領域邏輯必須依賴基礎設施才能完成相應的功能,另一方面,領域模型必須強調自己的穩定性,才能維持它在架構中的核心位置。為了解決這個矛盾,要么承認領域層並不是最穩定的;要么就別把基礎設施當層看。

領域層被人為地設定為最穩定的,實際上可以將領域層看做“在特定技術棧上的領域模型實現”;但這樣可能無法被大多數DDD實踐者接受,所以剩下一個選擇:基礎設施不是層。

能力供應商模式

如何才能取消基礎設施層,但仍然不影響領域模型的實現呢,可以使用能力供應商(Capability Provider)模式。

從基礎設施到有業務含義的能力

假設極客時間的訂單需要通過網銀來支付,並通過郵件將訂單狀態發送給客戶,模型為:
訂單支付模型1
偽代碼:

public class Order {
    public void Pay(){
        bank.pay(...);
        email.send(...);
    }
}

這樣的實現有個問題是領域層的Order直接依賴了基礎設施層的網銀支付、發郵件功能;而領域層是絕對穩定的,它不能依賴任何非領域邏輯(除了基礎庫)。

怎么辦呢,需要將對基礎設施層的依賴,看做一種未被發現的領域概念進行提取,這樣其實就發揮了我們定義業務的權利,從業務角度去思考技術組件的含義。

將技術組件進行擬人化處理

通過擬人化,可以清楚地看到技術組件幫我們完成了什么業務操作,比如轉賬的時出納(Cashier),通知用戶的是客戶(Customer Service),於是模型就能轉化為:
擬人化

這樣就可以將具有業務含義的能力抽象成接口納入領域層,而使用基礎設施的技術能力去實現領域層的接口,即基礎設施層成為了能力供應商。
雖然從實現上看,只是將對具體實現的依賴,轉化為對接口的依賴,但這樣做的好處卻契合了“兩關聯”:

  • 領域模型與軟件實現關聯
  • 統一語言與模型關聯

使用能力供應商的多層架構

可以將基礎設施看做對不同層的擴展或貢獻,它雖被接口隔離,卻是其它層的有機組成部分,作為能力供應商,參與層內、層間的交互。
使用能力供應商的多層架構

能力供應商是一個元模式,關聯對象、角色對象、上下文對象都可以看做它的具體應用。

能力供應商模式的缺點

能力供應商模式有一個缺點是將顯式的依賴關系,轉化為了隱式的依賴關系,這就對知識管理有了更高的要求。
這里把技術概念轉換成了領域概念,並反映到統一語言上,這就需要團隊不斷地執行循環,才能把知識消化掉。業務方與技術方也需要緊密地配合與信任。

不要用解決方案去定義問題,而是問題定義解決方案。相同的解決方案,在面對不同的問題是就是不同的模式,比如代理模式 裝飾器模式 中介者模式,解決方案都是一個類代理給另一個類,但它們並不是同一個東西

參考資料
極客時間:如何落地業務建模 徐昊


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM