在落地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)就是指領域模型中的某個對象會在多個上下文中發揮重要作用,甚至是聚合根。這會導致對象本身變得很復雜、模型僵化;還可能帶來潛在的性能問題。
因富含邏輯而產生的過大類
假設之前的極客時間例子中,模型經過擴展后,包含了三個上下文:
- 訂閱:用戶閱讀訂閱內容的上下文,根據訂閱關系判斷哪些內容是用戶可見的;
- 社交:用戶維持朋友關系的上下文,可以分享動態與信息;
- 訂單:用戶購買專欄的上下文,通過訂單與支付,完成對專欄的訂閱。
按照這個模型,得到的富含知識的實現為:
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中通常會將系統分為四層:
- 展現層(Representation Layer),負責給最終用戶展現信息,並接受用戶的輸入作為功能的觸發點。如果不是人機交互系統,用戶也可以是其他軟件系統。
- 應用層(Application Layer),負責支撐具體的業務或者交互流程,將業務邏輯組織為軟件的功能。
- 領域層(Domain Layer),核心的領域概念、信息與規則。它不隨應用層的流程、展現層的界面以及基礎設施層的能力改變而改變。
- 基礎設施層(Infrastructure Layer),通用的技術能力,比如數據庫、MQ等。
基礎設施層與領域層誰更穩定
在上圖的四層架構中
- 展現層最容易改變:新的交互模式、不同的視覺模板都會導致改變;
- 應用層的邏輯會隨着業務流程以及功能點的變化而改變,比如流程的重組與優化、新功能點的引入;
- 領域層是核心領域概念的提取,理論上來說,如果通過知識消化完成模型的提取,那么由模型構成的領域層應該就是穩定態了,不會發生重大變化;
- 基礎設施層的邏輯由所選擇的技術棧決定,更改技術組件、替換框架都會造成基礎設施層的變化。基礎設施層的變化頻率與所用的技術組件有關,越是核心的組件,變化就越緩慢,比如相對數據庫,緩存系統的變化頻率往往會更快。
此外,基礎設施層還可能發生不可預知的突變,比如過去的NoSQL、大數據、雲計算都曾為基礎設施層帶來過突變。而且,周圍系統生態的演化與變更也會造成影響,比如消息通知系統從短信變成微信,支付從網銀變成移動支付等等。
總之基礎設施層沒有領域層穩定,但上圖中,怎么能讓領域層依賴基礎設施層呢?
基礎設施不是層
領域模型對基礎設施的態度是非常微妙的,一方面,領域邏輯必須依賴基礎設施才能完成相應的功能,另一方面,領域模型必須強調自己的穩定性,才能維持它在架構中的核心位置。為了解決這個矛盾,要么承認領域層並不是最穩定的;要么就別把基礎設施當層看。
領域層被人為地設定為最穩定的,實際上可以將領域層看做“在特定技術棧上的領域模型實現”;但這樣可能無法被大多數DDD實踐者接受,所以剩下一個選擇:基礎設施不是層。
能力供應商模式
如何才能取消基礎設施層,但仍然不影響領域模型的實現呢,可以使用能力供應商(Capability Provider)模式。
從基礎設施到有業務含義的能力
假設極客時間的訂單需要通過網銀來支付,並通過郵件將訂單狀態發送給客戶,模型為:
偽代碼:
public class Order {
public void Pay(){
bank.pay(...);
email.send(...);
}
}
這樣的實現有個問題是領域層的Order直接依賴了基礎設施層的網銀支付、發郵件功能;而領域層是絕對穩定的,它不能依賴任何非領域邏輯(除了基礎庫)。
怎么辦呢,需要將對基礎設施層的依賴,看做一種未被發現的領域概念進行提取,這樣其實就發揮了我們定義業務的權利,從業務角度去思考技術組件的含義。
將技術組件進行擬人化處理
通過擬人化,可以清楚地看到技術組件幫我們完成了什么業務操作,比如轉賬的時出納(Cashier),通知用戶的是客戶(Customer Service),於是模型就能轉化為:
這樣就可以將具有業務含義的能力抽象成接口納入領域層,而使用基礎設施的技術能力去實現領域層的接口,即基礎設施層成為了能力供應商。
雖然從實現上看,只是將對具體實現的依賴,轉化為對接口的依賴,但這樣做的好處卻契合了“兩關聯”:
- 領域模型與軟件實現關聯
- 統一語言與模型關聯
使用能力供應商的多層架構
可以將基礎設施看做對不同層的擴展或貢獻,它雖被接口隔離,卻是其它層的有機組成部分,作為能力供應商,參與層內、層間的交互。
能力供應商是一個元模式,關聯對象、角色對象、上下文對象都可以看做它的具體應用。
能力供應商模式的缺點
能力供應商模式有一個缺點是將顯式的依賴關系,轉化為了隱式的依賴關系,這就對知識管理有了更高的要求。
這里把技術概念轉換成了領域概念,並反映到統一語言上,這就需要團隊不斷地執行循環,才能把知識消化掉。業務方與技術方也需要緊密地配合與信任。
不要用解決方案去定義問題,而是問題定義解決方案。相同的解決方案,在面對不同的問題是就是不同的模式,比如代理模式 裝飾器模式 中介者模式,解決方案都是一個類代理給另一個類,但它們並不是同一個東西
參考資料
極客時間:如何落地業務建模 徐昊