在 ASP.NET Core 項目中使用 MediatR 實現中介者模式

  • 2019 年 11 月 20 日
  • 筆記

一、前言

   最近有在看 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 知多少