在這篇文章中, 我介紹的是一個使用 CQRS 和 Event Sourcing 模式的專案, 它使用了 onion architecture, 用 Typescript 編寫.
"flexible" how?
我使用 flexible 來推廣這種能夠適應不同環境的架構. 更確切地說, 我試圖:
- 核心業務邏輯與實現細節相互分離
- 獨立於任何 資料庫(database), 框架(framework), 服務(service)
- 儘可能地使用簡單的 純函式(pure function)
- 使專案易於橫向擴充套件
- 使專案易於測試
- 使用型別系統主要是為了 通用語言(ubiquitous language) 與核心領域之間的交流
注意: 專案中的某些地方可能過渡設計!
CQRS 可以不與 Event Sourcing 一同使用, 你也不一定要遵循onion architecture和型別系統. 當然, 我將這些結合在一起主要是為了測試和了解它們, 因此我希望你能夠選擇適合於你的技術.
Project details
我正在構建的專案是一個平臺, 可以幫助作者(開發者, 藝術家, 作家等)儘早收到反饋,並將他們的工作傳達給受眾, 無論他們受歡迎的程度如何. 你可以在 project readme 中瞭解更多資訊, 但是對於這篇文章, 只需要理解以下三個實體物件塑造的領域模型:
- article——作者的提交的稿件(比如部落格文章或者是youtube視訊)
- journal——只有滿足期刊的一系列規則後才能被收錄的文章集
- user——作者或編輯, 以及他們相應的許可權(類似於 StackOverflow 的排名系統)
Every action causes a reaction
我將努力嘗試使用我的專案作為示例來解釋 Event sourcing. 但是, 如果你是第一次接觸這種模式, 我建議你同時看一下這段視訊.
在 Event Sourcing 中, 我們能夠看到系統中的一些行為, 每一個行為都會引起反應. 在這種情況下, 行為能夠通過 命令 實現, 反應能夠通過 事件 實現. (source)
然而, 對當前的專案來說, 以反方向展示更加容易——從反應到行為.
因此, 無需再費周折, 下面是當前專案中所使用的事件列表:
注意: "Journal", "Article"和"User"是單獨的單元,我將其稱為 aggregates(即使它的"物件導向的定義"不完全適合我的模型, 但是目的是相同的)
在對事件列表感到滿意後, 我對每種型別做了更詳細的定義(使用 TypeScript):
注意: 在專案的當前版本中, 事件不再以這種方式定義, 而是使用 io-ts 轉換(稍後將詳細介紹).
應用根據這些事件是用於捕獲系統中的更改還是用作報告資料來源, 分為兩個方面:
- 寫入(或者是命令)——處理事件儲存問題, 確保業務規則和處理命令
- 讀取(或者是查詢)——獲取寫入端生成的事件, 並使用它們來構建和維護適合客戶端查詢的模型
簡而言之, 這是典型的CQRS應用, 我們將命令(寫入請求)和查詢(讀取請求)之間的責任分離:
在這篇文章中, 我將主要介紹應用程式的寫入方面(通常更復雜).
Mental model for storing events
Event Sourcing 系統中, 用於儲存事件的心智模型十分簡單——新事件將追加到列表中, 以後可以從列表中檢索. 此外, 儲存時, 永遠不會刪除或更新事件.
因此, 它基本上是從 CRUD 到 CR 的轉換.
當我第一次開始學習 Event Sourcing 時, 我經常想象我每次進行更改時都必須載入的大量事件列表(我認為我應該使用所有事件來補充單一聚合).
但是這種方法存在問題, 它會導致資料庫級別的阻塞問題或更新失敗(由於悲觀併發)
注意: 有關"一致性邊界"的更詳細解釋, 我建議閱讀 "Patterns, Principles and Practices of DDD"(第19章 ——聚合).
長話短說, 考慮事件儲存的更好方法是: 有多個事件列表(事件流), 每個事件列表包含與不同聚合對應的事件.
舉個例子, 對於 journals, 它看上去是這樣的:
注意: 有關事件如何在SQL或NoSQL資料庫中持久化, 以及悲觀或樂觀鎖如何完成, 不在本文的討論範圍之內.The big picture
即使"行為導致反應"這種說法正確, 但它並沒有真正告訴你如何建立, 捕獲或驗證命令, 如何確保不變數(業務規則), 如何處理關注點的耦合和分離.
為了解釋這一點, 提前瞭解"大局"是有用的:
但是由於系統中存在如此多的元件, 因此很難"透過現象看本質".這就是為什麼在解釋每個元件的作用之前, 我會先介紹一個架構, 這就是為了將技術複雜性與領域的複雜性分開.
它被稱為"onion architecture":
該體系結構使用簡單的規則將軟體劃分為多個層: 外層可以依賴於較低層, 但較低層中的程式碼不依賴於外層中的任何程式碼.- 架構的核心是一個 領域模型(包含業務規則), 僅使用純函式實現(易於測試)
- 命令處理程式 可以使用領域模型並僅通過 repository 與外部通訊(易於模擬)
- 最外層可以訪問所有內層, 它提供了 repository 介面的實現, 系統的入口點(REST API), 與資料庫的連線(事件儲存)等.
應用程式的表示, 永續性和域邏輯問題將以不同的速率和不同的原因發生變化; 分離這些問題可以適應變化, 而不會對程式碼庫中不相關的區域造成不良影響.(Patterns, Principles and Practices of DDD)
這種架構的另一個優點是它更利於定義你的目錄結構:
注意: 在大多數onion architecture的例子中, "commandHandlers"通常是應用層的一部分. 但是, 在我的例子中, 處理命令是這一層目前唯一正在做的事情, 所以我決定將它稱之為"commandHandlers"(如果將來我還需要用它處理更多的東西, 我可能會將它重新命名為"application") 如果你聽說過"clean architecture"或"hexagonal architecture"(埠和介面卡), 請注意它們與"onion architecture"幾乎相同.Authentication & formation of a command
命令是被使用者請求的一些更改所觸發的物件.它通常與結果事件一一對應:
CreateJournal => JournalCreated
AddJournalEditor => JournalEditorAdded
ConfirmJournalEditor => JournalEdditorConfirmed
...
複製程式碼
但它有時會觸發多個事件:
ReviewArticle => [ArticleReviewed, ArticlePromoted, ArticleAccepted]
ReviewArticle => [ArticleReviewed, ArticleRejected]
複製程式碼
有許多方法可以生成和處理命令, 但是對於這個專案, 我使用一個簡單的 REST 埠(/command)來接受JSON物件:
{
name: 'AddJournalEditor',
payload: {
journalId: 'journal-1',
editorInfo: {
email: 'editor@gmail.com'
},
timestamp: 1511865224832
}
}
複製程式碼
此物件通過POST請求被接收, 然後轉換為:
{
userId: 'xyz',
payload: {
journalId: 'journal-1',
editorInfo: {
email: 'editor@gmail.com'
},
timestamp: 1511865224832
}
}
複製程式碼
注意: userId
屬性的背後是一個完整的身份驗證過程,這不是一件容易的事。為此,我決定使用 Auth0 服務(類似於"Firebase身份驗證"或"Amazon Cognito"), 但當然, 你可以使用自己的實現.
這裡需要注意的是命令處理程式不會因身份驗證的複雜性而變得臃腫, 並且假設傳送 userId
的服務是可信的.
然後將命令物件(包含 userId)傳遞給適當的命令處理程式(由命令名稱找到).
以下是此過程的簡化示例:
Command handler — validating the input data
正如CQRS常見問題解答中所述, 這是命令處理程式遵循的常見步驟序列(與原始版本略有不同):- 驗證自身行為的命令
- 驗證聚合當前狀態的命令
- 如果驗證成功, 嘗試保留新事件. 如果在此步驟中存在併發衝突, 放棄或重試
在第一步("驗證自身行為的命令"), 命令處理程式檢查命令物件是否有任何缺失的屬性, 無效的電子郵件, URL等.
為此, 我正在使用 io-ts ——一種用於IO驗證的執行時型別系統, 它與 TypeScript 相容(但也可以在沒有它的情況下使用).
它通過組合這些簡單型別來工作(完整示例):
更復雜的命令型別(完整示例):
然後驗證 REST API 傳送的輸入資料.
注意: 如果驗證成功, TypeScript 將推斷命令的型別:
此時, 命令處理程式必須執行第二步: "根據聚合的當前狀態驗證命令".
或者換句話說, 它必須決定應該儲存哪些事件, 或者應該通過丟擲錯誤來拒絕命令(如果某些業務規則被破壞).
該決定是在 領域模型 的幫助下做出的.
Using a domain model to check business rules
領域模型是應用程式定義業務規則的一部分. 它的實現應該儘可能簡單(因此即使是非程式設計師也可以理解)並與系統的其餘部分分離(這是 onion architecture 模式的重點).繼續"adding an editor"的示例, 這是一個 命令處理程式(函式的高亮部分使用了領域模型):
addEditor
屬於 journal
聚合, 它的實現是一個簡單的純函式, 返回結果事件或丟擲錯誤(如果任何業務規則被破壞):
引數 userInfo
和 timestamp
源自 命令物件. "聚合的當前狀態"由 user
和 journal
物件表示, 這些物件使用 Repository 檢索.
注意: 如果你不喜歡看到硬編碼的字串, 請記住我正在使用 TypeScript, 如果不以正確的方式使用, 它會讓你抓狂:
除了編譯時錯誤之外, 使用"重新命名符號功能"重新命名任何屬性或字串適用於專案中的任何檔案(在vs code中測試).Retrieving the current state of the aggregate with a repository
userState
和 journalState
使用被注入的依賴項(userRepository
和 journalRepository
)檢索:
這些儲存庫通常包含一個名為 getById
的方法.
這個方法的作用你應該已經猜到了, 通過傳入的 id 得到一個聚合狀態.
因此, 對於 journal 聚合, 它應該返回這種型別的物件:
但是, 事件儲存對 journal 聚合的格式一無所知, 如圖所示, 它只儲存事件: 這就是我必須使用 reducer 將這些事件轉換為必需狀態的原因.注意: 請記住, 為了獲得當前的聚合狀態, 你不必使用 Event Sourcing. 有時它更適合檢索一個完整的物件(使用 MongoDB 或類似的東西)並跳過部分減少和儲存事件. 但是, 如果你像我一樣, 希望你的模型"flexible"(這樣你就可以在以後輕鬆更改聚合狀態的格式), 你必須處理"reducers".
reducer 只是一個(純)函式(類似於 Redux reducer), 也在 領域模型 中定義:
*注意: 同樣,使用 TypeScript, 您可以安全地使用硬編碼字串, 其中對於每種情況的事件型別都會被推斷出來:Saving events in the Event Store
命令處理程式的最後一步是: 如果驗證成功, 則嘗試保留新事件. 如果在此步驟中存在併發衝突, 就放棄或重試.
除了聚合狀態之外, 儲存庫還將返回 save
函式, 然後該函式用於持久化事件:
注意: 我使用的樂觀鎖基於寫入端檢索的事件版本而不是讀取端. 這是根據我的領域做出的有意識的決定, 如果你試圖使用這個解決方案在你的專案上, 請確保你理解 tradeoffs(我不會在這篇文章中解釋, 因為它已經足夠長了)
但是, 如果你決定使用從讀取端檢索的版本, 你可以這樣傳遞版本號: save(events, expectedVersion)
Summary of the application flow on the write side
- 命令 是使用者傳送的物件(來自UI)
- REST API 接收命令並處理使用者身份驗證
- 將"已驗證的命令"傳送到 命令處理程式
- 命令處理程式 向 repository 發出 聚合狀態 請求
- Repository 從 事件儲存 中檢索事件,並使用 領域模型 中定義的 reducer 將它們轉換為聚合狀態
- 命令處理程式 使用響應結果事件的 領域模型 驗證聚合當前狀態的命令
- 命令處理程式 將結果事件傳送到 repository
- Repository 嘗試在事件儲存中保留接收的資料, 同時使用樂觀鎖確保一致性
The Read Side
使用事件重新建立聚合狀態既不復雜也不昂貴.
但是, 事件儲存不適合跨聚合查詢. 例如, 像"選擇名稱以xyz開頭的所有日誌"這樣的查詢將需要重建所有的聚合, 過於昂貴(更不用說在單一的 CRUD 應用程式中一些更復雜的查詢, 這是"資金消耗"的重要來源).
這是讀取端需要解決的問題.
簡而言之, 讀取端監聽從寫入端釋出的事件, 將這些事件作為對本地模型的更改進行投影, 並允許在該模型上進行查詢.(source)
通過構建維護專用於任何型別查詢的資料庫的模型(或多個模型), 你可以節省大量處理能力。
我認為這值得重申:
If your your hosting bill is unjustifiably YUGE! mostly due to complex queries - you should consider CQRS/ES architecture.
由於讀取端總是"落後"寫入端(即使只有幾分之一秒), 應用程式變得"最終一致". 這就是為什麼它更便宜, 更易於擴充套件, 但這也是為什麼寫入方面與單一 CRUD 應用程式相比更復雜的原因.
Conclusion
我喜歡在事件中思考. 它使我專注於領域而不是資料庫架構, 甚至不必是程式設計師也能理解這些. 這使你更容易與領域專家溝通(DDD 的很大一部分).
此外, 這種架構的性質迫使我不要將一致性視為理所當然, 因此我從中瞭解了更多資訊(這在使用微服務時非常有用).
但是, 使用這些模式都有其成本. 如果你覺得沒有必要全部使用, 也許你可以使用它的一部分.
正如我已經提到的那樣; CQRS 模式可以在沒有 Event Sourcing 的情況下使用, 你也不必遵循"onion architecture"或使用型別系統. 例如, 你可以:
- 使用類似的模型, 其中事件被 NoSQL 資料庫中持久儲存的物件替換(無事件源)
- 使用寫入模型中的 reducers 進行客戶端查詢(無 CQRS)
- 用 onion architecture 更容易(通過在"開發階段"模擬基礎設施層)地構建無伺服器應用程式(使用"lamdas"或"cloud functions")
- 以類似的方式使用型別, 其中領域以一種精細的, 自我記錄的方式呈現(型別優先開發)
- 使用執行時型別系統進行IO驗證(如io-ts)
- ...
對於這些, 我自己還處於學習階段, 還沒有完成這個專案. 如果你對展示的模式或專案本身有任何建議, 請隨時發表評論, 釋出問題或親自與我聯絡.
Resources
- Martin Fowler — Event Sourcing (video, article)
- Martin Fowler — CQRS (article)
- Greg Young — Event Sourcing (video)
- Alberto Brandolini — Event Storming (article)
- Chris Richardson — Developing microservices with aggregates (video)
- Scott Millett — Patterns, Principles, and Practices of DDD (book)
- CQRS.nu — FAQ (article)
- MSDN — Introducing Event Sourcing (article)
- Scott Wlaschin — Domain Driven Design (video, article)
- Mark Seemann — Functional architecture is Ports and Adapters (article, video)