從資源庫中查詢所有需要顯示的數據是困難的,特別是在需要顯示來自不同聚合類型與實例的數據時。領域越復雜,這種困難程度越大。
因此,我們並不期望單單使用資源庫來解決這個問題。因為我們需要從不同的資源庫獲取聚合實例,然后再將這些實例數據組裝成一個數據傳輸對象(DTO)。或者,我們可以在同一個查詢中使用特殊的查找方法將不同資源庫的數據組合在一起。如果這些辦法都不合適,我們可能需要在用戶體驗上做出妥協,使界面顯示生硬地服從於模型的聚合邊界。然而,很多人都認為,這種機械式的用戶界面從長遠看來是不夠的。
那么,有沒有一種完全不同的方法可以將領域數據映射到界面顯示中呢?答案是CQRS。CQRS是緊縮對象(或者組件)設計原則和命令-查詢分離應用在架構模式中的結果。
Bertrand Meyer對CQRS模式有以下評述:
一個方法要么是執行某種動作的命令,要么是返回數據的查詢,而不能兩者皆是。換句話說,問題不應該對答案進行修改。更正式的解釋是,一個方法只有在具有參考透明性時才能返回數據,此時該方法不會產生副作用。
在對象層面,這意味着:
- 如果一個方法修改了對象的狀態,該方法便是一個命令,它不應該返回數據。在Java和C#中,這樣的方法應該聲明為void。
- 如果一個方法返回了數據,該方法便是一個查詢,此時它不應該通過直接的或間接的手段修改對象的狀態。在Java和C#中,這樣的方法應該以其返回的數據類型進行聲明。
這樣的指導原則是非常直接明了的,同時具有實踐和理論基礎作為支撐。但是,在DDD的架構模式中,我們為什么應該使用CQRS呢,又如何使用呢?
在領域模型中——比如限界上下文中所討論的領域模型——我們通常會看到同時包含有命令和查詢的聚合。同時,我們也經常在資源庫中看到不同的查找方法,這些方法對對象屬性進行過濾。但是在CQRS中,我們將忽略這些看似常態的情形,我們將通過不同的方式來查詢用於顯示的數據。
現在,對於同一個模型,考慮將那些純粹的查詢功能從命令功能中分離出來。聚合將不再有查詢方法,而只有命令方法。資源庫也將變成只有add()或save()方法(分別支持創建和更新操作),同時只有一個查詢方法,比如fromId()。這個唯一的查詢方法將聚合的身份標識作為參數,然后返回該聚合實例。資源庫不能使用其他方法來查詢聚合,比如對屬性進行過濾等。在將所有查詢方法移除之后,我們將此時的模型稱為命令模型(也被稱為寫模型)。但是我們仍然需要向用戶顯示數據,為此我們將創建第二個模型,該模型專門用於優化查詢,我們稱這為查詢模型(也被稱為讀模型)。
這不是增加了復雜性嗎?
你可能會認為:這種架構風格需要大量的額外工作,我們解決了一些問題,但同時又帶來了另外的問題,並且我們需要編寫更多的代碼。
但無論如何,不要急於否定這種架構。在某些情況下,新增的復雜性是合理的。請記住,CQRS旨在解決數據顯示復雜性問題,而不是什么絢麗的新風格以使你的簡歷增光添彩。
因此,領域模型將被一分為二,命令模型和查詢模型分開進行存儲。最終,我們得到的組件系統如圖所示:

CQRS的各個方面
接下來,讓我們依次了解CQRS模式的各個方面。我們先從客戶端和查詢模型開始,再了解命令模型。
客戶端和查詢處理器
客戶端則可以是Web瀏覽器,也可以是定制開發的桌面應用程序。它們將使用運行在服務器端的一組查詢處理器。圖中並沒有顯示服務器的架構層次。不管使用什么樣的架構層,查詢處理器都表示一個只知道如何向數據庫執行基本查詢(比如SQL)的簡單組件。
這里並不存在多么復雜的分層,查詢組件至多是對數據存儲(比如數據庫)進行查詢,然后可能將查詢結果以某種格式進行序列化。如果客戶端運行的是C#,那么它可以直接對數據庫進行查詢。然而,這可能需要大量的數據庫連接,此時使用數據庫連接池則是最佳辦法。
如果客戶端可以處理數據庫結果集,此時我們可能不需要對查詢結果進行序列化,但我依然建議使用。這里存在兩種不同的觀點。一種觀點是客戶直接處理結果集,或者是一些非常基本的序列化數據,比如XML和JSON。另一種觀點認為應該將返回數據轉換成DTO讓客戶端處理。這可能只是一個偏好問題,但是任何時候我們引入DTO和DTO組裝器,系統的復雜性都會隨之增加。因此,每個團隊應該選擇最適合自身的方法。
查詢模型(讀模型)
查詢模型是一種非規范化數據模型,它並不反映領域行為,只是用於數據顯示(也有可能是生成數據報告)。如果數據模型是SQL數據庫,那么每張數據庫表便是一種數據顯示視圖。它可以包含很多列,甚至是所顯示數據的一個超集。表視圖可以通過多張表進行創建,此時每張表代表整個顯示數據的一個邏輯子集。
創建足夠多的視圖
值得一提的是,創建CQRS數據視圖可以是非常廉價的,特別是在使用單種形式的事件源時,此時所有的事件都將被持久化,這樣在任何時候我們都可以重新發布顯示數據,我們也可以從頭重建單個顯示視圖,或者將整個查詢模型轉向另外的持久化機制。事件源使我們可以簡單地創建和維護顯示視圖以響應UI變化,這樣我們可以在不考慮數據庫表結構的前提下獲得更直觀的用戶體驗。
比如,要在用戶界面上顯示用戶、經理和管理者等信息,我們縱然可以只創建一張數據庫表來包含所有這些信息。但是,如果為每種類型的用戶分別創建一個顯式(專門的)視圖,我們便可以將每種安全角色的數據進行分離,由此以用戶類型為單位來顯示安全信息。要顯示常規用戶信息,我們可以選擇該常規用戶所對應數據庫表視圖的所有列;要顯示經理信息,我們則可以選擇經理所對應數據庫表視圖的所有列。這樣一來,常規用戶將不能看到經理用戶的數據信息。
此時的選擇語句只需要提供數據庫表視圖的主鍵即可。下面的SQL語句表示了一個查詢處理器選擇一種產品的某個常規用戶的所有數據列:
select * from vw_usr_product where id = ?
順便提一下,這里使用的表視圖命令規范並不值得推薦,但這並不是我們的重點。這里的主鍵表示某種聚合類型或者多聚合組合類型的唯一標識。在本例中,主鍵id表示命令模型中某個Product的唯一標識。數據模型的設計應該遵循“一張表對應一種用戶界面顯示類型(對應的是某種類型的,而不是一個)”的原則,不同的安全角色應該對應有不同的表視圖。但是,我們應該從實際出發,具體情況具體分析。
具體情況具體分析
如果存在25個證券營銷人,但是根據SEC規則,他們相互之間都不能看到彼此的銷售信息,那么此時我們應該創建25個表視圖嗎?這里使用一個過濾器可能更加合適,否則我們需要創建太多的表視圖。
具體實施起來這可能是困難的,因為我們可能需要將多張表或者多個表視圖聯合起來查詢。聯合查詢可能是有必要的,或者至少比過濾器更加實用,特別是當領域中存在大量的安全角色時。
數據庫的表視圖不是會造成重復嗎?
在執行更新時,一個基本的數據庫表視圖是不會產生重復的。一個視圖只對應於一個查詢,在本例中甚至連聯合查詢都不會用到。只有具體化視圖(視圖中存有數據)才存在更新重復,因為此時的視圖數據需要復制到另外的地方以供選擇查詢語句使用。在設計數據庫表和視圖時我們應該多留意,以使對查詢模型的更新達到最優化。
客戶端驅動命令處理
用戶界面客戶端向服務器發送命令(或者間接地執行應用服務)以在聚合上執行相應的行為操作,此時的聚合即屬於命令模型。提交的命令包含了行為操作的名稱和所需參數。命令數據包是一個序列化的方法調用。由於命令模型擁有設計良好的契約和行為,將命令匹配到相應的契約是很直接的事情。
要達到這樣的目的,用戶界面客戶端必須收集到足夠的數據以完成命令調用。這表明我們需要慎重考慮用戶體驗設計,因為用戶體驗設計需要引導用戶如何正確地提交命令。此時最好的方法是使用一種誘導式的,任務驅動式的用戶界面設計,這種方法會把不必要的數據過濾掉,然后執行准確的命令調用。因此,設計出一種演繹式的,能夠生成顯式命令的用戶界面是可能的。
命令處理器
客戶端提交的命令將被命令處理器所接收。命令處理器可以有不同的類型風格,這里我們將分別討論它們的優缺點。
我們可以使用分類風格,此時多個命令處理器位於同一個應用服務中。在這種風格中,我們根據命令類別來實現應用服務。每一個應用服務都擁有多個方法,每個方法處理某種類型的命令。該風格最大的優點是簡單。分類風格命令處理器易於理解,創建簡單,維護方便。
我們也可以使用專屬風格,此時每種命令都對應於某個單獨的類,並且該類只有一個方法。這種風格的優點是:每個處理器的職責是單一的,命令處理器之間相互獨立,我們可以通過增加處理器種類來處理更多的命令。
專屬風格可能發展成為消息風格,其中每個命令將通過異步的消息發送到某個命令處理器。消息風格使得每個命令處理器可以處理某種特殊的消息類型,同時我們可以通過增加單種處理器的數量來緩解消息負載。但是,消息風格並不能作為默認的命令處理方式,因為它的設計比其他兩種都復雜。因此,我們應該首先考慮使用前兩種同步方式的命令處理器,只有在有伸縮性需要的情況下才采用異步方式。可能有人會認為,異步方式可以在不同系統間進行解耦,因此系統具有更高的彈性。這種偏見往往容易導致消息風格命令處理器的產生。
無論采用哪種風格的命令處理器,我們都應該在不同的處理器間進行解耦,不能使一個處理器依賴於另一個處理器。這樣,對一種處理器的重新部署不會影響到其他處理器。
命令處理器通常只完成有限的功能。如果處理器擁有創建功能,那么它會創建一個新的聚合實例,然后將該實例添加到資源庫中。通常地,命令處理器將從資源庫中獲取聚合實例,再調用該實例的行為方法:
public void CommitBacklogItemToSprint(string aTenantId,string aBacklogItemId,string aSprintId)
{
TenantId tenantId = new TenantId(aTenantId); BacklogItem backlogItem = backlogItemRepository.backlogItemOfId(tenantId,new BacklogItemId(aBacklogItemId)); Sprint sprint = sprintRepository.sprintOfId(tenantId,new SprintId(aSprintId)); backlogItem.CommiTo(sprint); }
當該命令處理器執行結束后,一個聚合實例將被更新,同時命令模型還將發布一個領域事件。對於更新查詢模型來說,這樣的領域事件是至關重要的。值得注意的是,就像在領域事件和聚合中所講,所發布的領域事件還可能導致另一些受同一個命令所影響的聚合實例的同步更新,最終,這些聚合實例都將與本次事務所修改的聚合實例保持最終一致性。
