大家好,我是沙漠盡頭的狼!
AvaloniaUI 是一個強大的跨平台 .NET 用戶端開發框架,讓開發者能夠針對 Windows、Linux、macOS、Android 和 iOS 等多個平台建置應用程式。在建構複雜的應用程式時,模組化與元件間的通訊變得尤為重要。Prism 框架提供了模組化的開發方式,支援擴充套件的熱插拔,而 MediatR 則是一個實作了中介者(Mediator)模式的事件訂閱發佈框架,非常適合用於模組之間以及模組與主程式之間的通訊。
本文重點是介紹 MediatR,它是 .NET 中的開源簡單中介者模式實作。它透過一種程序內訊息傳遞機制(無其他外部依賴),進行請求/回應、命令、查詢、通知和事件的消息傳遞,並透過泛型來支援消息的智慧排程。開源庫位址是 https://github.com/jbogard/MediatR。
本文將詳細介紹如何在 Avalonia 專案中使用 MediatR 和 Microsoft 的依賴注入(MS.DI)函式庫來實現事件驅動的通訊。

0. 基礎知識準備-MediatR 的基本用法
MediatR 中有兩種訊息傳遞的方式:
Request/Response,用於單獨的 Handler。Notification,用於多個 Handler。
Request/Response
Request/Response 有點類似於 HTTP 的 Request/Response,發出一個 Request 會得到一個 Response。
Request 訊息在 MediatR 中,有兩種型別:
IRequest<T>回傳一個 T 類型的值。IRequest不回傳值。
對於每個 request 類型,都有相應的 handler 介面:
IRequestHandler<T, U>實作該介面並回傳Task<U>RequestHandler<T, U>繼承該類別並回傳UIRequestHandler<T>實作該介面並回傳Task<Unit>AsyncRequestHandler<T>繼承該類別並回傳TaskRequestHandler<T>繼承該類別不回傳
Notification
Notification 就是通知,呼叫者發出一次,然後可以有多個處理者參與處理。

1. 準備工作
首先,確保你的 Avalonia 專案中已經安裝了必要的 NuGet 套件。你將需要 Prism.DryIoc.Avalonia 作為依賴注入容器,以及 MediatR 來處理事件的發佈和訂閱。此外,為了將 MediatR 整合到 DryIoc 容器中,你還需要 DryIoc.Microsoft.DependencyInjection 套件(這裡感謝網友 寒 提供的技術解答)。
在專案的 .csproj 檔案或 NuGet 套件管理員中加入以下參考:
<PackageReference Include="Prism.DryIoc.Avalonia" Version="8.1.97.11072" />
<PackageReference Include="MediatR" Version="12.2.0" />
<PackageReference Include="DryIoc.Microsoft.DependencyInjection" Version="8.0.0-preview-01" />
2. 設定容器和註冊服務
在 Avalonia 專案中,你需要設定 DryIoc 容器以使用 Microsoft 的 DI 擴充,並註冊 MediatR 服務。這通常在你的主啟動類別(如 App.axaml.cs)中完成。
以下是設定容器和註冊服務的範例程式碼:
namespace CodeWF.Tools.Desktop;
public class App : PrismApplication
{
// 省略了模組注入等與主題無關的程式碼,有興趣原始碼在文末可查
/// <summary>
/// 1、DryIoc.Microsoft.DependencyInjection 低版本可不要這個方法(5.1.0 及以下)
/// 2、高版本必須,否則會拋出例外:System.MissingMethodException:“Method not found: 'DryIoc.Rules DryIoc.Rules.WithoutFastExpressionCompiler()'.”
/// 參考 issues:https://github.com/dadhi/DryIoc/issues/529
/// </summary>
/// <returns></returns>
protected override Rules CreateContainerRules()
{
return Rules.Default.WithConcreteTypeDynamicRegistrations(reuse: Reuse.Transient)
.With(Made.Of(FactoryMethod.ConstructorWithResolvableArguments))
.WithFuncAndLazyWithoutRegistration()
.WithTrackingDisposableTransients()
//.WithoutFastExpressionCompiler()
.WithFactorySelector(Rules.SelectLastRegisteredFactory());
}
protected override IContainerExtension CreateContainerExtension()
{
IContainer container = new Container(CreateContainerRules());
container.WithDependencyInjectionAdapter();
return new DryIocContainerExtension(container);
}
protected override void RegisterRequiredTypes(IContainerRegistry containerRegistry)
{
base.RegisterRequiredTypes(containerRegistry);
IServiceCollection services = ConfigureServices();
IContainer container = ((IContainerExtension<IContainer>)containerRegistry).Instance;
container.Populate(services);
}
private static ServiceCollection ConfigureServices()
{
var services = new ServiceCollection();
// 注入 MediatR
var assemblies = AppDomain.CurrentDomain.GetAssemblies().ToList();
// 加入模組注入,未顯示呼叫模組類型前,模組組件是未載入到目前應用程式域`AppDomain.CurrentDomain`的
var assembly = typeof(SlugifyStringModule).GetAssembly();
assemblies.Add(assembly);
services.AddMediatR(configure =>
{
configure.RegisterServicesFromAssemblies(assemblies.ToArray());
});
return services;
}
}
在上面的程式碼中,我們覆寫了 CreateContainerRules、CreateContainerExtension 和 RegisterRequiredTypes 方法以設定 DryIoc 容器,並註冊了 MediatR 服務和相關處理程式。
請注意,在註冊 MediatR 服務時,我們從當前已載入的組件清單中尋找並註冊處理程式。如果模組是按需載入的,請確保在註冊處理程式之前已載入了相應的模組。
此外,我們還示範了如何手動加入模組組件到清單中以便註冊處理程式。這通常在你需要明確控制哪些模組和處理程式被註冊時很有用。但是,請注意,在大多數情況下,你可能希望使用更自動化的方式來載入和註冊模組及處理程式(例如,透過掃描特定目錄或使用慣例等)。這取決於你的具體需求和專案結構。
另外,請注意程式碼中的註解和說明,它們提供了有關每個步驟和設定的額外資訊。在實際專案中,你可能需要根據專案的實際情況和需求進行相應的調整和最佳化。例如,你可能需要處理循環依賴、設定範圍、使用攔截器或裝飾器等進階功能。這些都可以在 DryIoc 和 MediatR 的文件中找到更詳細的說明和範例。
3. MediatR 2 種傳遞方式
有了前面的基礎知識準備,我們加入類別庫專案 CodeWF.Tools.MediatR.Notifications,並加入請求定義(主專案及模組的回應處理程式需要實作):
public class TestRequest : IRequest<string>
{
public string? Args { get; set; }
}
加入通知定義:
public class TestNotification : INotification
{
public string? Args { get; set; }
}
請求和通知定義結構一樣(實作介面不同),只有一個字串屬性。
4. 加入處理程式
範例專案結構如下,因為該開源專案(文末連結)寫在站長的 AvaloniaUI 桌面工具專案,本文只關注如下圖 3 個專案即可:

在 AvaloniaUI 主專案 (CodeWF.Tools.Desktop) 加入請求回應處理程式:
public class TestHandler : IRequestHandler<TestRequest, string>
{
public async Task<string> Handle(TestRequest request, CancellationToken cancellationToken)
{
return await Task.FromResult($"主專案處理程式:Args = {request.Args}, Now = {DateTime.Now}");
}
}
加入通知回應處理程式:
public class TestNotificationHandler(INotificationService notificationService) : INotificationHandler<TestNotification>
{
public Task Handle(TestNotification notification, CancellationToken cancellationToken)
{
notificationService.Show("Notification",
$"主專案 Notification 處理程式:Args = {notification.Args}, Now = {DateTime.Now}");
return Task.CompletedTask;
}
}
在模組【CodeWF.Tools.Modules.SlugifyString】中加入請求回應處理程式 (因為順序關係,不會觸發,這裡加入只是示範請求為一對一回應):
public class TestHandler : IRequestHandler<TestRequest, string>
{
public async Task<string> Handle(TestRequest request, CancellationToken cancellationToken)
{
return await Task.FromResult($"模組【SlugifyString】Request 處理程式:Args = {request.Args}, Now = {DateTime.Now}");
}
}
加入通知回應處理程式 (會和主專案通知回應處理程式一樣被觸發):
public class TestNotificationHandler(INotificationService notificationService) : INotificationHandler<TestNotification>
{
public Task Handle(TestNotification notification, CancellationToken cancellationToken)
{
notificationService.Show("Notification",
$"模組【SlugifyString】Notification 處理程式:Args = {notification.Args}, Now = {DateTime.Now}");
return Task.CompletedTask;
}
}
幾個回應處理程式類別定義類似:收到請求時,回傳格式化字串;收到通知時,彈出提示表明目前是哪個位置收到的通知,便於示範效果。
5. 請求和通知示範
觸發操作我們寫在模組【CodeWF.Tools.Modules.SlugifyString】中,在模組的 ViewModel 類別裡透過依賴注入取得請求和通知的發送者實例 ISender 和 IPublisher:
using Unit = System.Reactive.Unit;
namespace CodeWF.Tools.Modules.SlugifyString.ViewModels;
public class SlugifyViewModel : ViewModelBase
{
// 省略別名轉換相關邏輯程式碼,原始碼文末查看
private readonly INotificationService _notificationService;
private readonly IClipboardService? _clipboardService;
private readonly ITranslationService? _translationService;
public SlugifyViewModel(INotificationService notificationService, IClipboardService clipboardService,
ITranslationService translationService, ISender sender, IPublisher publisher) : base(sender, publisher)
{
_notificationService = notificationService;
_clipboardService = clipboardService;
_translationService = translationService;
KindChanged = ReactiveCommand.Create<TranslationKind>(OnKindChanged);
}
public async Task ExecuteMediatRRequestAsync()
{
var result = Sender.Send(new TestRequest() { Args = To });
_notificationService.Show("MediatR", $"收到回應:{result.Result}");
}
public async Task ExecuteMediatRNotificationAsync()
{
await Publisher.Publish(new TestNotification() { Args = To });
}
}
點選 測試 MediatR-Request 按鈕觸發呼叫 ISender.Send 發出請求並得到回應,透過點選 測試 MediatR-Notification 按鈕觸發呼叫 IPublisher.Publish 發出通知。
請求效果:

看上面的請求效果:雖然在主專案和模組專案都註冊了一個回應,但只有主專案被觸發。
通知效果:

在主專案和模組專案都註冊了一個通知回應,所以兩個處理程式都彈出了提示。
6. 總結
為什麼使用 MediatR,而未使用 Prism 的事件聚合器?
站長開發工具做了線上版 (https://blazor.dotnet9.com),也做了跨平台桌面版本(AvaloniaUI),兩個版本使用 MediatR 可以複用大部分事件程式碼。
CQRS or DDD?
這節直接複製 MediatR 在 .NET 應用中的實踐 - 明志唯新 (yimingzhi.net),大家應該可以學到些什麼:
軟體開發發展到今天,模式和理念不斷在架構中重新整理:從分散式到微服務,再到雲原生 ……。時代對一個程式設計師,尤其是伺服器端程式設計師,提出的要求越來越高。DDD(領域驅動設計)在微服務架構中一再被提及,甚至有人提出這是必須項!
實施一個完美的 DDD 還是有難度的,現實中奮戰在一線的 CRUD 程式設計師還是不少。那麼在 CRUD 和 DDD 之間我們是否還有緩衝區呢?MediatR 的作者曾經也撰文討論過這個問題,我很認同他的基本觀點:設計是為應用服務的,不能為了 DDD 而 DDD。
CQRS 的全稱是:"Command and Query Responsibility Segregation",直譯過來就是命令與查詢責任分離,可以通俗的理解為 讀寫分離。

微軟的官方文件中對此做過如下陳述:
CQRS 命令和查詢責任分離資料儲存的讀取和更新操作分離的模式。 在應用程式中實作 CQRS 可以最大程度地提高其效能、可擴縮性和安全性。 透過遷移到 CQRS 而建立的靈活性使系統能夠隨著時間的推移更好地發展,並防止更新命令在域層級導致合併衝突。
微軟也給出了相應的隔離模型解決方案:
CQRS 使用命令來更新資料,使用查詢來讀取資料,將讀取和寫入 分離到不同的 模型中。
- 命令應基於任務,而不是以資料為中心。
- 命令可以放置在佇列中進行非同步處理,而不是同步處理。
- 查詢絕不修改資料庫。 查詢傳回的 DTO 不封裝任何領域知識。

CQRS 的好處包括:
- 獨立縮放: CQRS 允許讀取和寫入工作負載獨立縮放,這可能會減少鎖競爭。
- 最佳化的資料架構: 讀取端可使用針對查詢最佳化的架構,寫入端可使用針對更新最佳化的架構。
- 安全性: 更輕鬆地確保僅正確的領域實體對資料執行寫入操作。
- 關注點分離: 分離讀取和寫入端可使模型更易維護且更靈活。 大多數複雜的業務邏輯被分到寫模型。 讀模型會變得相對簡單。
- 查詢更簡單: 透過將具體化檢視儲存在讀取資料庫中,應用程式可在查詢時避免複雜的聯結。
有了 MediatR 我們可以在應用中輕鬆實作 CQRS:
IRequest<>的訊息名稱以Command為結尾的是命令,其對應的 Handler 執行寫任務IRequest<>的訊息名稱以Query為結尾的是查詢,其對應的 Handler 執行讀資料
結束語
MediatR 是一個簡單的中介者實作,可以極大降低我們的應用複雜度,也能夠使我們一路從 CRUD 到 CQRS 到 DDD 進行逐級演進。畢竟我們是生活在現實中的人,不能罔顧商業現實,純粹一味追求技術。

商業技術的演進,應該是一路持續的改革而不是來一場革命。疫情總有反覆,但是我們得活著,相對輕鬆的活著!
參考
文中範例寫了主要程式碼,但可能缺少部分細節,原始碼連結如下,歡迎留言交流。
本文原始碼:GitHub