在 ASP.NET Core 專案中使用 MediatR 實現中介者模式
一、前言
最近有在看 DDD 的相關資料以及微軟的 eShopOnContainers 這個專案中基於 DDD 的架構設計,在 Ordering 這個示例服務中,可以看到各層之間的程式碼呼叫與我們之前傳統的呼叫方式似乎差異很大,整個專案各個層之間的程式碼全部是通過注入 IMediator 進行呼叫的,F12 檢視原始碼後可以看到該介面是屬於 MediatR 這個元件的。既然要照葫蘆畫瓢,那我們就先來了解下如何在 ASP.NET Core 專案中使用 MediatR。
程式碼倉儲:https://github.com/Lanesra712/grapefruit-common/tree/master/sample/aspnetcore/aspnetcore-mediatr-tutorial
二、Step by Step
MediatR 從 github 的專案主頁上可以看到作者對於這個專案的描述是基於中介者模式的 .NET 實現,是一種基於程序內的資料傳遞。也就是說這個元件主要實現的是在一個應用中實現資料傳遞,如果想要實現多個應用間的資料傳遞就不太適合了。從作者的 github 個人主頁上可以看到,他還是 AutoMapper 這個 OOM 元件的作者,PS,如果你想要了解如何在 ASP.NET Core 專案中使用 AutoMapper,你可以檢視我之前寫的這一篇文章(電梯直達)。而對於 MediatR 來說,在具體的學習使用之前,我們先來了解下什麼是中介者模式。
1、什麼是中介者模式
很多舶來詞的中文翻譯其實最終都會與實際的含義相匹配,例如軟體開發過程中的 23 種設計模式的中文名稱,我們其實可以比較容易的從中文名稱中得知出該設計模式具體想要實現的作用,就像這裡介紹的中介者模式。
在我們通過程式碼實現實際的業務邏輯時,如果涉及到多個物件類之間的互動,通常我們都是會採用直接引用的形式,隨著業務邏輯變的越來越複雜,對於一個簡單的業務抽象出的實現方法中,可能會被我們新增上各種判斷的邏輯或是對於資料的業務邏輯處理方法。
例如一個簡單的使用者登入事件,我們可能最終會抽象出如下的業務流程實現。
public bool Login(AppUserLoginDto dto, out string msg) { bool flag = false; try { // 1、驗證碼是否正確 flag = _redisLogic.GetValueByKey(dto.VerificationCode); if (!flag) { msg = "驗證碼錯誤,請重試"; return false; } // 2、驗證賬戶密碼是否正確 flag = _userLogic.GetAppUser(dto.Account.Trim(), dto.Password.Trim(), out AppUserDetailDto appUser); if (!flag) { msg = "賬戶或密碼錯誤,請重試"; return false; } // 3、驗證賬戶是否可以登入當前的站點(未被鎖定 or 具有登入當前系統的許可權...) flag = _authLogic.CheckIsAvailable(appUser); if (!flag) { msg = "使用者被禁止登入當前系統,請重試"; return false; } // 4、設定當前登入使用者資訊 _authLogic.SetCurrentUser(appUser); // 5、記錄登入記錄 _userLogic.SaveLoginRecord(appUser); msg = ""; return true; } catch (Exception ex) { // 記錄錯誤資訊 msg = $"使用者登入失敗:{ex.Message}"; return false; } }
這裡我們假設對於登入事件的實現方法存在於 UserAppService 這個類中,對於 redis 資源的操作在 RedisLogic 類中,對於使用者相關資源的操作在 UserLogic 中,而對於許可權校驗相關的資源操作位於 AuthLogic 類中。
可以看到,為了實現 UserAppService 類中定義的登入方法,我們至少需要依賴於 RedisLogic、UserLogic 以及 AuthLogic,甚至在某些情況下可能在 UserLogic 和 AuthLogic 之間也存在著某種依賴關係,因此我們可以從中得到如下圖所示的類之間的依賴關係。
一個簡單的登入業務尚且如此,如果我們需要對登入業務新增新的需求,例如現在很多網站的登入和註冊其實是放在一起的,當登入時如果判斷沒有當前的使用者資訊,其實會催生建立新使用者的流程,那麼,對於原本的登入功能實現,是不是會存在繼續新增新的依賴關係的情況。同時對於很多本身就很複雜的業務,最終實現出來的方法是不是會有更多的物件類之間存在各種的依賴關係,牽一髮而動全身,後期修改測試的成本會不會變得更高。
那麼,中介者模式是如何解決這個問題呢?
在上文有提到,對於舶來詞的中文名稱,中文更多的會根據實際的含義進行命名,試想一下我們在現實生活中提到中介,是不是更多的會想到房屋中介這一角色。當我們來到一個新的城市,面臨著租房的問題,絕大多數的情況下,我們最終需要通過中介去達成我們租房的目的。在租房這個案例中,房屋中介其實就是一箇中介者,他承接我們對於想要租的房子的各種需求,從自己的房屋資料庫中去尋找符合條件的,最終以一個橋樑的形式,連線我們與房東,最終就房屋的租住達成一致。
而在軟體開發中,中介者模式則是要求我們根據實際的業務去定義一個包含各種物件之間互動關係的物件類,之後,所有涉及到該業務的物件都只關聯於這一個中介物件類,不再顯式的呼叫其它類。採用了中介者模式之後設計的登入功能所涉及到的類依賴如下圖所示,這裡的 AppUserLoginEventHandler 其實就是我們的中介類。
當然,任何事都會有利有弊,不會存在百分百完美的事情,就像我們通過房租中介去尋找合適的房屋,最終我們需要付給中介一筆費用去作為酬勞,採用中介者模式設計的程式碼架構也會存在別的問題。因為在程式碼中引入了中介者這一物件,勢必會增加我們程式碼的複雜度,可能會使原本很輕鬆就實現的程式碼變得複雜。同時,我們引入中介者模式的初衷是為了解決各個物件類之間複雜的引用關係,對於某些業務來說,本身就很複雜,最終必定會導致這個中介者物件異常複雜。
畢竟,軟體開發的過程中不會存在銀彈去幫我們解決所有的問題。
那麼,在本篇文章的示例程式碼中,我將使用 MediatR 這一元件,通過引入中介者模式的思想來完成上面的使用者登入這一案例。
2、元件載入
在使用 MediatR 之前,這裡簡單介紹下這篇文章的示例 demo 專案。這個示例專案的架構分層可以看成是介於傳統的多層架構與採用 DDD 的思想的架構分層。嗯,你可以理解成四不像,屬於那種傳統模式下的開發人員在往 DDD 思想上進行遷移的成品,具體的程式碼分層說明解釋如下。
01_Infrastructure:基礎架構層,這層會包含一些對於基礎元件的配置或是幫助類的程式碼,對於每個新建的服務來說,該層的程式碼幾乎都是差不多的,所以對於基礎架構層的程式碼其實最好是釋出到公有 or 私有的 Nuget 倉庫中,然後我們直接在專案中通過 Nuget 去引用。
對於採用 DDD 的思想構建的專案來說,很多人可能習慣將一些實體的配置也放置在基礎架構層,我的個人理解還是應該置於領域層,對於基礎架構層,只做一些基礎元件的封裝。如果有什麼不對的地方,歡迎在評論區提出。
02_Domain:領域層,這層會包含我們根據業務劃分出的領域的幾乎所有重要的部分,有領域物件(Domain Object)、值物件(Value Object)、領域事件(Domain Event)、以及倉儲(Repository)等等領域元件。
這裡雖然我建立了 AggregateModels(聚合實體)這個資料夾,其實在這個專案中,我建立的還是不包含任何業務邏輯的貧血模型。同時,對於倉儲(Repository)在領域分層中是置於 Infrastructure(基礎架構層)還是位於 Domain(領域層),每個人都會有自己的理解,這裡我還是更傾向於放在 Domain 層中更符合其定位。
03_Application:應用層,這一層會包含我們基於領域所封裝出的各種實際的業務邏輯,每個封裝出的服務應用之間並不會出現互相呼叫的情況。
Sample.Api:API 介面層,這層就很簡單了,主要是通過 API 介面暴露出我們基於領域對外提供的各種服務。
整個示例專案的分層結構如下圖所示。
與使用其它的第三方元件的使用方式相同,在使用之前,我們需要在專案中通過 Nuget 新增對於 MediatR 的程式集引用。
這裡需要注意,因為我們主要是通過引用 MediatR 來實現中介者模式,所以我們只需要在領域層和應用層載入 MediatR 即可。而對於 Sample.Api 這個 Web API 專案,因為需要通過依賴注入的方式來使用我們基於 MediatR 所構建出的各種服務,所以這裡我們還要新增 MediatR.Extensions.Microsoft.DependencyInjection 這個程式集到 Sample.Api 中。
Install-Package MediatR Install-Package MediatR.Extensions.Microsoft.DependencyInjection
3、案例實現
首先我們在 Sample.Domain 這個類庫的 AggregateModels 資料夾下新增 AppUser(使用者資訊)類 和 Address(地址資訊) 類,這裡雖然並沒有採用 DDD 的思想去劃分領域物件和值物件,我們創建出來的都是不含任何業務邏輯的貧血模型。但是在使用者管理這個業務中,對於使用者所包含的聯絡地址資訊,其實是一種無狀態的資料。也就是說對於同一個地址資訊,不會因為置於多個使用者中而出現數據的二義性。因此,對於地址資訊來說,是不需要唯一的標識就可以區分出這個資料的,所以這裡的 Address 類就不需要新增主鍵,其實也就是對應於領域建模中的值物件。
這裡我是使用的 EF Core 作為專案的 ORM 元件,當建立好需要使用實體之後,我們在 Sample.Domain 這個類庫下面新建一個 SeedWorks 資料夾,新增自定義的 DbContext 物件和用於執行 EF Core 第一次生成資料庫時寫入預置種子資料的資訊類。
這裡需要注意,在 EF Core 中,當我們需要將編寫的 C# 類通過 Code First 創建出資料庫表時,我們的 C# 類必須包含主鍵資訊。而對應到我們這裡的 Address 類來說,它更多的是作為 AppUser 類中的屬性資訊來展示的,所以這裡我們需要對 EF Core 生成資料庫表的過程進行重寫。
這裡我們在 SeedWorks 資料夾下建立一個新的資料夾 EntityConfigurations,在這裡用來存放我們自定義的 EF Core 建立表的規則。新建一個繼承於 IEntityTypeConfiguration<AppUser> 介面的 AppUserConfiguration 配置類,在介面預設 Configure 方法中,我們需要編寫對映規則,將 Address 類作為 AppUser 類中的欄位進行顯示,最終實現後的程式碼如下所示。
public class AppUserConfiguration : IEntityTypeConfiguration<AppUser> { public void Configure(EntityTypeBuilder<AppUser> builder) { // 表名稱 builder.ToTable("appuser"); // 實體屬性配置 builder.OwnsOne(i => i.Address, n => { n.Property(p => p.Province).HasMaxLength(50) .HasColumnName("Province") .HasDefaultValue(""); n.Property(p => p.City).HasMaxLength(50) .HasColumnName("City") .HasDefaultValue(""); n.Property(p => p.Street).HasMaxLength(50) .HasColumnName("Street") .HasDefaultValue(""); n.Property(p => p.ZipCode).HasMaxLength(50) .HasColumnName("ZipCode") .HasDefaultValue(""); }); } }
當建立表的對映規則編寫完成後,我們就可以對 UserApplicationDbContext 類進行重寫 OnModelCreating 方法。在這個方法中,我們就可以去應用我們自定義設定的實體對映規則,從而讓 EF Core 按照我們的想法去建立資料庫,最終實現的程式碼如下所示。
public class UserApplicationDbContext : DbContext { public DbSet<AppUser> AppUsers { get; set; } public UserApplicationDbContext(DbContextOptions<UserApplicationDbContext> options) : base(options) { } /// <summary> /// /// </summary> /// <param name="modelBuilder"></param> protected override void OnModelCreating(ModelBuilder modelBuilder) { // 自定義 AppUser 表建立規則 modelBuilder.ApplyConfiguration(new AppUserConfiguration()); } }
當我們建立好 DbContext 後,我們需要在 Startup 類的 ConfigureServices 方法中進行注入。在示例程式碼中,我使用的是 MySQL 8.0 資料庫,將配置檔案寫入到 appsettings.json 檔案中,最終注入 DbContext 的程式碼如下所示。
public void ConfigureServices(IServiceCollection services) { // 配置資料庫連線字串 services.AddDbContext<UserApplicationDbContext>(options => options.UseMySql(Configuration.GetConnectionString("SampleConnection"))); }
資料庫的連線字串配置如下。
{ "ConnectionStrings": { "SampleConnection": "server=127.0.0.1;database=sample.application;user=root;password=123456@sql;port=3306;persistsecurityinfo=True;" } }
在上文有提到,除了建立一個 DbContext 物件,我們還建立了一個 DbInitializer 類用於在 EF Core 第一次執行建立資料庫操作時將我們預置的資訊寫入到對應的資料庫表中。這裡我們只是簡單的判斷下 AppUser 這張表是否存在資料,如果沒有資料,我們就新增一條新的記錄,最終實現的程式碼如下所示。
public class DbInitializer { public static void Initialize(UserApplicationDbContext context) { context.Database.EnsureCreated(); if (context.AppUsers.Any()) return; AppUser admin = new AppUser() { Id = Guid.NewGuid(), Name = "墨墨墨墨小宇", Account = "danvic.wang", Phone = "13912345678", Age = 12, Password = "123456", Gender = true, IsEnabled = true, Address = new Address("啦啦啦啦街道", "啦啦啦市", "啦啦啦省", "12345"), Email = "[email protected]", }; context.AppUsers.Add(admin); context.SaveChanges(); } }
當我們完成種子資料植入的程式碼,我們需要在程式啟動之前就去執行我們的程式碼。因此我們需要修改 Program 類中的 Main 方法,實現在執行 web 程式之前去執行種子資料的植入。
public class Program { public static void Main(string[] args) { var host = CreateWebHostBuilder(args).Build(); using (var scope = host.Services.CreateScope()) { // 執行種子資料植入 // var services = scope.ServiceProvider; var context = services.GetRequiredService<UserApplicationDbContext>(); DbInitializer.Initialize(context); } } public static IWebHostBuilder CreateWebHostBuilder(string[] args) => WebHost.CreateDefaultBuilder(args) .UseStartup<Startup>(); }
這時,執行我們的專案,程式就會自動執行建立資料庫的操作,同時會將我們預設好的種子資料寫入到資料庫表中,最終實現的效果如下圖所示。
基礎的專案程式碼已經完成之後,我們就可以開始學習如何通過 MediatR 來實現中介者模式。在這一章的示例專案中,我們會使用到 MediatR 中兩個很重要的介面型別:IRequest 和 INotification。
在 Github 上,作者針對這兩個介面做了如下的解釋,這裡我會按照我的理解去進行使用。同時,為了防止我的理解出現了偏差,從而對各位造成影響,這裡貼上作者回復解釋的原文。
Requests are for: 1 request to 1 handler. Handler may or may not return a value Notifications are for: 1 notification to n handlers. Handler may not return a value. In practical terms, requests are "commands", notifications are "events". Command would be directing MediatR to do something like "ApproveInvoiceCommand -> ApproveInvoiceHandler". Event would be notifications, like "InvoiceApprovedEvent -> SendThankYouEmailToCustomerHandler"
對於繼承於 IRequest 介面的類來說,一個請求(request)只會有一個針對這個請求的處理程式(requestHandler),它可以返回值或者不返回任何資訊;
而對於繼承於 INotification 介面的類來說,一個通知(notification)會對應多個針對這個通知的處理程式(notificationHandlers),而它們不會返回任何的資料。
請求(request)更像是一種命令(command),而通知(notification)更像是一種事件(event)。嗯,可能看起來更暈了,jbogard 這裡給了一個案例給我們進一步的解釋了 request 與 notification 之間的差異性。
雙十一剛過,很多人都會瘋狂剁手,對於購買大件來說,為了能夠更好地擁有售後服務,我們在購買後肯定會期望商家給我們提供發票,這裡的要求商家提供發票就是一種 request,而針對我們的這個請求,商家會做出迴應,不管能否開出來發票,商家都應當通知到我們,這裡的通知使用者就是一種 notification。
對於提供發票這個 request 來說,不管最終的結果如何,它只會存在一種處理方式;而對於通知使用者這個 notification 來說,商家可以通過簡訊通知,可以通過公眾號推送,也可以通過郵件通知,不管採用什麼方式,只要完成了通知,對於這個事件來說也就已經完成了。
而對應於使用者登入這個業務來說,使用者的登入行為其實就是一個 request,對於這個 request 來說,我們可能會去資料庫查詢賬戶是否存在,判斷是不是具有登入系統的許可權等等。而不管我們在這個過程中做了多少的邏輯判斷,它只會有兩種結果,登入成功或登入失敗。而對於使用者登入系統之後可能需要設定當前登入人員資訊,記錄使用者登入日誌這些行為來說,則是歸屬於 notification 的。
弄清楚了使用者登入事件中的 request 和 notification 劃分,那麼接下來我們就可以通過程式碼來實現我們的功能。這裡對於示例專案中的一些基礎元件的配置我就跳過了,如果你想要具體的瞭解這裡使用到的一些元件的使用方法,你可以查閱我之前的文章。
首先,我們在 Sample.Application 這個類庫下面建立一個 Commands 資料夾,在下面存放使用者的請求資訊。現在我們建立一個用於對映使用者登入請求的 UserLoginCommand 類,它需要繼承於 IRequest<T> 這個泛型介面。因為對於使用者登入這個請求來說,只會有可以或不可以這兩個結果,所以對於這個請求的響應的結果是 bool 型別的,也就是說,我們具體應該繼承的是 IRequest<bool>。
對於使用者發起的各種請求來說,它其實只是包含了對於這次請求的一些基本資訊,而對於 UserLoginCommand 這個使用者登入請求類來說,它可能只會有賬號、密碼、驗證碼這三個資訊,請求類程式碼如下所示。
public class UserLoginCommand : IRequest<bool> { /// <summary> /// 賬戶 /// </summary> public string Account { get; private set; } /// <summary> /// 密碼 /// </summary> public string Password { get; private set; } /// <summary> /// 驗證碼 /// </summary> public string VerificationCode { get; private set; } /// <summary> /// ctor /// </summary> /// <param name="account">賬戶</param> /// <param name="password">密碼</param> /// <param name="verificationCode">驗證碼</param> public UserLoginCommand(string account, string password, string verificationCode) { Account = account; Password = password; VerificationCode = verificationCode; } }
當我們擁有了儲存使用者登入請求資訊的類之後,我們就需要對使用者的登入請求進行處理。這裡,我們在 Sample.Application 這個類庫下面新建一個 CommandHandlers 資料夾用來存放使用者請求的處理類。
現在我們建立一個繼承於 IRequestHandler 介面的 UserLoginCommandHandler 類用來實現對於使用者登入請求的處理。IRequestHandler 是一個泛型的介面,它需要我們在繼承時宣告我們需要實現的請求,以及該請求的返回資訊。因此,對於 UserLoginCommand 這個請求來說,UserLoginCommandHandler 這個請求的處理類,最終需要繼承於 IRequestHandler<UserLoginCommand, bool>。
就像上面提到的一樣,我們需要在這個請求的處理類中對使用者請求的資訊進行處理,在 UserLoginCommandHandler 類中,我們應該在 Handle 方法中去執行我們的判斷邏輯,這裡我們會引用到倉儲來獲取使用者的相關資訊。倉儲中的程式碼這裡我就不展示了,最終我們實現後的程式碼如下所示。
public class UserLoginCommandHandler : IRequestHandler<UserLoginCommand, bool> { #region Initizalize /// <summary> /// 倉儲例項 /// </summary> private readonly IUserRepository _userRepository; /// <summary> /// ctor /// </summary> /// <param name="userRepository"></param> public UserLoginCommandHandler(IUserRepository userRepository) { _userRepository = userRepository ?? throw new ArgumentNullException(nameof(userRepository)); } #endregion Initizalize /// <summary> /// Command Handler /// </summary> /// <param name="request"></param> /// <param name="cancellationToken"></param> /// <returns></returns> public async Task<bool> Handle(UserLoginCommand request, CancellationToken cancellationToken) { // 1、判斷驗證碼是否正確 if (string.IsNullOrEmpty(request.VerificationCode)) return false; // 2、驗證登入密碼是否正確 var appUser = await _userRepository.GetAppUserInfo(request.Account.Trim(), request.Password.Trim()); if (appUser == null) return false; return true; } }
當我們完成了對於請求的處理程式碼後,就可以在 controller 中提供使用者訪問的入口。當然,因為我們需要採用依賴注入的方式去使用 MediatR,所以在使用之前,我們需要將請求的對應處理關係注入到依賴注入容器中。
在通過依賴注入的方式使用 MediatR 時,我們需要將所有的事件(請求以及通知)注入到容器中,而 MediatR 則會自動尋找對應事件的處理類,除此之外,我們也需要將通過依賴注入使用到的 IMediator 介面的實現類注入到容器中。而在這個示例專案中,我們主要是在 Sample.Domain、Sample.Application 以及我們的 Web Api 專案中使用到了 MediatR,因此,我們需要將這三個專案中使用到 MediatR 的類全部注入到容器中。
一個個的注入會比較的麻煩,所以這裡我還是採用對指定的程式集進行反射操作,去獲取需要載入的資訊批量的進行注入操作,最終實現後的程式碼如下。
public static IServiceCollection AddCustomMediatR(this IServiceCollection services, MediatorDescriptionOptions options) { // 獲取 Startup 類的 type 型別 var mediators = new List<Type> { options.StartupClassType }; // IRequest<T> 介面的 type 型別 var parentRequestType = typeof(IRequest<>); // INotification 介面的 type 型別 var parentNotificationType = typeof(INotification); foreach (var item in options.Assembly) { var instances = Assembly.Load(item).GetTypes(); foreach (var instance in instances) { // 判斷是否繼承了介面 // var baseInterfaces = instance.GetInterfaces(); if (baseInterfaces.Count() == 0 || !baseInterfaces.Any()) continue; // 判斷是否繼承了 IRequest<T> 介面 // var requestTypes = baseInterfaces.Where(i => i.IsGenericType && i.GetGenericTypeDefinition() == parentRequestType); if (requestTypes.Count() != 0 || requestTypes.Any()) mediators.Add(instance); // 判斷是否繼承了 INotification 介面 // var notificationTypes = baseInterfaces.Where(i => i.FullName == parentNotificationType.FullName); if (notificationTypes.Count() != 0 || notificationTypes.Any()) mediators.Add(instance); } } // 新增到依賴注入容器中 services.AddMediatR(mediators.ToArray()); return services; }
因為需要知道哪些程式集應該進行反射獲取資訊,而對於 Web Api 這個專案來說,它只會通過依賴注入使用到 IMediator 這一個介面,所以這裡需要採用不同的引數的形式去確定具體需要通過反射載入哪些程式集。
public class MediatorDescriptionOptions { /// <summary> /// Startup 類的 type 型別 /// </summary> public Type StartupClassType { get; set; } /// <summary> /// 包含使用到 MediatR 元件的程式集 /// </summary> public IEnumerable<string> Assembly { get; set; } }
最終,我們就可以在 Startup 類中通過擴充套件方法的資訊進行快速的注入,實際使用的程式碼如下,這裡我是將需要載入的程式集資訊放在 appsetting 這個配置檔案中的,你可以根據你的喜好進行調整。
public class Startup { // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { // Config mediatr services.AddCustomMediatR(new MediatorDescriptionOptions { StartupClassType = typeof(Startup), Assembly = Configuration["Assembly:Mediator"].Split("|", StringSplitOptions.RemoveEmptyEntries) }); } }
在這個示例專案中的配置資訊如下所示。
{ "Assembly": { "Function": "Sample.Domain", "Mapper": "Sample.Application", "Mediator": "Sample.Application|Sample.Domain" } }
當我們注入完成後,就可以直接在 controller 中進行使用。對於繼承了 IRequest 的方法,可以直接通過 Send 方法進行呼叫請求資訊,MediatR 會幫我們找到對應請求的處理方法,最終登入 action 中的程式碼如下。
[ApiVersion("1.0")] [ApiController] [Route("api/v{version:apiVersion}/[controller]")] public class UsersController : ControllerBase { #region Initizalize /// <summary> /// /// </summary> private readonly IMediator _mediator; /// <summary> /// ctor /// </summary> /// <param name="mediator"></param> public UsersController(IMediator mediator) { _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); } #endregion Initizalize #region APIs /// <summary> /// 使用者登入 /// </summary> /// <param name="login">使用者登入資料傳輸物件</param> /// <returns></returns> [HttpPost("login")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] public async Task<IActionResult> Post([FromBody] AppUserLoginDto login) { // 實體對映轉換 var command = new UserLoginCommand(login.Account, login.Password, login.VerificationCode); bool flag = await _mediator.Send(command); if (flag) return Ok(new { code = 20001, msg = $"{login.Account} 使用者登入成功", data = login }); else return Unauthorized(new { code = 40101, msg = $"{login.Account} 使用者登入失敗", data = login }); } #endregion APIs }
當我們完成了對於使用者登入請求的處理之後,就可以去執行後續的“通知類”的事件。與使用者登入的請求資訊類相似,對於使用者登入事件的通知類也只是包含一些通知的基礎資訊。在 Smaple.Domain 這個類庫下面,建立一個 Events 檔案用來存放我們的事件,我們來新建一個繼承於 INotification 介面的 AppUserLoginEvent 類,用來對使用者登入事件進行相關的處理。
public class AppUserLoginEvent : INotification { /// <summary> /// 賬戶 /// </summary> public string Account { get; } /// <summary> /// ctor /// </summary> /// <param name="account"></param> public AppUserLoginEvent(string account) { Account = account; } }
在上文中有提到過,對於一個通知事件可能會存在著多種處理方式,所以這裡我們在 Smaple.Application 這個類庫的 DomainEventHandlers 資料夾下面會按照事件去建立對應的資料夾去存放實際處理方法。
對於繼承了 INotification 介面的通知類來說,在 MediatR 中我們可以通過建立繼承於 INotificationHandler 介面的類去處理對應的事件。因為一個 notification 可以有多個的處理程式,所以我們可以建立多個的 NotificationHandler 類去處理同一個 notification。一個示例的 NotificationHandler 類如下所示。
public class SetCurrentUserEventHandler : INotificationHandler<AppUserLoginEvent> { #region Initizalize /// <summary> /// /// </summary> private readonly ILogger<SetCurrentUserEventHandler> _logger; /// <summary> /// /// </summary> /// <param name="logger"></param> public SetCurrentUserEventHandler(ILogger<SetCurrentUserEventHandler> logger) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } #endregion Initizalize /// <summary> /// Notification handler /// </summary> /// <param name="notification"></param> /// <param name="cancellationToken"></param> /// <returns></returns> public Task Handle(AppUserLoginEvent notification, CancellationToken cancellationToken) { _logger.LogInformation($"CurrentUser with Account: {notification.Account} has been successfully setup"); return Task.FromResult(true); } }
如何去引發這個事件,對於領域驅動設計的架構來說,一個更好的方法是將各種領域事件新增到事件的集合中,然後在提交事務之前或之後立即排程這些域事件,而對於我們這個專案來說,因為這不在這篇文章考慮的範圍內,只是演示如何去使用 MediatR 這個元件,所以這裡我就採取在請求邏輯處理完成後直接觸發事件的方式。
在 UserLoginCommandHandler 類中,修改我們的程式碼,在確認登入成功後,通過呼叫 AppUser 類的 SetUserLoginRecord 方法來觸發我們的通知事件,修改後的程式碼如下所示。
public class UserLoginCommandHandler : IRequestHandler<UserLoginCommand, bool> { #region Initizalize /// <summary> /// 倉儲例項 /// </summary> private readonly IUserRepository _userRepository; /// <summary> /// /// </summary> private readonly IMediator _mediator; /// <summary> /// ctor /// </summary> /// <param name="userRepository"></param> /// <param name="mediator"></param> public UserLoginCommandHandler(IUserRepository userRepository, IMediator mediator) { _userRepository = userRepository ?? throw new ArgumentNullException(nameof(userRepository)); _mediator = mediator ?? throw new ArgumentNullException(nameof(mediator)); } #endregion Initizalize /// <summary> /// Command Handler /// </summary> /// <param name="request"></param> /// <param name="cancellationToken"></param> /// <returns></returns> public async Task<bool> Handle(UserLoginCommand request, CancellationToken cancellationToken) { // 1、判斷驗證碼是否正確 if (string.IsNullOrEmpty(request.VerificationCode)) return false; // 2、驗證登入密碼是否正確 var appUser = await _userRepository.GetAppUserInfo(request.Account.Trim(), request.Password.Trim()); if (appUser == null) return false; // 3、觸發登入事件 appUser.SetUserLoginRecord(_mediator); return true; } }
與使用 Send 方法去呼叫 request 類的請求不同,對於繼承於 INotification 介面的事件通知類,我們需要採用 Publish 的方法去呼叫。至此,對於一個採用中介者模式設計的登入流程就結束了,SetUserLoginRecord 方法的定義,以及最終我們實現的效果如下所示。
public void SetUserLoginRecord(IMediator mediator) { mediator.Publish(new AppUserLoginEvent(Account)); }
三、總結
這一章主要是介紹瞭如何通過 MediatR 來實現中介者模式,因為自己也是第一次接觸這種思想,對於 MediatR 這個元件也是第一次使用,所以僅僅是採用案例分享的方式對中介者模式的使用方法進行了一個解釋。如果你想要對中介者模式的具體定義與基礎的概念進行進一步的瞭解的話,可能需要你自己去找資料去弄明白具體的定義。因為初次接觸,難免會有遺漏或錯誤,如果從文章中發現有不對的地方,歡迎在評論區中指出,先行感謝。
四、參考
1、中介者模式— Graphic Design Patterns - 圖說設計模式
2、MediatR 知多少