從橫切到縱切,架構模式CQRS,提高系統進化能力

曾幾何時,你是否疑惑於VO、PO、DTO、BO、POJO、Entity、MODEL的區別?

你是否有過疑問,爲什麼Java裏有這麼多的以O爲名稱結尾的對象?!

你是否也厭倦了編寫從這個O對象到那個O對象之間的轉換代碼?!

你有沒有想過,這一切的根源在哪裏呢?有沒有辦法解決這個問題呢?

本文試圖給你答案!

分層架構的「原罪」

在架構風格:萬金油CS與分層一文中提到,分層架構是個萬金油架構,當你無法確定該使用哪種架構風格的時候,那麼可以先使用分層架構。而實際上確實是這樣,大部分的應用都採用了分層架構,特別是web應用。

以最簡單的三層架構來說:

  • 展示層:展示數據給用戶

  • 邏輯層:處理業務邏輯

  • 持久層:持久化數據

每一層都負責各自的任務、職責單一,開發也就相對簡單。每一層相對獨立,所以都能夠獨立進化,這是分層架構所宣稱的優勢!也是其「原罪」!

分層架構雖然將系統按層進行劃分,但是層與層之間還是需要進行交互的。交互就需要有接口或協議以及傳輸的數據。

對於外部調用,我們可以使用TCP、HTTP、RPC、WebService等方式來進行通信;而對於內部交互來說,我們可以直接使用方法調用,使用接口來進行解耦。

但是傳輸的數據結構該如何定呢?

  • 第一種方式是直接使用基礎數據結構,比如Map?這有幾個問題:

  • 沒有代碼提示,包括IDE層面的提示以及業務層面的字段提示,手誤的機率較大。將編譯期的錯誤延後到了運行期,降低了開發效率

  • 沒有較完備的基礎設施,例如基於註解的字段校驗

  • 性能相對對象會差一點

  • 第二種方式是使用一個對象進行傳遞,例如ActiveRecord或者直接使用Model。但是這會使各層強耦合,使得分層架構的優勢消失。由於每層的進化速度不同:持久層相對比較穩定;邏輯層可能需要根據業務邏輯的不同而進行調整,例如打折策略;而展示層可能需要過一段時間調整,避免審美疲勞。其中一層對傳輸對象的調整都可能導致其它層跟着一起修改。

  • 第三種方式就是上面說的使用各種傳輸對象:各層之間的數據傳輸使用獨立的傳輸對象,使得各層松耦合。但是增加了各種傳輸對象以及轉換代碼。同時轉換也消耗了部分性能。

各層的獨立進化,導致了交互的額外操作!這就是分層架構的「原罪」!也是需要這麼多傳輸對象的其中一個原因!

而另外一個原因是表現力差異!

再談表現力

在領域設計:聚合與聚合根聊到了表現力問題,「數據設計」的表現力要弱於「對象設計」!相對應的,其實「數據展現」的表現力也是弱於「對象設計」的!

我們還是以訂單來舉例!假設我下單購買了多個商品,也就是說一個訂單包含了多個明細。那麼訂單與訂單明細的這層關係在「持久層」是通過主鍵來表現的:

 

訂單明細包含了訂單的主鍵,表示哪些訂單明細是屬於哪個訂單的。

而這層關係在「邏輯層」是通過對象引用來表現的:

 

訂單對象中持有了指向訂單明細列表的引用。

而到了「展示層」,訂單和訂單詳情之間的關係就完全靠展示方式來表現了:

 

如果你不瞭解業務,光看代碼,是看不出訂單與訂單明細之間的關係的。上面只是純粹的展示了訂單明細在訂單信息的下面。

也就是說,當我們訪問頁面的時候,請求從「持久層」將扁平的數據查詢到了「邏輯層」,組裝成了結構化的對象,最後被傳遞到了「展現層」,又被拍扁了展示在我們面前。

由於每層表現形式的不同,亦導致了需要數據傳輸對象。

從橫切到縱切

既然橫向封層不可避免的需要數據傳輸對象來解耦各層之間的關係,那我們是否不使用橫向封層,而使用縱向切分呢?這就是CQRS架構模式!

CQRS通過對系統進行縱向切分:將「數據讀」和「數據寫」分離開,使得數據讀寫獨立進化,來解決數據顯示覆雜性問題。

CQRS架構如下:

 

流程如下:

  • 客戶端構建命令對象CommandModel發送給服務端

  • 服務端通過命令總線CommandBus接收到命令,委託給對應的CommandHandler去處理

  • CommandHandler處理完業務,將此命令通過Repository進行持久化(不一定是DB,下面會具體說)

  • 同時會構建一個對應的事件Event,添加到事件總線EventBus中(該事件可以是同步事件、也可以是異步事件)

  • 對應的EventHandler會對該事件進行處理,比如處理成便於展示的模型,存儲到ReadDB中

  • 客戶端可以對服務端發送查詢,服務端直接從ReadDB中獲取數據,構建QueryModel返回

這又什麼優勢呢?

  • 首先,現在只需要CommandModel和QueryModel兩個數據傳輸對象,不再需要那麼多的中間傳輸對象了。也就是說,省略了這部分的代碼和性能損耗。

  • 其次,讀寫分離,可以對讀寫進行專門的優化。

  • 最後,就是可以事件溯源EventSourcing。這個我們來詳細說一下。

我們以訂單保存和展示流程來詳細的看一下CQRS的優勢!

對於普通分層架構來說,在保存訂單時需要一個DTO用於存儲相關信息,然後轉成多個對應的Model來進行持久化;而查詢訂單的時候,你需要查詢出多個Model,然後組裝成另一個DTO來存儲查詢的信息,因爲展示的時候可能要展示更多的信息,比如買家和賣家相關信息。

同時由於數據都存儲在數據庫中,且表結構與Model是對應的,你能做的優化就是數據庫相關的優化手段。

而在CQRS中,數據庫被分成了讀庫和寫庫。那存在讀庫中的數據結構就可以完全按照展示邏輯來優化,比如:我可以有一張訂單展示表,表中包含了買家信息和賣家信息。在展示時,直接查詢這張表就可以了,不需要和用戶表進行關聯查詢,提高了數據讀性能。

而對於數據持久化來說,就不需要考慮數據展示了,只要提高持久化性能就可以了。例如不使用數據庫,而使用順序寫入的文件方式。同時也不一定要存儲數據本身,轉而存儲事件,就可以實現事件重演,這就是事件溯源。

事件溯源

在領域設計:Entity與VO一文中,提到了「狀態」!

一般我們處理狀態都是直接去修改它,像下面這樣:

 

那麼請問,這個開關剛纔經歷了什麼?!這是典型的ABA問題,即你只知道這個開關目前的狀態,但是它曾經有沒有開過或關過,你就無從得知了。

我們對數據的處理也是這樣,你只知道當前存在數據庫中的數據是什麼,而它曾經被修改過沒有?被修改成過什麼,你無從知曉。

因爲我們存的只是「即時狀態」,即「快照」!

事件溯源存儲的不是數據「快照」,而是「事件本身」!即它記錄了所有對該數據的事件。

如果你瞭解Redis的持久化方案,你對事件溯源就一定不會感到陌生。Redis有兩種持久化方式RDB方式和AOF方式:

  • RDB:在指定的時間間隔內,執行指定次數的寫操作,則會將內存中的數據寫入到磁盤中。對當前數據快照進行持久化

  • AOF:將指令追加到文件末尾。通過指令重演來恢復數據

我們一般的持久化方式實際對應的就是Redis的RDB方式,而事件溯源就是AOF方式。

回到上圖,在CQRS中,WriteDB可以通過類AOF的方式來存儲命令,也就是事件溯源。當需要對ReadDB中的數據進行恢復操作時,可以通過命令重演的方式來恢復。

不過你應該發現問題了,命令重演的方式性能上有問題。所以我們可以參考Redis,使用快照+事件溯源的方式來存儲。即WriteDB中存儲事件,額外再定時對數據進行快照備份。恢復數據時先通過快照備份恢復,再從指定位置進行命令重演,來提高性能。

強一致性or最終一致性

讀寫分離後,導致的一個問題就是讀寫一致性。在原來的分層架構中,數據寫入後再讀取,是可以立即讀取到寫入的數據的(事務保障)。

但是讀寫分離後,讀到的數據不一定是寫入的最新數據。一般情況下,這個問題並不大。因爲實際上你讀的基本上都是歷史數據!爲什麼這麼說呢?因爲你沒法保證數據在展現到你面前的過程中,沒有新的寫入。除非展示是基於推送機制的。

但是對於特殊情況下,可能不能容忍這樣的情況。有幾種解決方案:

  • 臨時性的顯示先前提交給命令模型的參數

  • 在頁面展示查詢模型的時間

  • 使用類似Comet這樣的長鏈接的方式或者事件模式來監聽數據

     

    參考資料

  • CQRS:https://martinfowler.com/bliki/CQRS.html

  • 《實現領域驅動設計》

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章