在上一篇文章《.NET應用程式框架架構設計實踐 - 概述》的評論部分,有網友提出了一個在面向領域驅動架構的實踐中比較常見的問題:“DDD使用彙總根訪問,那例如那些通用查詢如何??難道都要經過彙總根多步得到嗎?DDD如何?關聯表的查詢,例如3表關聯查詢?”這個問題比較泛,涉及的內容也比較多,我就單獨一篇文章介紹一下我對這個問題的看法。關於上面問題中的“通用查詢”- 呃,這個定義比較模糊,我只能給出我的一些想法或者經驗性的東西,我在本文中的經驗與觀點並不一定會100%適合您的應用情境,但我想應該還是具有一定指導性意義的。
彙總與彙總根
我想,還是從彙總根談起吧。彙總根是DDD中的概念,不管是經典的DDD架構,還是基於事件驅動的CQRS架構,其實它們之間絕大部分概念都是相通的,比如實體、值對象、服務、工廠、倉儲以及彙總/彙總根等。根據我的理解,彙總根是一個實體,它保持著與其它實體/值對象的引用,並與這些實體/值對象一起,來表達領域的通用語言中的一個唯一的無二義的邏輯概念。比如最常見的“客戶(Customer)”,在“線上銷售”的領域中,“客戶”不僅包含它所指代的那個個人(或者是組織)的名稱、聯絡電話、聯絡電郵,還會包含它的聯絡地址(Contact Address)以及寄送地址(Delivery Address),那麼就Address而言,在此我們可以將其視為值對象,因為我們只關心地址本身所包含的資訊。在這裡,“客戶(Customer)”不僅是實體,而且是“客戶-地址”所組成的對象集合(彙總)的彙總根。
在這裡會有異議的地方就是“銷售訂單(Sales Order)”是否應該屬於“客戶(Customer)”彙總。我覺得這還是要看在當前的領域中,“銷售訂單”是不是“客戶”的必有資訊,換句話說,“客戶”是不是沒有“銷售訂單”就不成其為“客戶”。我想,在大多數情況下,“客戶”應該是一個可以脫離“銷售訂單”而單獨存在的實體,那這樣的話,“銷售訂單”也將不屬於“客戶”彙總。
現在讓我們來看“線上銷售”領域中的另一部分:銷售訂單。當然,“銷售訂單(Sales Order)”是實體,本身也是訂單主體與“訂單明細(Sales Lines)”所組成的彙總的彙總根,這是很自然的事情,因為“銷售訂單”如果沒有訂單的明細資訊,也就失去了訂單本身的意義。此外,“客戶”實體也是這個彙總的一個組成部分,這也很好理解,“銷售訂單”本身就是客戶下達的,它不可能脫離“客戶”而憑空存在。於是,以“銷售訂單”為根的彙總,還包括“客戶”實體,以及“訂單明細”(至於“訂單明細”是實體還是值對象,這跟具體的領域定義有密切關係,比如如果涉及商品Item與購買量的打折等內容,那麼“訂單明細”就需要以實體方式處理,否則可以設計成“值對象”以減小系統開銷,本文繞過這個問題的討論)。在作進一步討論之前,讓我們回顧一下DDD中的倉儲。DDD告訴我們,倉儲是作用在彙總根上的:領域模型中對象的儲存與讀取都是以彙總為單位而進行的。
通過上面的討論,針對“線上銷售”領域,我們大致得到了如下的領域模型(為了縮短篇幅,圖中可能會省略某些部分)
問題來了,如果我們需要獲得某個“客戶”的所有訂單,該怎麼辦?在上面的領域模型中,Customer實體並沒有某個屬性或者方法來獲得其所有的銷售訂單。那麼在遇到這樣的問題時,通常都是通過SalesOrder的倉儲,配合規約(Specification)來篩選出所有符合特定“客戶”條件的銷售訂單,然後由倉儲返回銷售訂單的列表。你或許會覺得這種做法比較不科學,你會覺得應該通過Customer實體的某個屬性(比如SalesOrders)來獲得該“客戶”所擁有的所有銷售訂單,這樣會更直截了當些。但在上面我們已經對這個領域模型進行了討論,在我們的案例中,Customer是一個獨立的實體,SalesOrder不是它的必要組成部分。於是,為了維護領域模型的完整性,我們需要利用“銷售訂單”的倉儲來完成這個功能。虛擬碼如下:
public interface ISpecification<T>{ bool IsSatisfiedBy(T obj);}public abstract class Specification<T> : ISpecification<T>{ public abstract Expression<Func<T, bool>> Expression { get; } public bool IsSatisfiedBy(T obj) { return this.Expression.Compile()(obj); }}public class OrderCustomerMatchesSpecification : Specification<SalesOrder>{ private Customer customer; public OrderCustomerMatchesSpecification(Customer customer) { this.customer = customer; } public override Expression<Func<SalesOrder, bool>> Expression { get { return p => p.Customer.Id.Equals(customer.Id); } }}public interface IRepository<T> where T : IAggregateRoot{ void Add(T aggregateRoot); List<T> GetAllBySpecification(ISpecification<T> spec);}public class MemoryRepository<T> : IRepository<T> where T : IAggregateRoot{ private readonly List<T> store = new List<T>(); public void Add(T aggregateRoot) { if (!this.store.Exists(p => p.Id.Equals(aggregateRoot.Id))) this.store.Add(aggregateRoot); } public List<T> GetAllBySpecification(ISpecification<T> spec) { return this.store.Where(spec.IsSatisfiedBy).ToList(); }}ISpecification<SalesOrder> spec = new OrderCustomerMatchesSpecification(custDaxnet);List<SalesOrder> daxnetOrders = salesOrderRepository.GetAllBySpecification(spec);
在上面的代碼中,daxnetOrders對象所儲存的就是所有屬於custDaxnet這個Customer的銷售訂單。通過這個例子我們可以看出,當我們需要某些資訊的時候,我們只與領域模型中的彙總、實體、值對象以及倉儲打交道,我們完全沒有涉及任何資料庫、資料表、欄位、記錄等等這些概念,從上面的代碼也可以看出,我們可以使用服務樁(Service Stub,PoEAA)模式來Mock一個基於記憶體的倉儲,與關係型資料庫毫不相干。事實上也是如此,我們軟體設計者、開發人員以及領域專家在同一個事物上達成共識:領域模型。彙總、實體、值對象等成為領域模型的主要組成部分,而這些對象又各自保持著自己的狀態,也就是我們所需要的資料。在經典的DDD架構風格(例如Microsoft NLayerApp這樣的架構)中,我們通過領域模型中的對象及其之間的關係來獲得我們所需要的資訊,因此,資料的查詢應該是由倉儲引起,並通過彙總實現導航(Navigation)查詢。接下來,讓我們引入關係型資料庫,來談談本文最開始提出的“多個表關聯查詢”的問題。
領域模型 vs 關係型資料庫
在我之前所寫的《經典的應用系統結構、CQRS與事件溯源》一文中,討論了領域模型與關係型資料模型之間的“阻抗失衡”效應,在此也就不再重複了,但我們必須弄清楚一件事情,就是在DDD的實踐中,我們必須拋開關係型資料庫,甚至是其它的一切資料持久化機制,而只關注領域模型。於是,領域模型本身也需要屏蔽資料持久化的細節內容(我們通常稱之為“持久化無關性”,Persistence Ignorance)。這有兩個方面的原因:首先,DDD是面向領域的,不是面向資料的,領域模型對問題域進行了表述,這也是軟體人員與領域專家的溝通橋樑,如果引入資料存放區的細節內容,既不利於溝通,也會使得領域模型過多依賴具體的技術實現方案,提高了系統的耦合度;其次,由於“阻抗失衡”效應的存在,就需要有一個中介角色來解決這個失衡效應,通常是ORM承擔了這個角色,然而,從技術實現的角度看,針對同一個領域模型,ORM可以有不同的處理方式,具體採用哪種處理方式,可以通過ORM架構的配置資訊(例如,NHibernate的hbm對應檔)來決定;在這種情況下,領域模型+ORM決定了關係型資料庫的結構,於是,對資料表、欄位、記錄等關係型資料庫的討論就沒多大意義了,因為關係型資料庫本身的結構也是不確定的。現在,讓我們來看個例子,瞭解一下ORM處理同一個領域模型的不同方式。就以上文所提到的“客戶 - 地址”彙總為例,ORM處理這個彙總至少(但不限於)可以有如下四個方式:
- 外鍵映射模式(Foreign Key Mapping Pattern,PoEAA)
這種方式會將對象間的關係映射到資料表的外部索引鍵關聯。比如“客戶 - 地址”彙總,ORM會在資料庫中產生兩張表:Customer表和Address表,Customer表中包含兩個Address記錄的外鍵引用:
- 關聯表映射模式(Association Table Mapping Pattern,PoEAA)
這種方式會引入第三張資料表,用來儲存另外兩張表之間的主鍵關聯。比如“客戶 - 地址”彙總,ORM會在資料庫中產生三張表:Customer表、Address表以及CustomerAddress表:
- 嵌入值模式(Embedded Value Pattern,PoEAA)
嵌入值模式會將一個對象映射成另一個對象表的若干欄位。比如“客戶 - 地址”彙總,ORM僅會在資料庫中產生一張表:Customer表,其中包含了Address對象所有屬性值的欄位:
- 序列化LOB模式(Serialized LOB Pattern,PoEAA)
該模式會將另一對象的資料序列化成一個LOB(BLOB或者CLOB),然後以一個欄位的形式儲存在當前對象所對應的資料表中。比如“客戶 - 地址”彙總,ORM會在資料庫中產生一張資料表:Customer表,並在其中儲存“地址”對象的序列化LOB資料:
因此,在DDD實踐中,我們不會存在“如何進行關聯表查詢”這樣的問題,我們關注的是領域模型,至於關係型資料庫方面的工作,就交給ORM吧。
當然,理論歸理論,實際項目與理論上的東西相差太大,我們也需要具體問題具體分析。例如,ORM的引入雖然解決了領域模型與關係型資料模型之間的“阻抗失衡”,但也帶來了一定程度的效能問題,對於某些效能要求很高的系統,採用DDD實踐可能就不是一個很好的選擇,當然也可以想辦法找一個折中的方式來處理問題。比如,假設某個系統基本上對效能要求不高,可以採用DDD的實踐方式,只是個別查詢功能(比如總賬報表產生、資料統計等)要求高效,此時,我們還是可以應用DDD的實踐經驗,並試圖在這幾個功能上繞過領域模型,直接採用高效率的資料庫查詢方式(比如ADO.NET),當然這已經脫離了DDD的討論範圍,不過我們的目的就是為了實現一套穩定、安全、高效的系統,DDD或不DDD這並不是重點,重點在於合適就好。我想,這也是架構師的職責所在吧。
在我們採用“非正常手段”慢慢地繞過領域模型的時候,我們會發現一個有趣的現象:其實“查詢”根本就不是領域模型的一部分,“查詢”是可以作為一個單獨的系統而獨立存在的,在需要的時候,這個“查詢系統”可以被整合到實際系統當中(比如採用Microsoft Biztalk Server等手段),為用戶端提供查詢服務。既然“查詢”可以是一個單獨的系統,那麼如何?這個“查詢”系統,方法也就五花八門了:可以繼續結合ORM實現查詢,也可以直接寫SQL語句進行查詢,甚至還可以使用一些現有的查詢方塊架,總之只要能夠向用戶端提供所需要的資料就行了。“查詢”不再受到領域模型的牽制,在如此廣泛的技術選型背景下,我想,要實現一套複雜的、可定製的查詢機制根本就不會是什麼難事。
面向領域驅動的CQRS(Command Query Responsibility Segregation,命令查詢職責分離)架構就是這樣一種架構風格:它完全將“查詢”部分從領域模型中分離出來。
CQRS體繫結構模式
在我之前所寫的《EntityFramework之領域驅動設計實踐【擴充閱讀】:CQRS體繫結構模式》一文(以下簡稱《CQRS》)中,已經非常詳細地對CQRS體繫結構模式進行了介紹和總結,在這裡再對這種結構的“查詢”部分簡要地說幾句。
在CQRS中,我們可以看到,作用在彙總根上的“倉儲”,已經退化成“領域倉儲(Domain Repository)”,領域倉儲也是作用在彙總根上的,但它只有兩個操作:Save以及GetByAggregateRootId。顯而易見,Save的功能就是將整個彙總儲存起來,而GetByAggregateRootId則是通過彙總根的標識來獲得整個彙總。於是,像上面我所例舉的“擷取某個客戶的所有銷售訂單”這樣的操作,在CQRS的Command部分是無法完成的:你無法通過規約(Specification)來獲得“包含”某個客戶的所有訂單,你只能夠通過訂單號來擷取訂單資訊。或許(我是說或許),在CQRS架構的領域模型中我們根本無需知道某個訂單是屬於哪個客戶的,OK,直接將“客戶”實體從“銷售訂單”彙總中排除出去。關於這個問題我在領域驅動設計的官方論壇裡討論過,得到的結論就是:領域模型只應該包含必要的資訊,一切與查詢有關的內容,都應該設計在“查詢”部分。
在《CQRS》一文中我已經給出了一張結構圖,現在我再細化一下這個圖以體現其查詢部分的具體情況:
在中,領域模型在完成操作之後,會產生領域事件,在彙總被儲存到資料庫的同時,領域事件也會被發布到事件匯流排(Event Bus)上。然後,事件派發處理器(Event Dispatcher,在這裡使用的是Microsoft Biztalk Server)會將事件派發到各種不同的訂閱機制,比如Dynamics AX系統或者單獨的查詢資料庫。這樣,查詢資料庫將會有較大的設計空間(比如可以根據用戶端View Model來設計關係型資料庫的表結構),Query Reader的設計也會變得非常簡單。在這樣的結構下,實現通用查詢、複雜查詢也會非常簡單。
總結
總之,領域模型可以提供一定的查詢能力,比如通過倉儲、規約以及對象關係導航等方式獲得所需要的資料,但查詢應該不是領域模型的組成部分,它是可以被分離出去的。對於經典的架構風格(比如Microsoft NLayerApp這樣的架構風格),如果需要獲得複雜的查詢功能,那就直接繞過領域模型,單獨出一個系統直接存取資料庫進行查詢,然後把查詢返回給用戶端;用戶端獲得查詢結果後,再根據修改過的資料,通過倉儲獲得領域對象然後更新領域模型;對於CQRS的架構風格,我們將獲得更大的查詢部分的設計空間,查詢功能的實現也不再成為問題。
希望本文能夠對關注這方面內容的讀者朋友一定的協助。