當我們有一個以數據為中心的應用程式,即只實現基本的 CRUD 操作,並將業務流程(即要更改的數據和更改的順序)留給用戶時,好處是用戶可以在不需要更改應用程式的情況下更改業務流程。另一方面,這意味著所有用戶都需要知道所有可以使用該應用程式執行業務流程的所有細節,這在沒有明確的規範且有大量人員參與其中時,將會是一個大問題。

在一個以數據為中心的應用程式中,該應用程式對業務流程一無所知,因此該 domain 無法擁有任何「動詞」,也就是說,應用程式本身無法做出除了改變原始數據以外的任何事情。它變成了數據模型(data model)的高度抽象。這些流程只存在於應用程式用戶的腦海中,或者甚至存在於釘在電腦螢幕上的便利貼中。

一個非凡且實用的應用程式旨在減輕使用者的「流程」負擔,透過捕捉他們的意圖,使其成為一個能夠處理行為的應用程式,而不僅僅是儲存數據。

CQRS is the result of an evolution of several technical concepts that work together to help provide the application with an accurate reflection of the domain, while overcoming common technical limitations. CQRS 是許多技術概念演變的結果,這些概念使應用程式能準確地反映領域(domain),並同時克服常見的技術限制。

命令查詢分離 Command Query Separation

正如 Martin Fowler 所述,「命令查詢分離」這個術語是由 Bertrand Meyer 在他的《物件導向軟體建構(Object Oriented Software Construction)》(1988年)中首次提出的 - 這本書被認為是物件導向早期最具影響力的書籍之一。

梅爾認為,作為一個原則,我們不應該有既改變數據又返回數據的方法。因此,我們有兩種類型的方法:

  1. Queries(查詢):返回數據但不更改數據,因此沒有副作用;
  2. Commands(指令):更改數據,但不返回數據。

換句話說,提問不應改變答案,而行動也不應回饋答案,這同時也有助於尊重單一責任原則。

然而,有些模式是這條規則的例外,傳統的佇列和堆疊會彈出在佇列或堆疊中的元素,既改變了佇列或堆疊,也返回了從中移除的元素。

命令模式 Command Pattern

命令模式的主要概念是將我們從資料中心的應用程式轉移到以流程為中心的應用程式,具有領域知識和應用程式流程知識。

在實際操作中,這意味著我們不再讓使用者執行 CreateUser, ActivateUserSendUserCreatedEmail 這三個動作,而是讓使用者直接執行一個 RegisterUser 的指令,這個指令將會執行前述的三個動作,作為一個封裝的業務流程。

一個更生動的例子是當我們有一個表單用來更改客戶資料,假設該表單允許我們更改客戶的名稱、地址、電話號碼,以及他是否為優先客戶,並且只有當客戶付清賬單後才能成為優先客戶。在一個 CRUD 應用程式中,我們會接收資料,檢查客戶是否已經付清賬單,然後接受或拒絕資料更改請求。然而,這裡我們有兩個不同的業務流程:即使客戶沒有付清賬單,更改客戶的名稱、地址和電話號碼也應該成功。使用命令模式,我們可以在程式碼中清楚地區分這兩種情況,通過創建兩個代表兩種不同業務流程的命令:一個用於更改客戶資料,另一個用於將客戶升級為優先狀態,兩種流程都由同一個 UI 觸發。

Provide us with the right level of granularity and intent when modifying data. That’s what commands are all about. - Udi Dahan 2009, Clarified CQRS
在修改數據時,為我們提供適當的細節程度和意圖,這就是命令的意義。 - Udi Dahan 2009,闡述了CQRS

然而,這並不意味著不能有一個簡單的 CreateUser 命令。CRUD 用例可以完美地與帶有意圖的用例共存,這些用例代表著複雜的業務流程,但切勿將它們混淆。

從技術上來說,如同《Head First Design Patterns》所述,命令模式封裝執行某些動作或動作序列所需的所有內容。當我們有多種不同的業務流程(命令)需要在同一地點以相同的方式運行時,這尤其有用,因此它們需要有相同的介面。例如,所有命令都將具有相同的方法 execute(),以便在某個時候,任何命令都可以獨立被觸發而無關乎它是什麼命令,這將允許任何業務流程(命令)被同步或異步執行。

在《Head First Design Patterns》一書中,給出的例子是一個家中燈光的遙控器。我將使用相同的例子,並指出我認為它的不足的地方。

那麼,假設我們有一個可以控制家中燈光的遙控器,上面有一個按鈕可以開啟廚房的燈,另一個按鈕可以關閉這些燈,這些按鈕各自代表我們可以對家中燈光系統發出的命令。

那麼系統可以依下面這種方式設計: light control system

這是一種天真的設計,它甚至沒有考慮到 DIC,也沒有使用適當的 UML,但我希望它能達到目的,所以讓我們來看看上面的圖表:作為對某種交付機制的輸入的反應,LightController 會以 CommandInvoker 作為建構子提共參數實例化,並觸發特定的控制器動作,即 kitchenLightOnAction。這個動作將實例化適當的燈,即 KitchenLight,並且也將實例化適當的命令,即 KitchenLightOnCommand,並將燈物件作為建構子參數傳遞給它,然後將命令交給 CommandInvoker,它將在某個時候執行它。要關掉燈,我們將創建另一個動作和另一個命令,但設計基本上會是相同的。

所以我們有一個命令來開燈,另一個命令來關燈。如果我們需要將燈光設定為 50% 的功率呢?我們需要創建另一個命令!如果我們需要將它們設定為 25% 和 75% 呢?我們需要創建更多的命令!如果,我們有一個調光器,而不是按鈕,可以將燈光設定為幾乎任何值呢?我們不可能創建無窮無盡的命令!!!

在此階段,實作的問題是,指令中的邏輯將會是相同的,但數據(功率)每次都會不同。因此,我們應該創建一個具有相同邏輯並可以套用不同數據來執行的指令。但接下來我們遇到另一個問題:介面的 execute() 方法不接受參數。如果它接受參數,那將會破壞 Command 的整個技術概念(封裝執行某些業務流程所需的所有內容,而不必確切知道將要執行什麼)。

當然,我們可以透過在建構子中傳遞數據來解決這個問題,但這並不優雅。實際上,這將是一種駭客行為,因為該數據並非物件存在的必要條件,而是它執行某些邏輯所需的東西,所以該數據是方法的依賴,而非物件的依賴。

命令總線 Command Bus

可參考我的實作: EventBus

我們可以做的,以解決前述的命令模式限制,就是應用物件導向中最古老的原則之一:將變動的與不變動的分開

在這種情況下,改變的是數據,而不變的是命令中執行的邏輯,所以我們可以將它們分成兩個類別:一個將是用一個簡單的 DTO 來保存數據(我們將其稱為 command),另一個要保留要執行的邏輯(我們將其稱為 handler),它將有一個觸發邏輯執行的方法,即 execute。我們還將使命令調用者(command invoker)進化成能夠接收命令並找出哪個處理器可以處理它的東西。我們將其稱為命令總線(command bus)。

此外,透過稍微改變使用者介面模式,許多指令不需要立即處理,它們可以被排隊並異步執行,這具有一些優點,使系統更為穩健:

  • 因為我們並未立即處理指令,所以對使用者的回應能更快地送回。
  • 如果是因為系統缺陷,例如軟體的錯誤或者資料庫離線,導致命令執行失敗,使用者甚至可能完全不會察覺。當問題解決後,這個命令可以簡單地再次執行。

擁有一個集中的地方來觸發我們需要運行的邏輯(也就是觸發處理器的地方),也讓我們有一個地方可以添加將在所有處理器之前和/或之後執行的邏輯。例如,我們可以在將命令數據傳遞給處理器之前進行驗證,或者我們可以將處理器的執行包裹在一個數據庫事務中,或者我們可以讓命令總線支持複雜的佇列操作和異步命令/處理器執行。

命令總線通常達成這個目標的方式是使用裝飾器(Decorators),這些裝飾器會包裹在命令總線周圍(或已經裝飾它的裝飾器上),形成一種類似俄羅斯套娃的結構。 CommandBusMatryoshka

這讓我們可以創建自己的裝飾器,並配置(可能是第三方的)命令總線,由任何裝飾器組成,按任何順序,將我們的自定義功能添加到命令總線中。如果我們需要命令隊列,我們添加一個裝飾器來管理命令的隊列。如果我們不使用事務型數據庫,我們就不需要一個裝飾器來將處理器執行包裹在數據庫事務中,等等。

命令查詢責任分離 Command Query Responsibility Segregation

透過整合CQS、命令、查詢和命令總線的概念,我們終於達到了CQRS。CQRS可以以不同的方式和不同的層次來實現,也許只有命令方面,或者可能不使用命令總線。為了完整性,這是一個圖表,代表我如何看待一個完整的CQRS實現:

cqrs

查詢端 Query side

遵循 CQS,查詢端只會返回數據,而不會對其進行任何更改。由於我們並不打算對該數據執行業務流程,因此我們不需要業務物件(即實體),所以我們不需要 ORM 為我們填充實體,也不需要獲取所有需要填充實體的數據,我們只需要查詢原始數據給用戶,並且只需要查詢那些真正需要的數據!

這就是一種效能的提升:在查詢資料時,我們不需要經過業務邏輯層來獲取,我們只做並獲取我們確實需要的東西。

由於這種分離,另一種可能的優化是將數據儲存完全分離成兩個獨立的數據儲存:一個專門寫入優化,另一個專為讀取優化。例如,如果我們正在使用 RDBMS(關聯式數據庫管理系統):

  • 讀取操作不需要進行任何數據完整性驗證,也不需要任何外鍵約束,因為數據完整性驗證是在寫入數據儲存時完成的。因此,我們可以從讀取數據庫中移除數據完整性約束
  • 我們也可以在每個模板中使用具有我們需要的確切數據的 DB 視圖,使查詢變得簡單,因此更快(儘管我們需要將視圖與模板變更保持同步,增加了系統的複雜性)。

在這個階段,如果我們為每個模板都有一個專門的 DB 視圖,這使得查詢變得簡單,那我們為什麼還需要一個 RDBMS 來讀取呢?也許我們可以使用像 Mongo DB 或者甚至是Redis 這樣的文件儲存來讀取,這些都更快。也許可以,也許不行,我只是說如果應用程式在讀取方面有性能問題,那麼考慮一下這個問題可能是值得的。

查詢本身可以使用一個查詢物件來完成,該物件返回一個數據數組以在模板中使用,或者我們可以使用更為高級的東西,例如 query bus。舉例來說,它接收一個模板名稱,使用查詢物件來查詢數據,並返回模板所需的 ViewModel 實例。

這種方法可以解決 Greg Young 所指出的幾個問題:

  • 儲存庫上的大量讀取方法通常也包含分頁或排序資訊。
  • 為了建立資料傳輸物件,暴露了領域物件內部狀態的 getters。
  • 在讀取用例上使用預取路徑,因為他們需要 ORM 加載更多的數據。
  • 載入多個聚合根以建立 DTO 會導致對數據模型的查詢不夠理想。另一方面,由於 DTO 建立操作,聚合邊界可能會變得混亂。
  • 然而,最大的問題是查詢的優化極度困難:因為查詢是在物件模型上運行,然後由 ORM 可能轉換為數據模型,因此優化這些查詢可能非常困難。

指揮端 Command side

如前所述,我們透過使用指令,將應用程式從資料中心設計轉變為行為設計,這與領域驅動設計相符。

通過從處理命令的代碼中,從領域中移除讀取操作,Greg Young 所指出的問題就會消失:

  • 領域物件不再需要暴露內部狀態。
  • 除了 GetById 之外,儲存庫幾乎沒有或根本沒有任何查詢方法
  • 我們可以在專注於聚合邊界上的行為。

「一對多」和「多對多」的實體關係可能會對 ORM 性能產生嚴重影響。但好險我們在處理命令時很少需要這些關係,它們主要用於查詢,而我們剛剛將查詢從命令處理中移走,所以我們可以刪除這些實體關係。我在這裡談的不是關係型數據庫管理系統中表之間的關係,那些外鍵約束應該仍然存在於寫入數據庫中,我談的是在 ORM 級別配置的實體之間的連接。

Do we really need a collection of orders on the customer entity? In what command would we need to navigate that collection? In fact, what kind of command would need any one-to-many relationship? And if that’s the case for one-to-many, many-to-many would definitely be out as well. I mean, most commands only contain one or two IDs in them anyway. - Udi Dahan 2009, Clarified CQRS
我們真的需要在客戶實體上收集訂單嗎?在什麼命令中我們需要導航該集合?實際上,什麼樣的命令會需要任何一對多的關係?如果對於一對多的情況就是這樣,那麼多對多肯定也會被排除。我的意思是,大多數命令中只包含一兩個ID。 - Udi Dahan 2009,闡述了CQRS

與查詢端的思考方式相同,如果寫入端並未用於複雜的查詢,我們是否可以將 RDBMS 替換為帶有序列化實體的文件或鍵值儲存?可能可以,也可能不行,但在應用程式在寫入端遇到性能問題,這可能值得思考。

商業流程事件 Business process events

在一個指令被處理之後,如果成功地被處理,處理器會觸發一個事件,通知應用程式的其餘部分發生了什麼。這些事件應該以觸發它的指令來命名,但是,按照事件的規則,它應該使用過去式。

Conclusion 結論

透過使用 CQRS,我們可以完全將讀取模型與寫入模型分開,讓我們能夠擁有最佳化的讀取和寫入操作。這不僅提升了性能,也增加了程式碼庫的清晰度和簡單性,使程式碼庫能夠反映出領域,並提高了程式碼庫的可維護性。

再次強調,這全都關於封裝、低耦合、高內聚,以及單一責任原則。

儘管如此,我們仍需記住,雖然CQRS提供了一種設計風格和多種技術解決方案,可以使應用程式非常強大,但這並不意味著所有的應用程式都應該這樣建立:我們應該在需要的時候,使用我們需要的東西。