從單體架構遷移到 CQRS 後,我覺得 DDD 並不可怕
作者 | Chunting Wu
譯者 | 平川
策劃 | 閆園園
本文最初發佈於 InterviewNoodle 博客。
軟件設計是一個不斷髮展演進的過程。每個大型系統都是從小微系統開始的。當現有的架構遇到問題而又無法解決時,系統就會開始演進。每一次演進都會伴隨着一些技術上的選擇。需要解決什麼問題?需要付出什麼樣的代價?作爲一名架構師或高級工程師,必須找到一種合理的演進方式,在開發進度、技術棧、團隊水平等各方面都能滿足條件,這樣才能制定出可行的解決方案。
本文將介紹 CQRS(命令查詢職責分離)的基本理念和要解決的問題。我們將從一個小型單體架構開始,逐步演進,像每一個軟件系統的演進一樣。本文將介紹每一次演進背後的原因和方法。
1 傳統單體架構
這是最常見的系統設計。有一臺 API 服務器,通常是 restful API,和一個數據庫。客戶端事先與後端協商好傳輸格式。讀和寫都是通過 DTO,即數據傳輸對象完成的。然而,後端在處理業務邏輯時需要將 DTO 轉換爲具有領域知識的領域對象,並使用領域對象作爲數據庫的存儲單元。
爲了實現讀 / 寫分離,在左邊的寫路徑中,客戶端向後端發送 DTO,對數據庫進行 CUD(創建 / 更新 / 刪除)操作,後端在處理完成後向客戶端返回表示成功的 Ack 或表示失敗的 Nak。通常,在 restful API 中,2xx 表示成功,4xx 表示失敗。右邊的讀路徑只是通過讀請求來獲得相應的 DTO。
再從客戶端的的角度來說下 DTO 的含義。在客戶端,DTO 通常包含要在屏幕上呈現的所有數據。例如,當你在社交媒體上查看自己的個人資料時,它將包括你的名字、賬戶和其他個人信息,以及你自己最近的活動,甚至你關注的活動。DTO 包含所有需要在這個頁面上呈現的信息。
爲什麼我們要強調讀 / 寫分離?我們不能在讀 / 寫路徑上使用同一個程序嗎?因爲我們想在將來更好地優化我們的系統。寫路徑有特定的優化方法,讀路徑也是如此。比如說,做一個緩存,在讀路徑上可以使用預讀緩存來減少響應時間。而且,寫路徑可以通過寫入緩存來優化。其次,也可以把寫入操作異步執行。將所有 DTO 寫入消息隊列中,並由工作者進程負責處理,通過這種方式來處理大量的數據寫入。此外,可以使用適當的數據庫進行寫入和讀取。
因此,讀 / 寫分離是必不可少的。而且,在系統設計的早期階段就應該考慮到這一點。寫路徑專注於數據的持久化;而讀路徑則專注於數據的查詢。
然而,這個系統設計模型有兩個主要問題:
-
貧血模型,也被稱爲 CRUD 模型。後端專注於數據轉換而不處理業務邏輯,這將導致業務邏輯散落在各處,領域知識也會消失。例如,對於一個電子商務網站,我們會說 “購買”,而不是 “插入一條訂單記錄”。
-
可擴展性不足。從系統架構的角度來看,數據庫很容易成爲整個系統的瓶頸。讀取和寫入都必須在它上面進行。因爲缺少橫向擴展能力,RDBMS 的問題就更加嚴重了。
2 基於任務的單體架構
爲了解決上述傳統單體架構中存在的問題,這裏我們嘗試引入域的概念。
這個圖與上面的圖基本相同。唯一的區別是在寫路徑上用消息代替了 DTO。消息包含動作和數據,而不是像 DTO 那樣只包含數據本身。因此,我們可以在消息中攜帶特定域的動作,使後端更容易識別每個動作,並有一個相應的域實現。
在這個階段,CQRS 中的 C 出現了,消息就是一種命令。然而,可擴展性問題仍未得到解決。
另外,雖然我們簡化了 DTO,改爲使用消息進行通信,但在讀路徑上我們仍然需要 DTO。還是以社交媒體爲例。在修改暱稱時,消息的格式可能是 {"rename": "LazyDr"}。但是當呈現個人資料時,我們還需要額外的信息,如活動。這種信息缺口使得我們有必要在讀路徑上做大量的處理來獲取 DTO。
3 CQS(命令查詢分離)
CQS 的出現就是爲了解決以上讀寫分離的痛點。
讀取時,客戶端需要 DTO,所以後端可以在讀路徑上做一些專門針對讀取的優化,比如從原來的域對象預先生成 DTO,並將 DTO 存儲在專門的數據庫中以供讀取。
這樣一來,在讀路徑上,應用服務的實現變得更加簡單。應用服務會成爲一個很薄的讀取層,只負責分頁、排序等工作。發出請求後,客戶端很容易從數據庫中檢索到 DTO。
那麼問題來了,誰來生成這些預建的 DTO 呢?這是寫路徑的職責。
雖然這幅圖與之前看到的例子類似,但實際上,除了持久化域對象,應用服務還必須持久化 DTO。換句話說,大部分的業務邏輯都壓在了寫路徑上,它還需要準備各種讀視圖。
至此,我們已經解決了遇到的大部分問題,但擴展性問題仍然沒有得到解決。現在,我們進一步明確下擴展性,主要包括兩個方面:流量:寫入量增加。擴展:功能需求增加,例如需要各種不同的讀視圖。繼續以社交媒體爲例,它有一個個人資料的展示,但可能有另一個按照時間線的展示。CQRS 爲什麼寫路徑要負責準備讀視圖?寫應該專注於持久化,各種讀視圖不應該在寫路徑上處理。但是,讀路徑上只有讀,誰該準備那些讀視圖?
因此,完整的解決方案是這樣的:
左邊的寫路徑和右邊的讀路徑已經在 CQS 部分介紹過了。唯一的區別是增加了 Eventually,負責將寫路徑使用的數據庫轉換爲讀路徑使用的數據庫。一旦涉及到數據同步,就可能遇到數據一致性問題,所以這裏列出了幾種實現最終一致性的方法,按耗時從短到長排序如下:
-
後臺線程:典型代表是 Redis。在數據寫入主節點後,Redis 會立即在後臺將數據發送到的副本中。
-
消息隊列加工作者。這是異步數據複製的一種常見做法。在寫入數據庫時,會創建一個事件併發送到消息隊列,然後由工作者處理。
-
提取 - 轉換 - 加載:這個時間間隔最長,從幾分鐘到幾小時不等。使用 map-reduce 或其他方法將結果寫到另一邊。
無論採用哪種方法,單一真相來源都是必須的。也就是說,如果在轉換過程中發生任何故障,系統必須能夠恢復未完成的工作。因此,數據必須唯一而且可靠。
通常,數據有兩種類型:
-
狀態:狀態指你此刻看到的東西,比如說寫在銀行存摺上的餘額。
-
事件:事件是修改每個狀態的動作,例如銀行存摺上的每一條交易記錄。
實際上,我們已經有了可以作爲事件存儲的消息。對於寫路徑,按順序存儲消息非常有效。藉助這些消息,很容易根據需要創建出不同的讀視圖。這種方法也被稱爲事件源。
但僅有事件還很難有效地利用。爲了獲得最終結果,每一次轉換都必須從頭到尾運行,以重建讀視圖。因此,最好是採用一種混合方法。在寫路徑上,將狀態和事件都保留,轉換過程可以根據實際情況選擇數據源。
總結一下 CQRS 中數據的整個生命週期:
數據從客戶端開始,以命令格式進入後端。根據業務邏輯,它被轉換爲域對象並存儲在數據庫中。這些域對象被轉換爲各種讀視圖,並根據要求存儲在不同的專用讀數據庫中。最後,客戶端以 DTO 的形式獲取這些讀視圖。
4 小結
有許多書籍和文章以各種方式介紹了 DDD 和 CQRS。在我看來,這些模式限制了我們在進行 DDD 設計時的想象力,如實體、價值對象、聚合等。這使得大多數開發人員覺得,DDD 離自己很遠,很難實現,也很難實施。事實上,DDD 的概念並不複雜;相反,DDD 是爲了封裝業務邏輯,促進功能需求的擴展。
CQRS 就更簡單了。在這篇文章中,我們從系統演進的過程出發,介紹了整個系統的設計過程和需要解決的問題,最後自然地得出 CQRS 的結論。
系統設計中沒有銀彈。每一次演進都是爲了解決一些特定的問題。然而,它可能會帶來新的問題。以本文的設計過程爲例,CQRS 似乎解決了所有提到的問題,“貧血模型” 和可擴展性不足,但也帶來了新的問題,如數據一致性。每一種技術選擇都有它的權衡,只要瞭解每個選項背後的所有威脅因素,就可以選出相對可以接受的方法。
即使你選擇了 CQRS,在實踐中,實現最終的一致性仍然有三種方法可以選擇。系統設計是不斷選擇的結果。
這篇文章的目的是告訴你,DDD 沒有那麼可怕,CQRS 也沒有那麼複雜,只是一個決定而已。
查看英文原文:
https://medium.com/interviewnoodle/shift-from-monolith-to-cqrs-a34bab75617e
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/Sa9-mBvGOSf6mwWvcBqBGA