原文:https://herbertograca.com/2017/10/19/from-cqs-to-cqrs/
這篇文章是軟件架構(gòu)編年史(譯)的一部分,這部編年史由一系列關(guān)于軟件架構(gòu)的文章組成。在這一系列文章中,我將寫下我對軟件架構(gòu)的學(xué)習(xí)和思考,以及我是如何運(yùn)用這些知識(shí)的。如果你閱讀了這個(gè)系列中之前的文章,本篇文章的的內(nèi)容將更有意義。
如果我們的應(yīng)用以數(shù)據(jù)為中心,比如,僅實(shí)現(xiàn)基本的 CRUD 操作而把業(yè)務(wù)流程(例如,哪些數(shù)據(jù)需要修改,應(yīng)按什么順序修改)留給用戶;其優(yōu)點(diǎn)是用戶可以在無需改變應(yīng)用的情況下改變業(yè)務(wù)流程。而另一方面,這意味著所有用戶都需要了解所有使用應(yīng)用可以執(zhí)行的業(yè)務(wù)流程的全部細(xì)節(jié),當(dāng)我們的流程不那么簡單并且需要許多人都去理解它們時(shí),這是一個(gè)大問題。
以數(shù)據(jù)為中心的應(yīng)用對業(yè)務(wù)流程一無所知,因此領(lǐng)域不能使用任何動(dòng)詞,除了修改原始數(shù)據(jù)以外不能做任何事。它變成了徒有其表的數(shù)據(jù)模型抽象。流程都在使用應(yīng)用的用戶腦袋里,甚至只能在他們屏幕周圍貼著的便利貼上找到。
一個(gè)有效的能真正發(fā)揮作用的應(yīng)用的目標(biāo)應(yīng)該是通過捕捉用戶的意圖將他們從“流程”的負(fù)擔(dān)中解放,讓應(yīng)用可以處理行為,而不僅僅只是簡單地存儲(chǔ)數(shù)據(jù)。
CQRS 就是這樣一些技術(shù)概念演化的結(jié)果,它們一起幫助應(yīng)用更準(zhǔn)確地反映領(lǐng)域,同時(shí)還要克服常見的技術(shù)限制。
命令查詢分離
如 Martin Fowler 所述,“命令查詢分離”這個(gè)術(shù)語由 Bertrand Meyer 在他的“Object Oriented Software Construction” (1988)一書中提出。這本書據(jù)說是 OO 早期最有影響力的著作之一。
Meier 為這樣一條原則辯護(hù),我們不應(yīng)該使用既能修改數(shù)據(jù)也能返回?cái)?shù)據(jù)的方法。這樣我們就有了兩種類型的方法:
- 查詢:返回?cái)?shù)據(jù)但不修改數(shù)據(jù),因此沒有副作用;
- 命令:修改數(shù)據(jù)但不返回?cái)?shù)據(jù)。
換句話說,訪問不應(yīng)該改變答案而做事不應(yīng)該給出答案,這樣也遵守了單一職責(zé)原則。
然而,有一些模式是這條規(guī)則的例外,Martin Fowler 又說,傳統(tǒng)的隊(duì)列和堆棧的實(shí)現(xiàn)在彈出一個(gè)元素時(shí),即改變了隊(duì)列/堆棧也返回了移除的元素。
命令模式
命令模式的主要思想就是讓我們遠(yuǎn)離數(shù)據(jù)為中心的應(yīng)用,向具備領(lǐng)域知識(shí)和應(yīng)用流程知識(shí)的以流程為中心的應(yīng)用邁進(jìn)。
事實(shí)上,這意味著用戶不需要按順序分別執(zhí)行“CreateUser”、“ActivateUser”、“SendUserCreatedEmail”三個(gè)操作,只需要簡單地執(zhí)行一個(gè)“RegisterUser”命令,就可以將上面三個(gè)操作作為一個(gè)封裝好的業(yè)務(wù)流程執(zhí)行。
一個(gè)更有意思的例子是使用表單來修改一個(gè)客戶的數(shù)據(jù)。假設(shè)我們可以使用表單來修改客戶的名字、地址和電話號(hào)碼,以及設(shè)置他是否是優(yōu)先客戶。我們還假設(shè)客戶只有支付了賬單才可以成為優(yōu)先客戶。在一個(gè) CRUD 應(yīng)用中,我們在收到數(shù)據(jù)之后,可以檢查客戶是否支付了賬單,還可以接受或是拒絕數(shù)據(jù)修改請求。然而,這卻是兩個(gè)不同的業(yè)務(wù)流程:即便是客戶沒有支付賬單,他也能成功地修改名字、地址和電話號(hào)碼。使用命令模式之后,我們就能在代碼中清晰地區(qū)別它們,創(chuàng)建兩個(gè)代表不同業(yè)務(wù)流程的命令:一個(gè)用來改變客戶數(shù)據(jù),而另一個(gè)用來升級(jí)用戶的優(yōu)先狀態(tài),兩個(gè)流程都由同一個(gè) UI 界面觸發(fā)。
在修改數(shù)據(jù)時(shí)為我們提供正確的粒度和意圖。這就是命令的全部。—— Udi Dahan 2009, Clarified CQRS
可是,還是有一點(diǎn)要記得,并不是說不能有“CreateUser”這樣的簡單命令。CRUD 的用例可以和帶著意圖的代表著復(fù)雜業(yè)務(wù)流程的用例完美共存,重要的是別誤用。
技術(shù)上來說,如Head First Design Patterns 所述,命令模式會(huì)將執(zhí)行一個(gè)動(dòng)作或者一系列動(dòng)作所需的所有信息都封裝起來。當(dāng)我們需要在同一個(gè)地方以同樣的方式執(zhí)行一些不同的業(yè)務(wù)流程(命令)時(shí)這特別有用,因此它們需要同樣的接口。例如,所有命令都有同樣的execute()
方法,這樣在某個(gè)時(shí)刻,任何命令都可以被觸發(fā),不管到底是哪個(gè)命令。這也能讓任何業(yè)務(wù)流程(命令)可以被放到隊(duì)列中在合適的時(shí)候執(zhí)行,同步或異步都行。
Head First Design Patterns 一書給出的例子是屋子里的燈的遙控器。接下來我也會(huì)使用同樣的例子,盡管我會(huì)指出它的不足之處。
那么,假設(shè)我們有一個(gè)控制屋子里的燈的遙控器,上面有一個(gè)按鈕可以打開廚房里的燈,還有一個(gè)按鈕關(guān)掉它們。每個(gè)按鈕都代表著一個(gè)我們可以發(fā)給房屋燈光系統(tǒng)的命令。
下圖是這個(gè)系統(tǒng)一種可能的設(shè)計(jì):
這個(gè)一個(gè)樸素的設(shè)計(jì),當(dāng)然,它甚至不用考慮 DIC 我也完全用不到 UML。但我希望它能表達(dá)我的意思,所以我們來看看上面這幅圖:作為對來自傳達(dá)機(jī)制的輸入的反映,LightController
會(huì)使用參數(shù)為CommandInvoker
的構(gòu)造方法實(shí)例化并觸發(fā)一個(gè)特定的控制器動(dòng)作kitchenLightOnAction
。這個(gè)動(dòng)作將實(shí)例化正確的燈KitchenLight
,還會(huì)實(shí)例化正確的命令KitchenLightOnCommand
,把燈對象作為構(gòu)造方法的參數(shù)傳遞它。然后命令會(huì)被交給CommandInvoker
在某個(gè)時(shí)刻執(zhí)行。要關(guān)燈的話,我們得創(chuàng)建另外的動(dòng)作和命令,但設(shè)計(jì)基本是一樣的。
這樣我們就有了一個(gè)開燈的命令和一個(gè)關(guān)燈的命令。如果我們要將它們的功率設(shè)置為 50% 呢?我們再創(chuàng)建一條命令!如果我們要將它們的功率設(shè)置為 25% 和 75 % 呢?我們創(chuàng)建更多的命令!如果我們不用按鈕而是用調(diào)光器將燈花設(shè)置成任意值呢?我們沒辦法創(chuàng)建無限多的命令!!!
這時(shí)的實(shí)現(xiàn)問題是:雖然命令中的邏輯一樣,但是數(shù)據(jù)(功率的百分比)每次都不一樣。所以我們應(yīng)該創(chuàng)建一個(gè)命令,它的邏輯不變,僅僅是執(zhí)行時(shí)的數(shù)據(jù)不同,但我們就會(huì)面臨一個(gè)問題,接口execute()
方法不接受參數(shù)。如果讓它接受參數(shù),那么將破壞整個(gè)命令的最重要的技術(shù)思路(將執(zhí)行業(yè)務(wù)流程所需的所有信息都封裝起來,而不用知道將要執(zhí)行的到底是哪一個(gè)流程)。
當(dāng)然,我們可以將數(shù)據(jù)傳遞給命令的構(gòu)造方法來繞過這個(gè)問題,但并不優(yōu)雅。實(shí)際上這是一個(gè)非正常的手段,因?yàn)閿?shù)據(jù)不是對象之所以存在的必要信息,數(shù)據(jù)是它執(zhí)行某段邏輯是需要的信息。因此,這些數(shù)據(jù)是方法的依賴而非對象的依賴。
我們還可以使用原生的語言結(jié)構(gòu)[譯:??]來繞過這個(gè)問題,但還是不夠優(yōu)雅。
命令總線
要解決命令模式的這個(gè)限制,我們能做的就是應(yīng)用最古老的 OO 原則:將變化的部分和不變的部分分開。
這個(gè)例子中變化的數(shù)據(jù)不變的是命令中執(zhí)行的邏輯,所以我們可以將它們分成兩個(gè)類。一個(gè)是用來存放數(shù)據(jù)的簡單 DTO(我們稱之為命令),另一個(gè)存放要執(zhí)行的邏輯(我們稱之為處理器),它擁有一個(gè)用來觸發(fā)邏輯執(zhí)行的方法execute(CommandInterface $command): void
。CommandInvoker
也將演化,它將可以接收命令并找出能夠處理該命令的處理器。我們稱之為命令總線。
用戶界面的模式還可以進(jìn)一步修改,許多命令不需要立即處理,它們可以放到隊(duì)列中異步地執(zhí)行。這種方式有一些優(yōu)點(diǎn)能讓系統(tǒng)更健壯:
- 響應(yīng)可以更快地返回給用戶,因?yàn)椴挥玫戎盍⒓磮?zhí)行;
- 如果因?yàn)橄到y(tǒng)缺陷(如出現(xiàn)問題或者數(shù)據(jù)庫下線) 導(dǎo)致命令失敗,用戶可能根本不會(huì)意識(shí)到。當(dāng)問題解決后命令可以簡單地進(jìn)行重放。
在一個(gè)集中的地方處理需要執(zhí)行(觸發(fā)處理器)的邏輯,還會(huì)帶來一個(gè)好處:我們可以在一個(gè)地方為所有處理器增加執(zhí)行前后的邏輯。例如,我們可以在命令數(shù)據(jù)傳給處理器之前進(jìn)行校驗(yàn),或者我們可以用數(shù)據(jù)庫事務(wù)包裝處理器的執(zhí)行邏輯,或者讓命令總線支持復(fù)雜的隊(duì)列操作和異步的命令/處理器執(zhí)行。
命令總線一般會(huì)使用包裝著它的裝飾器(或者已經(jīng)包裝了該裝飾器的裝飾器)來實(shí)現(xiàn)這個(gè)目標(biāo),類似俄羅斯套娃的結(jié)構(gòu).
這樣我們可以創(chuàng)建自己的裝飾器,可以配置命令總線(可能是第三方的)由哪些裝飾器按照何種順序組成,在命令總線中加入我們的定制功能。如果我們需要隊(duì)列,我們就增加一個(gè)管理命令隊(duì)列的裝飾器。如果我們沒有使用支持事務(wù)的數(shù)據(jù)庫,我們就不需要用裝飾器將處理器的執(zhí)行器包裝在數(shù)據(jù)庫事務(wù)中。以此類推。
命令查詢職責(zé)分離
將 CQS、命令和命令總線的概念組合在一起,我們最終得到了 CQRS。CQRS 可以用不同的方式實(shí)現(xiàn),也可以不同程度地實(shí)現(xiàn),也許只用了命令端,也許不會(huì)使用命令總線。為了保持完整性,下面的圖代表了我所認(rèn)為的全套 CQRS 實(shí)現(xiàn):
查詢端
依照 CQS,查詢端只返回?cái)?shù)據(jù),完全不會(huì)修改它。由于我們不會(huì)嘗試在這些數(shù)據(jù)上執(zhí)行業(yè)務(wù)邏輯,我們不需要業(yè)務(wù)對象(如實(shí)體),所以我們不需要 ORM 來填充實(shí)體,也不需要獲取填充實(shí)體所需的全部數(shù)據(jù)。我們只需要查詢原始數(shù)據(jù)展現(xiàn)給用戶,并且只用查詢展現(xiàn)給用戶的模板所需的數(shù)據(jù)!
這立即就可以提升性能:查詢數(shù)據(jù)時(shí)無需穿過業(yè)務(wù)邏輯層,我們直接查詢剛好夠用的數(shù)據(jù)。
這種拆分還可能帶來的優(yōu)化是數(shù)據(jù)存儲(chǔ)完全會(huì)被拆分成兩個(gè)獨(dú)立的數(shù)據(jù)存儲(chǔ):一個(gè)專為寫優(yōu)化,另一個(gè)專為讀優(yōu)化。例如,如果我們使用關(guān)系型數(shù)據(jù)庫管理系統(tǒng):
- 讀操作不需要任何數(shù)據(jù)完整性校驗(yàn),也完全不需要外鍵約束,因?yàn)閿?shù)據(jù)完整性的校驗(yàn)在寫入數(shù)據(jù)存儲(chǔ)是已經(jīng)完成。所以我們可以去掉讀庫的數(shù)據(jù)完整性約束。
- 我們還能使用剛好包含每個(gè)模板需要的數(shù)據(jù)的數(shù)據(jù)庫視圖,讓查詢變得簡單,變得更快(盡管我們要在模板變化時(shí)保持視圖與之同步,而這會(huì)增加系統(tǒng)的復(fù)雜性) 。
這一點(diǎn)上,如果每個(gè)模板我們都有專門的數(shù)據(jù)庫視圖與之對應(yīng)來簡化查詢,為什么我們還需要使用關(guān)系型數(shù)據(jù)庫管理系統(tǒng)來做讀取呢?!也許我們可以使用文檔存儲(chǔ)來做讀取,比如 Mongo DB 甚至 Redis,它們要更快一些。也許可行,也許不行,我只是覺得如果應(yīng)用在讀取端出現(xiàn)性能問題的話這值得考慮。
查詢本身可以使用返回一組供模板使用的數(shù)據(jù)的查詢對象來實(shí)現(xiàn),或者我們可以使用更成熟的方案,例如查詢總線,它接收一個(gè)模板名字,使用一個(gè)查詢對象查詢數(shù)據(jù)并返回該模板需要的 ViewModel 實(shí)例。
這種方法可以解決 Greg Young 提出的一些問題:
- 大量的存儲(chǔ)庫讀方法常常還要包含分頁和排序信息;
- 為了構(gòu)造 DTO,Getter 暴露了領(lǐng)域?qū)ο蟮膬?nèi)部狀態(tài);
- *在讀取用例上使用預(yù)取路徑,因?yàn)樗鼈冃枰嘤?ORM 加載的數(shù)據(jù);
- 構(gòu)建 DTO 需要加載多個(gè)聚合根,導(dǎo)致對數(shù)據(jù)模型的非最優(yōu)查詢。另外,DTO 的構(gòu)建操作還會(huì)導(dǎo)致聚合邊界變得模糊;
- 不過,最大的問題是查詢的優(yōu)化極度困難:因?yàn)椴樵兪轻槍ο竽P偷牟僮魅缓蟊晦D(zhuǎn)換成數(shù)據(jù)模型,比如 ORM,這些查詢的優(yōu)化可能非常困難。
命令端
如前所述,使用命令之后,我們將應(yīng)用由以數(shù)據(jù)為中心的設(shè)計(jì)變成了圍繞行為的設(shè)計(jì),這和 DDD 完全一致。
將讀取操作從處理命令的代碼和領(lǐng)域中去掉之后,Greg Young 提出的問題也就不復(fù)存在:
- 領(lǐng)域?qū)ο笸蝗徊辉傩枰┞秲?nèi)部狀態(tài)了;
- 除了
GetById
之外,資源庫幾乎沒有任何查詢方法; - 聚合的邊界將更聚焦于行為。
實(shí)體間“一對多”和“多對多”的關(guān)系會(huì)嚴(yán)重的影響 ORM 的性能。好消息是我們在處理命令時(shí)很少會(huì)需要這些關(guān)系,它們大多數(shù)時(shí)候只會(huì)在查詢中用到,而我們已經(jīng)把查詢從命令的處理中移走了,所以我們可以移除這些實(shí)體關(guān)系。這里我所說的并不是關(guān)系型數(shù)據(jù)庫管理系統(tǒng)中表之間的關(guān)系,這些外鍵約束依然應(yīng)該存在于寫庫中,我指的是在 ORM 級(jí)別配置的實(shí)體間的連接。
我們真的需要在客戶實(shí)體中保留訂單集合嗎?我們需要在哪條命令中瀏覽這個(gè)集合?實(shí)際上,到底有什么樣的命令會(huì)需要一對多關(guān)系?如果一對多關(guān)系是這種情況,那么多對多關(guān)系絕對也是一樣的。我的意思是,大多數(shù)命令都只包含一兩個(gè) ID。—— Udi Dahan 2009, Clarified CQRS
按照和查詢端的一樣的思路,如果復(fù)雜查詢用不上寫入端,我們能用序列化實(shí)體的文檔或鍵值存儲(chǔ)來代替關(guān)系型數(shù)據(jù)庫管理系統(tǒng)嗎?也許可行,也許不行,我只是覺得如果應(yīng)用在寫入端出現(xiàn)性能問題的話這值得考慮。
業(yè)務(wù)處理事件
命令處理完之后,如果成功,處理器會(huì)觸發(fā)一個(gè)事件將發(fā)生的事情通知到應(yīng)用的其它部分。事件應(yīng)該和按觸發(fā)它的命令一樣,只是應(yīng)該以過去時(shí)態(tài)命名,這是它的命名規(guī)則。
總結(jié)
使用 CQRS 之后,我們就能夠把讀模型和寫模型完全分開,讓我們可以優(yōu)化讀操作和寫操作。除了性能提升,它還讓代碼庫更清晰簡潔,更能體現(xiàn)出領(lǐng)域,更易維護(hù)。
同樣,這全部都是封裝、低耦合、高內(nèi)聚和單一責(zé)任原則的體現(xiàn)。
然而,請記住,盡管 CQRS 提供了一種設(shè)計(jì)風(fēng)格和一些技術(shù)解決方案,可以使應(yīng)用非常健壯,但這并不意味著所有應(yīng)用都應(yīng)該以這種方式構(gòu)建:我們應(yīng)該在需要的時(shí)候使用我們需要的東西。
引用來源
(我認(rèn)為最有價(jià)值的條目都加粗了。)
1994 – Gamma, Helm, Johnson, Vlissides – Design Patterns: Elements of Reusable Object-Oriented Software
1999 – Bala Paranj – Java Tip 68: Learn how to implement the Command pattern in Java
2004 – Eric Freeman, Elisabeth Robson – Head First Design Patterns
2005 – Martin Fowler – Command Query Separation
2009 – Udi Dahan – Clarified CQRS
2010 – Greg Young – CQRS, Task Based UIs, Event Sourcing agh!
2010 – Greg Young – CQRS Documents
2010 – Udi Dahan – Race Conditions Don’t Exist
2011 – Martin Fowler – CQRS
2011 – Udi Dahan – When to avoid CQRS
2014 – Greg Young – CQRS and Event Sourcing – Code on the Beach 2014
2015 – Matthias Noback – Responsibilities of the command bus
2017 – Martin Fowler – What do you mean by “Event-Driven”?
2017* – Doug Gale – Command Pattern
2017* – Wikipedia – Command Pattern