在Avalonia專案中使用MediatR和MS.DI庫實現事件驅動通訊

在Avalonia專案中使用MediatR和MS.DI庫實現事件驅動通訊

AvaloniaUI是一個強大的跨平台.NET用戶端開發框架,讓開發者能夠針對Windows、Linux、macOS、Android和iOS等多個平台建置應用程式。在建構複雜的應用程式時,模組化和元件間的通訊變得尤為重要。Prism框架提供了模組化的開發方式,支援外掛程式的熱插拔,而MediatR則是一個實現了中介者(Mediator)模式的事件訂閱發佈框架,非常適合用於模組之間以及模組與主程式之間的通訊。

最後更新 2024/3/2 下午3:45
沙漠尽头的狼
預計閱讀 12 分鐘
分類
Avalonia UI
標籤
.NET C# Avalonia UI Prism MediatR

大家好,我是沙漠盡頭的狼!

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> 繼承該類別並回傳 U
  • IRequestHandler<T> 實作該介面並回傳 Task<Unit>
  • AsyncRequestHandler<T> 繼承該類別並回傳 Task
  • RequestHandler<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;
    }
}

在上面的程式碼中,我們覆寫了 CreateContainerRulesCreateContainerExtensionRegisterRequiredTypes 方法以設定 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 是一個簡單的中介者實作,可以極大降低我們的應用複雜度,也能夠使我們一路從 CRUDCQRSDDD 進行逐級演進。畢竟我們是生活在現實中的人,不能罔顧商業現實,純粹一味追求技術。

商業技術的演進,應該是一路持續的改革而不是來一場革命。疫情總有反覆,但是我們得活著,相對輕鬆的活著!

參考

文中範例寫了主要程式碼,但可能缺少部分細節,原始碼連結如下,歡迎留言交流。

參考文章:MediatR 在 .NET 應用中的實踐

本文原始碼:GitHub

繼續探索

延伸閱讀

更多文章