從單體架構遷移到 CQRS 後,DDD 並不可怕

danny_2018發表於2022-08-29

 本文最初釋出於 InterviewNoodle 部落格。

  軟體設計是一個不斷髮展演進的過程。每個大型系統都是從小微系統開始的。當現有的架構遇到問題而又無法解決時,系統就會開始演進。每一次演進都會伴隨著一些技術上的選擇。需要解決什麼問題?需要付出什麼樣的代價?作為一名架構師或高階工程師,必須找到一種合理的演進方式,在開發進度、技術棧、團隊水平等各方面都能滿足條件,這樣才能制定出可行的解決方案。

  本文將介紹 CQRS(命令查詢職責分離)的基本理念和要解決的問題。我們將從一個小型單體架構開始,逐步演進,像每一個軟體系統的演進一樣。本文將介紹每一次演進背後的原因和方法。

  1 傳統單體架構

  這是最常見的系統設計。有一臺 API 伺服器,通常是 restful API,和一個資料庫。客戶端事先與後端協商好傳輸格式。讀和寫都是透過 DTO,即資料傳輸物件完成的。然而,後端在處理業務邏輯時需要將 DTO 轉換為具有領域知識的領域物件,並使用領域物件作為資料庫的儲存單元。

  為了實現讀 / 寫分離,在左邊的寫路徑中,客戶端向後端傳送 DTO,對資料庫進行 CUD(建立 / 更新 / 刪除)操作,後端在處理完成後向客戶端返回表示成功的 Ack 或表示失敗的 Nak。通常,在 restful API 中,2xx 表示成功,4xx 表示失敗。右邊的讀路徑只是透過讀請求來獲得相應的 DTO。

  再從客戶端的的角度來說下 DTO 的含義。在客戶端,DTO 通常包含要在螢幕上呈現的所有資料。例如,當你在社交媒體上檢視自己的個人資料時,它將包括你的名字、賬戶和其他個人資訊,以及你自己最近的活動,甚至你關注的活動。DTO 包含所有需要在這個頁面上呈現的資訊。

  為什麼我們要強調讀 / 寫分離?我們不能在讀 / 寫路徑上使用同一個程式嗎?因為我們想在將來更好地最佳化我們的系統。寫路徑有特定的最佳化方法,讀路徑也是如此。比如說,做一個快取,在讀路徑上可以使用預讀快取來減少響應時間。而且,寫路徑可以透過寫入快取來最佳化。其次,也可以把寫入操作非同步執行。將所有 DTO 寫入訊息佇列中,並由工作者程式負責處理,透過這種方式來處理大量的資料寫入。此外,可以使用適當的資料庫進行寫入和讀取。

  因此,讀 / 寫分離是必不可少的。而且,在系統設計的早期階段就應該考慮到這一點。寫路徑專注於資料的持久化;而讀路徑則專注於資料的查詢。

  然而,這個系統設計模型有兩個主要問題:

  貧血模型,也被稱為 CRUD 模型。後端專注於資料轉換而不處理業務邏輯,這將導致業務邏輯散落在各處,領域知識也會消失。例如,對於一個電子商務網站,我們會說“購買”,而不是“插入一條訂單記錄”。

  可擴充套件性不足。從系統架構的角度來看,資料庫很容易成為整個系統的瓶頸。讀取和寫入都必須在它上面進行。因為缺少橫向擴充套件能力,RDBMS 的問題就更加嚴重了。

  基於 Spring Boot + MyBatis Plus + Vue & Element 實現的後臺管理系統 + 使用者小程式,支援 RBAC 動態許可權、多租戶、資料許可權、工作流、三方登入、支付、簡訊、商城等功能

  專案地址:

  影片教程:

  2 基於任務的單體架構

  為了解決上述傳統單體架構中存在的問題,這裡我們嘗試引入域的概念。

  這個圖與上面的圖基本相同。唯一的區別是在寫路徑上用訊息代替了 DTO。訊息包含動作和資料,而不是像 DTO 那樣只包含資料本身。因此,我們可以在訊息中攜帶特定域的動作,使後端更容易識別每個動作,並有一個相應的域實現。

  在這個階段,CQRS 中的 C 出現了,訊息就是一種命令。然而,可擴充套件性問題仍未得到解決。

  另外,雖然我們簡化了 DTO,改為使用訊息進行通訊,但在讀路徑上我們仍然需要 DTO。還是以社交媒體為例。在修改暱稱時,訊息的格式可能是{"rename": "LazyDr"}。但是當呈現個人資料時,我們還需要額外的資訊,如活動。這種資訊缺口使得我們有必要在讀路徑上做大量的處理來獲取 DTO。

  基於 Spring Cloud Alibaba + Gateway + Nacos + RocketMQ + Vue & Element 實現的後臺管理系統 + 使用者小程式,支援 RBAC 動態許可權、多租戶、資料許可權、工作流、三方登入、支付、簡訊、商城等功能

  專案地址:

  影片教程:

  3 CQS(命令查詢分離)

  CQS 的出現就是為了解決以上讀寫分離的痛點。

  讀取時,客戶端需要 DTO,所以後端可以在讀路徑上做一些專門針對讀取的最佳化,比如從原來的域物件預先生成 DTO,並將 DTO 儲存在專門的資料庫中以供讀取。

  這樣一來,在讀路徑上,應用服務的實現變得更加簡單。應用服務會成為一個很薄的讀取層,只負責分頁、排序等工作。發出請求後,客戶端很容易從資料庫中檢索到 DTO。

  那麼問題來了,誰來生成這些預建的 DTO 呢?這是寫路徑的職責。

  雖然這幅圖與之前看到的例子類似,但實際上,除了持久化域物件,應用服務還必須持久化 DTO。換句話說,大部分的業務邏輯都壓在了寫路徑上,它還需要準備各種讀檢視。

  至此,我們已經解決了遇到的大部分問題,但擴充套件性問題仍然沒有得到解決。現在,我們進一步明確下擴充套件性,主要包括兩個方面:流量:寫入量增加。擴充套件:功能需求增加,例如需要各種不同的讀檢視。繼續以社交媒體為例,它有一個個人資料的展示,但可能有另一個按照時間線的展示。CQRS為什麼寫路徑要負責準備讀檢視?寫應該專注於持久化,各種讀檢視不應該在寫路徑上處理。但是,讀路徑上只有讀,誰該準備那些讀檢視?

  因此,完整的解決方案是這樣的:

  左邊的寫路徑和右邊的讀路徑已經在 CQS 部分介紹過了。唯一的區別是增加了 Eventually,負責將寫路徑使用的資料庫轉換為讀路徑使用的資料庫。一旦涉及到資料同步,就可能遇到資料一致性問題,所以這裡列出了幾種實現最終一致性的方法,按耗時從短到長排序如下:

  1. 後臺執行緒:典型代表是 Redis。在資料寫入主節點後,Redis 會立即在後臺將資料傳送到的副本中。

  2. 訊息佇列加工作者。這是非同步資料複製的一種常見做法。在寫入資料庫時,會建立一個事件併傳送到訊息佇列,然後由工作者處理。

  3. 提取 - 轉換 - 載入:這個時間間隔最長,從幾分鐘到幾小時不等。使用 map-reduce 或其他方法將結果寫到另一邊。

  無論採用哪種方法,單一真相來源都是必須的。也就是說,如果在轉換過程中發生任何故障,系統必須能夠恢復未完成的工作。因此,資料必須唯一而且可靠。

  通常,資料有兩種型別:

  1. 狀態:狀態指你此刻看到的東西,比如說寫在銀行存摺上的餘額。

  2. 事件:事件是修改每個狀態的動作,例如銀行存摺上的每一條交易記錄。

  實際上,我們已經有了可以作為事件儲存的訊息。對於寫路徑,按順序儲存訊息非常有效。藉助這些訊息,很容易根據需要建立出不同的讀檢視。這種方法也被稱為事件源。

  但僅有事件還很難有效地利用。為了獲得最終結果,每一次轉換都必須從頭到尾執行,以重建讀檢視。因此,最好是採用一種混合方法。在寫路徑上,將狀態和事件都保留,轉換過程可以根據實際情況選擇資料來源。

  總結一下 CQRS 中資料的整個生命週期:

  資料從客戶端開始,以命令格式進入後端。根據業務邏輯,它被轉換為域物件並儲存在資料庫中。這些域物件被轉換為各種讀檢視,並根據要求儲存在不同的專用讀資料庫中。最後,客戶端以 DTO 的形式獲取這些讀檢視。

   4 小結

  有許多書籍和文章以各種方式介紹了 DDD 和 CQRS。在我看來,這些模式限制了我們在進行 DDD 設計時的想象力,如實體、價值物件、聚合等。這使得大多數開發人員覺得,DDD 離自己很遠,很難實現,也很難實施。事實上,DDD 的概念並不複雜;相反,DDD 是為了封裝業務邏輯,促進功能需求的擴充套件。

  CQRS 就更簡單了。在這篇文章中,我們從系統演進的過程出發,介紹了整個系統的設計過程和需要解決的問題,最後自然地得出 CQRS 的結論。

  系統設計中沒有銀彈。每一次演進都是為了解決一些特定的問題。然而,它可能會帶來新的問題。以本文的設計過程為例,CQRS 似乎解決了所有提到的問題,“貧血模型”和可擴充套件性不足,但也帶來了新的問題,如資料一致性。每一種技術選擇都有它的權衡,只要瞭解每個選項背後的所有威脅因素,就可以選出相對可以接受的方法。

  即使你選擇了 CQRS,在實踐中,實現最終的一致性仍然有三種方法可以選擇。系統設計是不斷選擇的結果。

  這篇文章的目的是告訴你,DDD 沒有那麼可怕,CQRS 也沒有那麼複雜,只是一個決定而已。


來自 “ 芋道原始碼 ”, 原文作者:Chunting Wu;原文連結:https://mp.weixin.qq.com/s/CdxtRnBs_eXRO11PiMJBig,如有侵權,請聯絡管理員刪除。

相關文章