團隊開發框架實戰—CQRS架構

團隊開發框架實戰—CQRS架構

CQRS架構圖

261851438603372.jpg
CQRS架構圖.png

什么是CQRS?

這里只通過Udi Dahan的《Clarified CQRS》文章中的一張圖片簡要介紹一下:


2012032222580035.png

UI上有兩種類型的操作:命令和查詢,例如顯示銷量最好的5個產品就屬于查詢,而提交一個訂單、修改密碼等則屬于命令。因為大部分系統都是讀多寫少,而且業務邏輯基本都出現在寫入的一端,所以查詢和命令的分離可以讓我們獨立的去優化查詢。

查詢 (Query)

上圖中,可以看到Query不是通過DB來查詢,而是通過一個專門用于查詢的Read DB(上圖中的Cache,它不一定是數據庫,但為方便起見,下面統稱Read DB),Read DB中的表(方便起見,暫且認為這個Read DB是一個RDBMS)是專門針對UI優化過的,例如里面可能會有LatestProductListModel(ProductId, ProductName, Price, BrandName, AddedTime)、BestSoldProductListModel(ProductId, ProductName, TotalSold)這樣的表,分別表示最新的產品列表,銷量最好的產品列表(它們其實就相當于是View Model)。LatestProductListModel中有一個BrandName的字段,注意,不是BrandId,因此,對于界面中的查詢,幾乎全都可以通過SELECT * FROM [TABLE]這樣的SQL語句來實現,可能有少數Where,但基本沒有Join,這對于界面的加載速度絕對是有利無弊的(其實也是在用空間換時間)。

命令 (Command)

業務邏輯大部分都發生在寫入的時候,例如用戶購買商品提交訂單時,我們要驗證庫存,用戶信息訂單數據是否有效等。如果從傳統DDD的角度看,Command類似于Application Service,用戶的命令(如提交訂單)會以Command的形式得到執行,而Command中也不會帶有業務邏輯,Command中做的事情基本上是:通過Repository得到相關的領域對象,調用某些領域服務(Domain Service)執行一些操作(業務邏輯都將保留在領域模型中),然后執行Commit或SaveChanges之類的方法提交改動,之后,相關的數據就會寫入到Write DB中(圖的DB,下文統稱Write DB)。需要注意的是,UI上的查詢都是查Read DB,而不是Write DB。

領域模型 (Domain Model)

這和Evans的DDD中說的領域模型沒有太多區別,是“the heart of software”。

領域事件 (Domain Event)

領域事件占據的地位非常重要,不僅限于CQRS。相信會有一部分人曾和我一樣碰到過這樣的問題:
Account實體(表示帳戶)有個Balance屬性(表示帳戶余額),我們一般不會公開這個屬性的setter,而是通過寫一些IncreaseBalance(decimal amount)之類的方法來實現帳戶余額的變動。
這時問題就來了,我們想在帳戶變動時添加一條AccountLog記錄,但Log記錄成千上萬,我們不能直接通過ORM的一對多映射把AccountLog集合實現成Account的一個集合屬性,那我們就需要在IncreaseBalance()中得到AccountLogRepository,這樣才有辦法插入AccountLog(從DDD的角度,AccountLog不是聚合根,所以不能有AccountLogRepository,但在性能影響嚴重的時候,也只好做些取舍了)。
不管用了依賴注入還是什么的,總之,Account已經依賴上Repository了,這就讓領域對象變得很不純凈,并且,假如我們以后不僅要記錄log,還要短信通知用戶呢?那要修改源代碼嗎?這也很不OCP。
而領域事件正好可以解決這種問題:只要在IncreaseBalance()方法的末尾,觸發一個領域事件,然后我們獨立寫一個EventHandler的類去實現log的添加(框架可以保證EventHandler可以和領域事件綁定到一起)。
回到CQRS,因為Command將數據寫到了Write DB中,而UI查詢的是Read DB,那我們就需要用某種方式實現這兩個數據庫的同步,解決辦法已經很明顯了,寫一堆的EventHandler類去監聽領域事件。例如我們有一個更改產品價格的命令ChangePriceCommand,它執行后,一個叫做PriceChangedEvent會被觸發,那我們只要寫一個PirceChangedEventHandler的類,在這里面將Read DB中相關的價格信息更改到最新值即可實現同步(這里會涉及到Read DB中表結構改變的問題,后面再說)。

Command的實現

概述

UI中的寫入操作都將被封裝為一個命令中,發送給Domain Model來處理。
我們遵循Domain Driven Design的設計思想,因此所有的業務邏輯都只在Domain Model中處理,Command中將不會帶有業務邏輯。Command中的代碼無非是通過Repository獲取某些個聚合根(Aggregate Root),然后將操作委托給相應的領域對象或領域服務來處理,僅此而已。

實現

實現上,我們會涉及三個東西:

  • Command對象

Command對象的作用是用來封裝命令數據,所以這類對象以屬性為主,少量簡單方法,但注意這些方法中不能包含業務邏輯。
舉個用戶注冊的例子,用戶注冊是一個命令,所以我們需要一個RegisterCommand類,這個類定義如下:

using Tdf.CQRS.Commanding;

namespace Tdf.CQRSSample.Commands
{
    public class RegisterCommand : ICommand
    {
        public string Email { get; set; }
        public string NickName { get; set; }
        public string Password { get; set; }
        public string ConfirmPassword { get; set; }

        public RegisterCommand()
        {
        }
    }
}

這個類的每個屬性基本上都對應著注冊表單中的一個輸入(為了方便起見,上面的每個屬性都是public set,但若屬性不多不影響編碼,最好把屬性都改成private set,然后將屬性的值通過構造函數傳入)。當用戶點擊“注冊”按鈕時,Controller(假設使用MVC作為表現層模式)中會創建一個RegisterCommand的實例,設置相應的值,然后調用CommandBus.Send(registerCommand),然后根據執行的情況顯示相應的信息給用戶。(CommandBus后面會講到)

  • CommandExecutor

CommandExecutor的作用是執行一個命令,對于注冊的例子,我們會有一個RegisterCommandExecutor的類,它只有一個Execute方法,接受RegisterCommand參數:

using System;
using Tdf.CQRS.Commanding;
using Tdf.CQRS.Data;
using Tdf.CQRSSample.Domain.Entities;
using Tdf.CQRSSample.Domain.Services;

namespace Tdf.CQRSSample.Commands
{
    class RegisterCommandExecutor : ICommandExecutor<RegisterCommand>
    {
        public IRepository<User> _repository;

        public RegisterCommandExecutor(IRepository<User> repository)
        {
            _repository = repository;
        }

        public void Execute(RegisterCommand cmd)
        {
            if (String.IsNullOrEmpty(cmd.Email))
                throw new ArgumentException("Email is required.");

            if (cmd.Password != cmd.ConfirmPassword)
                throw new ArgumentException("Password not match.");

            // other command validation logics

            var service = new RegistrationService(_repository);
            service.Register(cmd.Email, cmd.NickName, cmd.Password);
        }
    }
}

在Execute方法中,我們需要先驗證Command的正確性,但需要注意的是,這里的驗證只是驗證RegisterCommand中的數據是否合法,并非驗證業務邏輯。例如,這里會驗證郵箱是否為空且格式是否正確,但郵箱格式正確并不意味著就可以注冊,因為系統可能要求18歲以上的成年人才能注冊,而這屬于業務邏輯,RegistrationService將會負責確保所有的業務規則不被破壞,RegistrationService屬于Domain Service,存在于Domain Model中。

可以看到,CommandExecutor中主要有兩部分工作,一是驗證傳入的Command對象是否合法,二是調用領域模型完成操作。上一篇文章中提到的Command是一個概念層次的Command,它不單指(1)中的Command,而是包含了(1)和(2)等。

  • Command Bus

用于執行Command的是CommandExecutor,但CommandExecutor卻并不用來在UI層調用,UI層中只會用到Command對象和即將提到的Command Bus。Command Bus的作用是將一個Command派發給相應的CommandExecutor去執行。在開發UI層時,我們不需要關心Command會被哪個Executor執行了,而只要知道,上帝賜予了我們一個CommandBus,我們只要創建好Command對象,扔給它,神奇的CommandBus就會幫我們把它執行完。這樣一來,對于UI層的開發來說,所涉及的概念很簡單,涉及的類也少,大部分的工作都是得到表單中的輸入,封裝成Command對象,扔給CommandBus。

CommandBus的實現也很簡單。首先,我們需要讓CommandExecutor都實現一個泛型接口:

namespace Tdf.CQRS.Commanding
{
    public interface ICommandExecutor<TCommand>
        where TCommand : ICommand
    {
        void Execute(TCommand cmd);
    }
}

其中ICommand是一個空接口,沒有任何方法(即Marker Interface),它的作用是實現編譯時約束,這樣我們可以限制傳入CommandExecutor的都是Command對象,而不是不小心傳錯的User對象(所有的Command對象都必須實現ICommand接口)。

然后,把CommandBus寫成這樣:
通過IoC框架來簡化這個過程,另外也可以做一些改進,例如將CommandBus設計為擴展點之一。另外我們還可以將UnitOfWork(相當于平常的EntityFramework中的IDbContext,Linq 2 SQL中的DataContext)的生命周期在CommandBus中進行控制。
比較完整的CommandBus代碼如下

namespace Tdf.CQRS.Commanding
{
    public interface ICommandBus
    {
        void Send<TCommand>(TCommand cmd) where TCommand : ICommand;
    }
}
using Tdf.CQRS.Data;

namespace Tdf.CQRS.Commanding
{
    public class CommandBus : ICommandBus
    {
        public void Send<TCommand>(TCommand cmd) where TCommand : ICommand
        {
            try
            {
                var unitOfWork = UnitOfWork.StartUnitOfWork();
                var executor = ObjectContainer.Resolve<ICommandExecutor<TCommand>>();
                executor.Execute(cmd);
                UnitOfWork.Commit();
            }
            finally
            {
                UnitOfWork.Close();
            }
        }
    }
}

一些注意點

  • Command表示想要執行的命令,所以Command類的類名應當是動詞的形式。例如RegisterCommand, ChangePasswordCommand等。不過Command后綴則是可選的,只要能保持一致即可。
  • Command和CommandExecutor是一一對應的。也就是說,一個Command只會對應一個CommandExecutor,這和后面的事件有區別,事件是一對多的,一個Event可以對應多個EventHandler。
  • Command對象也起到了DTO(Data Transfer Object,在這個例子中感覺稱作View Model也無妨)的作用,這也是把Command和Executor相分離,不把Execute方法直接寫在Command類中的原因之一。
  • 注意Command的類名的重要作用,每個Command類的名稱都清晰地表達了一個意圖,例如ChangePasswordCommand清晰的表達了這個命令是要修改密碼,所以千萬不要隨意"復用"Command,這里的“復用”指的是,看到某兩個Command中有完全一樣的屬性,就覺得沒有必要使用兩個Command,而把它們合并成一個Command,這樣的"復用"會讓系統變得越來越難以理解,雖然它可能的確減少了幾行代碼。
  • 命令通常是用“發送”來描述,而事件則是用“發布”來描述,所以CommandBus中的方法名稱個人認為應該用Send比較合適,而不用Publish之類的。

Command執行結果的返回

面對UI中的各種命令,Controller會創建相應的Command對象,然后將其交給CommandBus,由CommandBus統一派發到相應的CommandExecutor中去執行,我們的ICommandBus的接口聲明如下:

namespace Tdf.CQRS.Commanding
{
    public interface ICommandBus
    {
        void Send<TCommand>(TCommand cmd) where TCommand : ICommand;
    }
}

當在實際項目中應用CQRS時,我們會發現上面的做法存在一個問題:有時候我們希望Command在執行完后返回一些結果,但上面的Send方法返回void,也就意味著我們沒有辦法得到執行結果。我們以一個用戶注冊的例子來說明。
在Command對象中添加一個ExecutionResult的屬性(這個屬性要放在具體的Command類中,不要放于ICommand接口中)。如上面的用戶注冊的例子,我們可以添加一個RegisterCommandResult的類,然后將RegisterCommand改成如下所示:

using Tdf.CQRS.Commanding;

namespace Tdf.CQRSSample.Commands
{
    public class RegisterCommand : ICommand
    {
        public string Email { get; set; }
        public string NickName { get; set; }
        public string Password { get; set; }
        public string ConfirmPassword { get; set; }

        // 亮點在這里
        public RegisterCommandResult ExecutionResult { get; set; }

        public RegisterCommand()
        {
        }
    }

    // 亮點在這里
    public class RegisterCommandResult
    {
        public string GeneratedUserId { get; set; }
    }
}

在調用CommandBus.Send()之前,我們完全不用理會這個ExecutionResult屬性,對于Controller的開發人員來說,他只要知道在Command執行完后,ExecutionResult的值就會被賦上,如果沒有,那就是CommandExecutor的bug。

而我們的RegisterCommandExecutor就可以改成(User類的構造函數會調用Id = Guid.NewGuid().ToString()對自己的Id進行賦值):

using System;
using Tdf.CQRS.Commanding;
using Tdf.CQRS.Data;
using Tdf.CQRSSample.Domain.Entities;
using Tdf.CQRSSample.Domain.Services;

namespace Tdf.CQRSSample.Commands
{
    class RegisterCommandExecutor : ICommandExecutor<RegisterCommand>
    {
        public IRepository<User> _repository;

        public RegisterCommandExecutor(IRepository<User> repository)
        {
            _repository = repository;
        }

        public void Execute(RegisterCommand cmd)
        {
            if (String.IsNullOrEmpty(cmd.Email))
                throw new ArgumentException("Email is required.");

            if (cmd.Password != cmd.ConfirmPassword)
                throw new ArgumentException("Password not match.");

            // other command validation logics

            var service = new RegistrationService(_repository);
            var user = service.Register(cmd.Email, cmd.NickName, cmd.Password);

            // 亮點在這里
            cmd.ExecutionResult = new RegisterCommandResult
            {
                GeneratedUserId = user.Id
            };
        }
    }
}

RegisterCommand中定義的ExecutionResult屬性可以讓開發人員清楚的知道這個屬性會在Command執行完后被賦上合適的值。對于一個Command,如果開發人員在其中找到類似ExecutionResult這樣的屬性,他就知道這個Command執行完后會返回執行結果,并且結果是以賦值的形式賦給Command中的ExecutionResult屬性,若Command中沒有發現ExecutionResult這樣的屬性,那開發人員便知道這個Command執行完不會返回執行結果。
到目前為止,我們所討論的Command都是同步執行的,如果Command被設計為異步執行,那本文所討論的內容便可以直接忽略。
如果系統的性能可以滿足需求,同步Command無疑是最好的。

CQRS架構的優點

  • CQ兩端架構分離、相互不受束縛,各自獨立設計、擴展
  • C端通常結合DDD,解決復雜的業務邏輯;Q端輕量級查詢,多種不同的查詢視圖通過訂閱事件來更新
  • C端通過分布式消息隊列水平擴展,天然支持削峰
  • EDA架構,整個系統各個部分松耦合,可擴展性好
  • 架構層面做到無并發,實現Command的高吞吐
  • 技術架構和業務代碼完全分離,程序員不用關心技術問題
  • 更方便的分工合作

CQRS架構的缺點

  • 不是強一致性,而是面向最終一致性
  • 強依賴高性能可靠的分布式消息隊列
  • 必須有強大可靠的CQRS框架,從頭做起成本高、風險大
  • 必須結合Event Sourcing模式,否則CQ分離意義不大
  • Event Sourcing模式的缺點
  • 一些CQRS的最佳原則提高了開發人員的門檻
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,250評論 6 530
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 97,923評論 3 413
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,041評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,475評論 1 308
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,253評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,801評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,882評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,023評論 0 285
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,530評論 1 331
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,494評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,639評論 1 366
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,177評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,890評論 3 345
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,289評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,552評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,242評論 3 389
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,626評論 2 370

推薦閱讀更多精彩內容

  • Spring Cloud為開發人員提供了快速構建分布式系統中一些常見模式的工具(例如配置管理,服務發現,斷路器,智...
    卡卡羅2017閱讀 134,775評論 18 139
  • Spring Boot 參考指南 介紹 轉載自:https://www.gitbook.com/book/qbgb...
    毛宇鵬閱讀 46,885評論 6 342
  • 1. Java基礎部分 基礎部分的順序:基本語法,類相關的語法,內部類的語法,繼承相關的語法,異常的語法,線程的語...
    子非魚_t_閱讀 31,704評論 18 399
  • 那天晚上接孩子,我看到了它,一只小貓,肚子是白的,身上的黑毛像是披著的一件黑外套,在冷颼颼的晚風下面,外套好像隨時...
    新意燕兒閱讀 200評論 2 0
  • 0很久之后,江湖都會傳說,有一伙人,某個夜晚,進行一次成長總結的時候聊到天打雷劈。 14月28號的時候。有些感觸。...
    oulan閱讀 204評論 0 0