從壹開始微服務 [ DDD ] 之十二 ║ 核心篇【下】:事件驅動EDA 詳解
緣起
哈嘍大家好,又是週二了,時間很快,我的第二個系列DDD領域驅動設計講解已經接近尾聲了,除了今天的時間驅動EDA(也有可能是兩篇),然後就是下一篇的事件回溯,就剩下最後的許可權驗證了,然後就完結了,這兩個月我也是一直在自學,然後再想栗子,個人感覺收穫還是很大的,比如DDD領域分層設計、CQRS讀寫分離、CommandBus命令匯流排、EDA事件驅動、四色原理等等,如果大家真的能踏踏實實的看完,或者說多看看書,對個人的思想提高有很大的幫助,這裡要說兩點,可能會有一些小夥伴不開心,但是還是要說說:
1、很多小夥伴一直問我看什麼書,我個人感覺,只要是書看就對了,與其糾結哪本,還不如踏踏實實先看一本。 2、還有小夥伴問,為啥還沒有看到微服務的內容?
我想說,其實微服務是一個很寬泛的領域,比如.net core的深入學習,依賴注入的使用,倉儲契約、DDD+事件匯流排的學習、中介者模式、Docker的學習、容器化設計等等等等,這些都屬於微服務的範疇,如果這些基礎知識不會的話,可能是學不好微服務的。
週末的時候,我又好好的整理了下我的Github上的程式碼,然後新建了一些分支(如果你不會使用Git命令,可以看我的一個文章: ofollow,noindex">https://www.jianshu.com/p/2b666a08a3b5 ,會一直更新),主要是這樣的(這個數字是對應的文章,比如今天的是第 12 ):
其實我這個系列所說的 DDD領域驅動設計,是一個很豐富的概念,裡邊包含了DDD的多層設計思想、CQRS、Bus、EDA、ES等等,所以如果你只想要其中的一部分,可以對應的分支進行Clone,比如你單純想要一個乾淨的基於DDD四層設計的模板,可以克隆 Framework_DDD_8 這個分支,如果你想帶有讀寫分離,可以克隆 CQRS_DDD_9 這個分支等等,也方便好好研究。
關於CQRS讀寫分離概念,請注意,分離不一定是分庫,一個資料庫也能實現讀寫分離,最簡單的就是從Code上來區分。
前言
好啦,上邊說了一些週末的思考,現在馬上進入正文,不知道大家對上週的內容還有沒有印象,主要用兩篇文章來說明了命令匯流排的設計思想和執行過程《 十 ║領域驅動【實戰篇·中】:命令匯流排Bus分發(一) 》、《 十一 ║ 基於原始碼分析,命令分發的過程(二) 》,咱們很好的實現了多個複雜模型間的解耦,成功的簡化了API介面層和 Application應用服務層,把重心真正的轉義到了領域層。
當然其中也有一些新的問題出現了,這個也可以當作今天的每篇一問:
首先,對領域通知的處理上,目前用的是通過一個 ErrorData 的key 來把錯誤通知放到了記憶體裡,然後去讀取,這樣有一個很危險的問題,就是生命週期的問題,如果在當前例項中,沒有及時刪除,可能會出現錯誤通知的混亂,這是致命的,當然還有 key 的問題,因為幾乎每一個 Command 都會有不同的資訊,我們不能通過簡簡單單的人為取名字來實現這個邏輯,這是荒唐的。
其次,如果我們 Command 執行完成,是如何釋出通知的,比如註冊成功的郵件,簡訊分發,站內推送等等。
最後,不知道大家有沒有深入的去學習,去了解 MediatR 中介者的兩個模式:請求/響應模式 與 釋出/訂閱模式的區別和聯絡(詳細的下邊會說到)。
你會說,很簡單呀,我們直接在 CommandHandler 命令處理程式中處理不就行了,一步一步往下走就可以了呀,如果你現在還有這樣的思維,那DDD可真的好好再學習了,為什麼呢?很簡單,我們當時為什麼要把 contrller 的業務邏輯剝離到領域模型,就是為了業務獨立化,不讓多個不相干的業務纏繞(比如我們之前是把model 驗證、錯誤返回、發郵件等,都是寫在 controller 裡的),那如果我們再把過多的業務邏輯寫到命令處理程式中的話,那命令處理模型不就成為了第二個 controller 了麼?我們為業務把 controller 剝離了一次,那今天咱們就繼續從 命令處理程式中,再優化一次。
零、今天要實現右下角 藍色 的部分
(週末有一個小夥伴問這個軟體的地址: https://www.mindmeister.com ,應該需要FQ)
一、領域事件驅動設計 —— EDA
1、什麼是領域事件
我們先看看官網,在《實現領域驅動設計》一書中對領域事件的定義如下:
領域專家所關心的發生在領域中的一些事件。
將領域中所發生的活動建模成一系列的離散事件。
每個事件都用領域物件來表示,領域事件是領域模型的組成部分,表示領域中所發生的事情。
領域事件:Domain Event,是針對某個業務來說的,或者說針對某個聚合的業務來說的,例如訂單生成這種業務,它可以同時對應一種事件,比如叫做OrderGeneratorEvent,而你的零散業務可能隨時會變,加一些業務,減一些業務,而對於訂單生成這個事件來說,它是唯一不變的,而我們需要把這些由產生訂單而發生變化的事情拿出來,而拿出來的這些業務就叫做"領域事件".其中的領域指的就是訂單生成這個聚合;而事件指的就是那些零散業務的統稱.
2、領域事件包含了哪些內容
如果你對上一篇命令匯流排很熟悉,這裡就特別簡單,幾乎是一個模式,只不過匯流排釋出的方式不一樣罷了,如果你比較熟悉命令驅動,這裡正好溫習。如果不瞭解,這裡就一起看吧,千萬記得再回去看前兩篇內容喲。
在面向物件的程式設計世界裡,做這種事情我們需要幾個抽象:
領域物件事件標示:標示介面,介面的一種, 用來約束一批物件,IEvent (當前也可以使用 抽象類 ,本文即是)
領域物件的處理 方法行為 :比如 StudentEventHandler。(我們的命令處理程式也是如此)
事件匯流排:事件處理核心類,承載了 事件的釋出,訂閱與取消訂閱 的邏輯,EventBus(這個和我們的命令匯流排CommandBus很類似)
某個領域物件的事件:它是一個事件處理類,它實現了 EventHandler,它所 處理的事情需要在Handle裡去完成 。
一個領域事件可以理解為是發生在一個特定領域中的事件,是你希望在同一個領域中其他部分知道併產生後續動作的事件。一個領域事件必須對業務有價值,有助於形成完整的業務閉環,也即一個領域事件將導致進一步的業務操作。就比如我們今天說到的領域通知,就應該是一個事件,我們從命令中產生的錯誤提示,通過處理程式,引發到事件匯流排內,並返回到前臺。
3、為什麼需要領域事件
領域事件也是一種基於事件的架構(EDA)。事件架構的好處可以把處理的流程解耦,實現系統可擴充套件性,提高主業務流程的內聚性。
在咱們文章的開頭,可說到了這個問題,不知道大家是否還記得,咱們再分析一下:
我們提交了一個新增Student 的申請,系統在完成儲存後,可能還需要傳送一個通知(當然這裡錯誤資訊,也有成功的),當然肯定還會會一些其他的後臺服務的活動。如果把這一系列的動作放入一個處理過程中,會產生幾個的明顯問題:
1、一個是命令提交的的事務比較長,效能會有問題,甚至在極端情況下容易引發資料庫的嚴重故障(伺服器方面); 2、另外提交的服務內聚性差,可維護性差,在業務流程發生變更時候,需要頻繁修改主程式(程式設計師方面)。 3、我們有時候只關心核心的流程,就比如新增Student,我們只關心是否新增成功,而且我們需要對這個成功有反饋,但是發郵件的功能,我們卻不用放在主業務中,甚至傳送成功與否,不影響 Student 的正常新增,這樣我們就把後續的這些活動事件,從主業務中剝離開,實現了高內聚和低耦合(業務方面)。
還記得 MediatR 有兩個中介者模式麼:請求/響應 和 釋出/訂閱。在我們的系統中,新增一個學生命令,就是用到的請求/響應 IRequest 模式,因為我們需要等待當前操作完成,我們需要匯流排對我們的請求做出響應。
但是有時候我們不需要在同一請求/響應中立即執行一個動作的結果,只要非同步執行這個動作,比如傳送電子郵件。在這種情況下,我們使用釋出/訂閱模式,以非同步方式傳送電子郵件,並避免讓使用者等待發送電子郵件。
4、領域事件驅動是如何執行的呢?
這個時候,就用到之前我畫的圖了,中介者模式下,上半部的命令匯流排已經說完,今天說另一半事件匯流排:
當然這裡也有一個網上的栗子,很不錯:
從圖中我們也可以看到,事件驅動的工作流程呢,在命令模式下,主要是在我們的命令處理程式中出現,在我們對資料進行持久化操作的時候,作為一個後續活動事件來存在,比如我們今天要實現的兩個處理工作:
1、通知資訊的收集(之前我們是採用的快取 Memory 來實現的); 2、領域通知處理程式(比如發郵件等);
這個時候,如果你對事件驅動有了一定的理解的話,你就會問,那我們在專案中具體的應該使用呢,請往下看。
二、建立事件匯流排
這個整體流程其實和命令匯流排分發很像,所以原理就不分析了,相信你如果看了之前的兩篇文章的話,一定能看懂今天的內容的。
1、定義領域事件標識基類
就如上邊我們說到的,我們可以定義一個介面,也可以定義一個抽象類,我比較習慣用抽象類,在核心領域層 Christ3D.Domain.Core 中的Events 資料夾中,新建Event.cs 事件基類:
namespace Christ3D.Domain.Core.Events { /// <summary> /// 事件模型 抽象基類,繼承 INotification /// 也就是說,擁有中介者模式中的 釋出/訂閱模式 /// </summary> public abstract class Event : INotification { // 時間戳 public DateTime Timestamp { get; private set; } // 每一個事件都是有狀態的 protected Event() { Timestamp = DateTime.Now; } } }
2、定義事件匯流排介面
在中介處理介面IMediatorHandler中,定義引發事件介面,作為釋出者,完整的 IMediatorHandler.cs 應該是這樣的
namespace Christ3D.Domain.Core.Bus { /// <summary> /// 中介處理程式介面 /// 可以定義多個處理程式 /// 是非同步的 /// </summary> public interface IMediatorHandler { /// <summary> /// 傳送命令,將我們的命令模型釋出到中介者模組 /// </summary> /// <typeparam name="T"> 泛型 </typeparam> /// <param name="command"> 命令模型,比如RegisterStudentCommand </param> /// <returns></returns> Task SendCommand<T>(T command) where T : Command; /// <summary> /// 引發事件,通過匯流排,釋出事件 /// </summary> /// <typeparam name="T"> 泛型 繼承 Event:INotification</typeparam> /// <param name="event"> 事件模型,比如StudentRegisteredEvent,</param> /// 請注意一個細節:這個命名方法和Command不一樣,一個是RegisterStudentCommand註冊學生命令之前,一個是StudentRegisteredEvent學生被註冊事件之後 /// <returns></returns> Task RaiseEvent<T>(T @event) where T : Event; } }
3、實現匯流排分發介面
在基層設施匯流排層 Christ3D.Infra.Bus 的記憶匯流排 InMemoryBus.cs 中,實現我們上邊的事件分發匯流排介面:
/// <summary> /// 引發事件的實現方法 /// </summary> /// <typeparam name="T">泛型 繼承 Event:INotification</typeparam> /// <param name="event">事件模型,比如StudentRegisteredEvent</param> /// <returns></returns> public Task RaiseEvent<T>(T @event) where T : Event { // MediatR中介者模式中的第二種方法,釋出/訂閱模式 return _mediator.Publish(@event); }
注意這裡使用的是中介模式的第二種——釋出/訂閱模式,想必這個時候就不用給大家解釋為什麼要使用這個模式了吧(提示:不需要對請求進行必要的響應,與請求/響應模式做對比思考)。現在我們把事件匯流排定義(是一個釋出者)好了,下一步就是如何定義事件模型和處理程式了也就是訂閱者,如果上邊的都看懂了,請繼續往下走。
三、事件模型的處理與使用
可能這句話不是很好理解,那說人話就是:我們之前每一個領域模型都會有不同的命令,那每一個命令執行完成,都會有對應的後續事件(比如註冊和刪除使用者肯定是不一樣的),當然這個是看具體的業務而定,就比如我們的訂單領域模型,主要的有下單、取消訂單、刪除訂單等。
我個人感覺,每一個命令模型都會有對應的事件模型,而且一個命令處理方法可能有多個事件方法。具體的請看:
1、定義新增Student 的事件模型
當然還會有刪除和更新的事件模型,這裡就用新增作為栗子,在領域層 Christ3D.Domain 中,新建 Events 資料夾,用來存放我們所有的事件模型,
因為是 Student 模型,所以我們在 Events 資料夾下,新建 Student 資料夾,並新建 StudentRegisteredEvent.cs 學生新增事件類:
namespace Christ3D.Domain.Events { /// <summary> /// Student被新增後引發事件 /// 繼承事件基類標識 /// </summary> public class StudentRegisteredEvent : Event { // 建構函式初始化,整體事件是一個值物件 public StudentRegisteredEvent(Guid id, string name, string email, DateTime birthDate, string phone) { Id = id; Name = name; Email = email; BirthDate = birthDate; Phone = phone; } public Guid Id { get; set; } public string Name { get; private set; } public string Email { get; private set; } public DateTime BirthDate { get; private set; } public string Phone { get; private set; } } }
2、定義領域事件的處理程式Handler
這個和我們的命令處理程式一樣,只不過我們的 命令處理程式 是匯流排在 應用服務層 分發的,而 事件處理程式 是在 領域層的命令處理程式 中被匯流排引發的,可能有點兒拗口,看看下邊程式碼就清楚了,就是一個引用場景的順序問題。
namespace Christ3D.Domain.EventHandlers { /// <summary> /// Student事件處理程式 /// 繼承INotificationHandler<T>,可以同時處理多個不同的事件模型 /// </summary> public class StudentEventHandler : INotificationHandler<StudentRegisteredEvent>, INotificationHandler<StudentUpdatedEvent>, INotificationHandler<StudentRemovedEvent> { // 學習被註冊成功後的事件處理方法 public Task Handle(StudentRegisteredEvent message, CancellationToken cancellationToken) { // 恭喜您,註冊成功,歡迎加入我們。 return Task.CompletedTask; } // 學生被修改成功後的事件處理方法 public Task Handle(StudentUpdatedEvent message, CancellationToken cancellationToken) { // 恭喜您,更新成功,請牢記修改後的資訊。 return Task.CompletedTask; } // 學習被刪除後的事件處理方法 public Task Handle(StudentRemovedEvent message, CancellationToken cancellationToken) { // 您已經刪除成功啦,記得以後常來看看。 return Task.CompletedTask; } } }
相信大家應該都能看的明白,在上邊的註釋已經很清晰的表達了響應的作用,如果有看不懂,咱們可以一起交流。
好啦,現在第二步已經完成,剩下最後一步:如何通過事件匯流排分發我們的事件模型了。
3、在事件匯流排EventBus中引發事件
這個使用起來很簡單,主要是我們在命令處理程式中,處理完了持久化以後,接下來呼叫我們的事件匯流排,對不同的事件模型進行分發,就比如我們的 新增Student 命令處理程式方法中,我們通過工作單元新增成功後,需要做下一步,比如發郵件,那我們就需要這麼做。
在命令處理程式 StudentCommandHandler.cs 中,完善我們的提交成功的處理:
// 持久化 _studentRepository.Add(customer); // 統一提交 if (Commit()) { // 提交成功後,這裡需要釋出領域事件 // 比如歡迎使用者註冊郵件呀,簡訊呀等 Bus.RaiseEvent(new StudentRegisteredEvent(customer.Id, customer.Name, customer.Email, customer.BirthDate,customer.Phone)); }
這樣就很簡單的將我們的事件模型分發到了事件匯流排中去了,這個時候記得要在 IoC 專案中,進行注入。關於觸發過程下邊我簡單說一下。
4、整體事件驅動執行過程
說到了這裡,你可能發現和命令匯流排很相似,也可能不是很懂,簡單來說,整體流程是這樣的:
1、首先我們在命令處理程式中呼叫事件匯流排來引發事件 Bus.RaiseEvent(........); 2、然後在Bus中,將我們的事件模型進行包裝成固定的格式 _mediator.Publish(@event); 3、然後通過注入的方法,將包裝後的事件模型與事件處理程式進行匹配,系統執行事件模型,就自動例項化事件處理程式 StudentEventHandler; 4、最後執行我們Handler 中各自的處理方法 Task Handle(StudentRegisteredEvent message)。
希望正好也溫習下命令匯流排的執行過程。
5、依賴注入事件模型和處理程式
// Domain - Events // 將事件模型和事件處理程式匹配注入 services.AddScoped<INotificationHandler<StudentRegisteredEvent>, StudentEventHandler>(); services.AddScoped<INotificationHandler<StudentUpdatedEvent>, StudentEventHandler>(); services.AddScoped<INotificationHandler<StudentRemovedEvent>, StudentEventHandler>();
這個時候,我們DDD領域驅動設計核心篇的第一部分就是這樣了,還剩下最後的,事件驅動的 事件源 和 事件儲存/回溯 ,我們下一講再說。
接下來咱們說說領域通知,為什麼要說領域通知呢,大家應該還記得我們之前將錯誤資訊放到了記憶體中,無論是操作還是業務上都很嚴重的問題,肯定是不可取的。那我們應該採用什麼辦法呢,欸?!沒錯,你會發現,通過上邊的事件驅動設計,發現領域通知我們也可以採用這個方法,首先是多個模型之間相互通訊,但又不相互引用;而且也在命令處理程式中,對資訊進行分發,和發郵件很類似,那具體如何操作呢,請往下看。
四、事件分發的另一個用途 —— 領域通知
1、領域通知模型 DomainNotification
這個通知模型,就像是一個訊息佇列一樣,在我們的記憶體中,通過通知處理程式進行釋出和使用,有自己的生命週期,當被訪問並呼叫完成的時候,會手動對其進行回收,以保證資料的完整性和一致性,這個就很好的解決了咱們之前用Memory快取通知資訊的弊端。
在我們的核心領域層 Christ3D.Domain.Core 中,新建資料夾 Notifications ,然後新增領域通知模型 DomainNotification.cs:
namespace Christ3D.Domain.Core.Notifications { /// <summary> /// 領域通知模型,用來獲取當前匯流排中出現的通知資訊 /// 繼承自領域事件和 INotification(也就意味著可以擁有中介的釋出/訂閱模式) /// </summary> public class DomainNotification : Event { // 標識 public Guid DomainNotificationId { get; private set; } // 鍵(可以根據這個key,獲取當前key下的全部通知資訊) // 這個我們在事件源和事件回溯的時候會用到,伏筆 public string Key { get; private set; } // 值(與key對應) public string Value { get; private set; } // 版本資訊 public int Version { get; private set; } public DomainNotification(string key, string value) { DomainNotificationId = Guid.NewGuid(); Version = 1; Key = key; Value = value; } } }
2、領域通知處理程式 DomainNotificationHandler
該處理程式,可以理解成,就像一個類的管理工具,在每次物件生命週期內 ,對領域通知進行例項化,獲取值,手動回收,這樣保證了每次訪問的都是當前例項的資料。
還是在資料夾 Notifications 下,新建處理程式 DomainNotificationHandler.cs:
namespace Christ3D.Domain.Core.Notifications { /// <summary> /// 領域通知處理程式,把所有的通知資訊放到事件匯流排中 /// 繼承 INotificationHandler<T> /// </summary> public class DomainNotificationHandler : INotificationHandler<DomainNotification> { // 通知資訊列表 private List<DomainNotification> _notifications; // 每次訪問該處理程式的時候,例項化一個空集合 public DomainNotificationHandler() { _notifications = new List<DomainNotification>(); } // 處理方法,把全部的通知資訊,新增到記憶體裡 public Task Handle(DomainNotification message, CancellationToken cancellationToken) { _notifications.Add(message); return Task.CompletedTask; } // 獲取當前生命週期內的全部通知資訊 public virtual List<DomainNotification> GetNotifications() { return _notifications; } // 判斷在當前匯流排物件週期中,是否存在通知資訊 public virtual bool HasNotifications() { return GetNotifications().Any(); } // 手動回收(清空通知) public void Dispose() { _notifications = new List<DomainNotification>(); } } }
到了目前為止,我們的DDD領域驅動設計中的 核心領域層 部分,已經基本完成了(還剩下下一篇的事件源、事件回溯):
3、在命令處理程式中釋出通知
我們定義好了領域通知的處理程式,我們就可以像上邊的釋出事件一樣,來發布我們的通知資訊了。這裡用一個栗子來試試:
在學習命令處理程式 StudentCommandHandler.cs 中的 RegisterStudentCommand 處理方法中,完善:
// 判斷郵箱是否存在 // 這些業務邏輯,當然要在領域層中(領域命令處理程式中)進行處理 if (_studentRepository.GetByEmail(customer.Email) != null) { ////這裡對錯誤資訊進行釋出,目前採用快取形式 //List<string> errorInfo = new List<string>() { "該郵箱已經被使用!" }; //Cache.Set("ErrorData", errorInfo); //引發錯誤事件 Bus.RaiseEvent(new DomainNotification("", "該郵箱已經被使用!")); return Task.FromResult(new Unit()); }
這個時候,我們把錯誤通知資訊在事件匯流排中釋出出去,剩下的就是需要在別的任何地方訂閱即可,還記得哪裡麼,沒錯就是我們的自定義檢視元件中,我們需要訂閱通知資訊,展示在頁面裡。
4、在檢視元件中獲取通知資訊
這個很簡單,之前我們用的是注入 IMemory 的方式,在快取中獲取,現在我們通過注入領域通知處理程式來實現,在檢視元件 AlertsViewComponent.cs 中:
public class AlertsViewComponent : ViewComponent { // 快取注入,為了收錄資訊(錯誤方法,以後會用通知,通過領域事件來替換) // private IMemoryCache _cache; // 領域通知處理程式 private readonly DomainNotificationHandler _notifications; // 建構函式注入 public AlertsViewComponent(INotificationHandler<DomainNotification> notifications) { _notifications = (DomainNotificationHandler)notifications; } /// <summary> /// Alerts 檢視元件 /// 可以非同步,也可以同步,注意方法名稱,同步的時候是Invoke /// 我寫非同步是為了為以後做準備 /// </summary> /// <returns></returns> public async Task<IViewComponentResult> InvokeAsync() { // 從通知處理程式中,獲取全部通知資訊,並返回給前臺 var notificacoes = await Task.FromResult((_notifications.GetNotifications())); notificacoes.ForEach(c => ViewData.ModelState.AddModelError(string.Empty, c.Value)); return View(); } }
5、StudentController 判斷是否有通知資訊
通過注入的方式,把 INotificationHandler<DomainNotification> 注入控制器,然後因為這個介面可以例項化多個物件,那我們就強型別轉換成 DomainNotificationHandler:
這裡要說明下,記得要對事件處理程式注入,才能使用:
// 將事件模型和事件處理程式匹配注入 services.AddScoped<INotificationHandler<DomainNotification>, DomainNotificationHandler>();
五、結語
好啦,今天的講解基本就到這裡了,今天重點說明了,我們如何使用事件匯流排,已經事件驅動模型下如何定義事件模型和事件處理程式,如果你都看懂了呢,這裡可以簡單回想一下以下幾個問題:
1、為什麼要定義事件驅動呢?(提示詞:業務分離)
2、我們是在哪裡釋出這些事件的呢?(提示詞:.publish()方法)
3、事件驅動中的生命週期是從哪裡開始到哪裡接受的?(提示:處理程式Handler)
如果你對以上的內容還是比較困惑呢,這裡有兩個文章可以參考,當然,多溝通才是關鍵!
https://www.cnblogs.com/lori/p/4080426.html
https://blog.csdn.net/sD7O95O/article/details/79609305
六、GitHub & Gitee
https://github.com/anjoy8/ChristDDD
https://gitee.com/laozhangIsPhi/ChristDDD
--END