緣起
哈嘍大家好,又是周二了,時間很快,我的第二個系列DDD領域驅動設計講解已經接近尾聲了,除了今天的時間驅動EDA(也有可能是兩篇),然后就是下一篇的事件回溯,就剩下最后的權限驗證了,然后就完結了,這兩個月我也是一直在自學,然后再想栗子,個人感覺收獲還是很大的,比如DDD領域分層設計、CQRS讀寫分離、CommandBus命令總線、EDA事件驅動、四色原理等等,如果大家真的能踏踏實實的看完,或者說多看看書,對個人的思想提高有很大的幫助,這里要說兩點,可能會有一些小伙伴不開心,但是還是要說說:
1、很多小伙伴一直問我看什么書,我個人感覺,只要是書看就對了,與其糾結哪本,還不如踏踏實實先看一本。
2、還有小伙伴問,為啥還沒有看到微服務的內容?
我想說,其實微服務是一個很寬泛的領域,比如.net core的深入學習,依賴注入的使用,倉儲契約、DDD+事件總線的學習、中介者模式、Docker的學習、容器化設計等等等等,這些都屬於微服務的范疇,如果這些基礎知識不會的話,可能是學不好微服務的。
周末的時候,我又好好的整理了下我的Github上的代碼,然后新建了一些分支(如果你不會使用Git命令,可以看我的一個文章: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,應該需要翻牆)
一、領域事件驅動設計 —— 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、實現總線分發接口
在基層設施總線層的記憶總線 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
這個和我們的命令處理程序一樣,只不過我們的命令處理程序是總線在應用服務層分發的,而事件處理程序是在領域層的命令處理程序中被總線引發的,可能有點兒拗口,看看下邊代碼就清楚了,就是一個引用場景的順序問題。
在領域層Chirst3D.Domain 中,新建 EventHandlers 文件夾,用來存放我們的事件處理程序,然后新建 Student事件模型的處理程序 StudentEventHandler.cs:
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 原生注入類NativeInjectorBootStrapper中,進行注入。關於觸發過程下邊我簡單說一下。
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()); }
這個時候,我們把錯誤通知信息在事件總線中發布出去,剩下的就是需要在別的任何地方訂閱即可,還記得哪里么,沒錯就是我們的自定義視圖組件中,我們需要訂閱通知信息,展示在頁面里。
注意:我們還要修改一下之前我們的命令處理程序基類 CommandHandler.cs 的驗證信息收集方法,因為之前是用緩存來實現的,我們這里也用發布事件來實現:
//將領域命令中的驗證錯誤信息收集 //目前用的是緩存方法(以后通過領域通知替換) protected void NotifyValidationErrors(Command message) { List<string> errorInfo = new List<string>(); foreach (var error in message.ValidationResult.Errors) { //errorInfo.Add(error.ErrorMessage); //將錯誤信息提交到事件總線,派發出去 _bus.RaiseEvent(new DomainNotification("", error.ErrorMessage)); } //將錯誤信息收集一:緩存方法(錯誤示范) //_cache.Set("ErrorData", errorInfo); }
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