CodeWF.EventBus:軽量イベントバス、コミュニケーションをよりスムーズに

CodeWF.EventBus:軽量イベントバス、コミュニケーションをよりスムーズに

CodeWF.EventBusは、モジュール間の疎結合通信を実現する柔軟なイベントバスライブラリです。WPF、WinForms、ASP.NET Coreなど、さまざまな.NETプロジェクトタイプに対応しています。シンプルな設計で、コマンドのパブリッシュとサブスクライブ、リクエストとレスポンスを簡単に実装できます。順序付けられたイベント処理により、イベントが適切に処理されることを保証します。コードを簡素化し、システムの保守性を向上させます。

最終更新 2024/06/20 13:02
沙漠尽头的狼
読了目安 11 分
カテゴリ
.NET
タグ
.NET C# ASP.NET Core WPF Winform

1. はじめに

イベントバス(EventBus)は、モジュール間の通信を疎結合にする強力なツールです。CodeWF.EventBus では、CQRS パターンを簡単に実装し、明確でシンプルなインターフェースを通じてイベントの購読と発行を行うことができます。以下では、このライブラリを使用してイベントを処理する方法について詳しく説明します。

CQRS(Command Query Responsibility Segregation)は、システム内のコマンド(書き込み操作)とクエリ(読み取り操作)の責務を分離することで、パフォーマンス、スケーラビリティ、応答性を向上させることを目的としたソフトウェアアーキテクチャパターンです。

CodeWF.EventBus は、プロセス内イベント配信(外部依存なし)に適しており、MediatR と同様の機能を提供します。MediatRASP.NET Core 向けに設計されており、より強力な機能を持ちますが、CodeWF.EventBus には以下の利点があります。

  1. コンパクトで柔軟性が高く、WPF、WinForms、Avalonia UI、ASP.NET Core など、さまざまなテンプレートプロジェクトで使用できます。
  2. 任意の IOC コンテナを使用するプロジェクトをサポートします。
  3. MASA Framework を参考にしたイベント処理機能の強化により、1 つのクラスで複数のイベント処理メソッドを定義できます。

2. 使用方法

2.1. イベントバスの登録

2.1.1. MS.DI コンテナ

主に ASP.NET Core アプリケーション(MVC、Razor Pages、Blazor Server など)を対象としています。NuGet パッケージ CodeWF.AspNetCore.EventBus を検索して最新版をインストールし、Program に以下のコードを追加します。

// ...

// 1. イベントバスを登録し、`EventHandler` 属性が付いたメソッドを持つクラスをシングルトンとして IOC コンテナに注入します
builder.Services.AddEventBus();

var app = builder.Build();

// ...

// 2. 上記で IOC コンテナに注入したクラスを取得し、処理メソッドをイベントバス管理に関連付けます
app.UseEventBus();

// ...
  • AddEventBus メソッドは、渡されたアセンブリリストをスキャンし、Event 属性を持つクラスの下に EventHandler 属性が付いたメソッドを持つクラスをシングルトンとして IOC コンテナに注入します。
  • UseEventBus メソッドは、前の手順で注入したクラスを IOC コンテナからインスタンス化し、それらのインスタンスのイベント処理メソッドをイベント管理キューに登録します。イベントが発行されると、イベント管理キューから該当するイベント処理メソッドを検索して呼び出し、イベント通知機能を実現します。

2.1.2. DryIOC コンテナ

DryIoc コンテナを使用している場合(例えば、WPF / Avalonia UI で Prism フレームワークの DryIoc コンテナを使用している場合)、NuGet パッケージ CodeWF.DryIoc.EventBus を検索して最新版をインストールし、RegisterTypes メソッドに以下のコードを追加します。

protected override void RegisterTypes(IContainerRegistry containerRegistry)
{
    IContainer? container = containerRegistry.GetContainer();

    // ...

    // Register EventBus
    containerRegistry.AddEventBus();

    // ...

    // Use EventBus
    container.UseEventBus();
}

2.1.3. 任意の IOC コンテナ

他の IOC コンテナを使用しているプロジェクトの場合は、NuGet パッケージ CodeWF.IOC.EventBus を検索して最新版をインストールし、IOC コンテナのシングルトン登録やサービス取得の API に応じて適宜修正してください。

上記の ASP.NET Core の例は、イベントバスの登録を以下のように変更することもできます。

// ...

// 1. イベントバスを登録し、`EventHandler` 属性が付いたメソッドを持つクラスをシングルトンとして IOC コンテナに注入します
EventBusExtensions.AddEventBus(
    (t1, t2) => builder.Services.AddSingleton(t1, t2),
    t => builder.Services.AddSingleton(t),
    Assembly.GetExecutingAssembly());

var app = builder.Build();

// ...

// 2. 上記で IOC コンテナに注入したクラスを取得し、処理メソッドをイベントバス管理に関連付けます
EventBusExtensions.UseEventBus(t => app.Services.GetRequiredService(t), Assembly.GetExecutingAssembly());

// ...

任意の IOC コンテナをサポートする原理は、AddEventBusUseEventBus メソッドにあります。

using CodeWF.EventBus;
using System.Reflection;

namespace CodeWF.IOC.EventBus
{
    public static class EventBusExtensions
    {
        public static void AddEventBus(Action<Type, Type> addSingleton1,
            Action<Type> addSingleton2, params Assembly[] assemblies)
        {
            addSingleton1(typeof(IEventBus), typeof(CodeWF.EventBus.EventBus));

            var allAssemblies = assemblies.Concat(new[] { Assembly.GetCallingAssembly() }).ToArray();

            CodeWF.EventBus.EventBusExtensions.HandleEventObject(type => addSingleton2(type),
                BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic,
                allAssemblies);
        }

        public static void UseEventBus(Func<Type, object> resolveAction, params Assembly[] assemblies)
        {
            if (!(resolveAction(typeof(IEventBus)) is IEventBus messenger))
            {
                throw new InvalidOperationException("Please call AddEventBus before calling UseEventBus");
            }

            var allAssemblies = assemblies.Concat(new[] { Assembly.GetCallingAssembly() }).ToArray();

            CodeWF.EventBus.EventBusExtensions.HandleEventObject(
                type => messenger.Subscribe(resolveAction(type)),
                BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, allAssemblies);

            CodeWF.EventBus.EventBusExtensions.HandleEventObject(type => messenger.Subscribe(type),
                BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic, allAssemblies);
        }
    }
}
using System;
using System.Linq;
using System.Reflection;

namespace CodeWF.EventBus
{
    public static class EventBusExtensions
    {
        public static void HandleEventObject(Action<Type> handleRecipient, BindingFlags findHandlerMethodBindingFlags,
            Assembly[] assemblies)
        {
            foreach (var assembly in assemblies)
            {
                var types = assembly.GetTypes()
                    .Where(t => t.IsClass
                                && !t.IsAbstract
                                && t.GetCustomAttributes<EventAttribute>().Any()
                                && t.GetMethods(findHandlerMethodBindingFlags)
                                    .Any(m =>
                                        m.GetCustomAttributes<EventHandlerAttribute>().Any()));

                foreach (var type in types)
                {
                    handleRecipient(type);
                }
            }
        }
    }
}

2.1.4. IOC コンテナを使用しない場合

デフォルトの WPF、WinForms、Avalonia UI、コンソールアプリケーションには IOC コンテナが導入されていません。そのようなプロジェクトでは、イベントサービス登録操作は必要ありません。

NuGet パッケージ CodeWF.EventBus を検索して最新版をインストールすると、機能の使用は IOC コンテナを使用した場合と同じですが、IOC 注入による自動購読機能が不足しています。具体的な違いについては、以下を参照してください。

2.2. イベントの定義

ここでは、CQRS を使用してアプリケーションのビジネスロジックを実装します。CQRS パターンでは、クエリとその他のビジネス操作は分離されています。CQRS について詳しく知りたい場合は、こちらの記事を参照してください:https://learn.microsoft.com/ja-jp/azure/architecture/patterns/cqrs

2.2.1. コマンドの定義

CQRS パターンでは、コマンドは書き込み操作を表します。Command クラスを継承したコマンドクラスを定義します。

public class CreateProductCommand : Command
{
    public string Name { get; set; }
    public decimal Price { get; set; }
}
public class CreateProductSuccessCommand : Command
{
    public string Name { get; set; }
    public decimal Price { get; set; }
}
public class DeleteProductCommand : Command
{
    public Guid ProductId { get; set; }
}

2.2.2. クエリの定義

CQRS パターンでは、クエリは読み取り操作を表します。クエリは応答を待つ必要があり、リクエスト/レスポンスに適しています。クエリを使用することで、呼び出し側は ProductQueryProductsQuery を使用するだけでよく、IProductServiceICategoryService などのサービスを意識する必要がありません。

Query<T> を継承したクエリクラスを定義します。

public class ProductQuery : Query<ProductItemDto>
{
    public Guid ProductId { get; set; }
    public override ProductItemDto Result { get; set; }
}
public class ProductsQuery : Query<List<ProductItemDto>>
{
    public string Name { get; set; }
    public override List<ProductItemDto> Result { get; set; }
}

Query<T> の T はクエリの応答結果の型を表し、XXXQuery では Result プロパティを使用してクエリ発行後に得られた結果を表します。

QueryCommand を継承し、Result プロパティを持ちます。

public abstract class Query<TResult> : Command
{
    public abstract TResult Result { get; set; }
}

2.3. イベントの購読(Subscribe)

2.3.1. 自動購読

自動購読 は、IOC コンテナを使用するプログラム(例:ASP.NET Core プログラム)でのみ使用できます。

通常、イベントハンドラは専用のクラスにカプセル化します。コードは以下の通りです。

[Event]
public class CommandAndQueryHandler(IEventBus eventBus, IProductService productService)
{
    [EventHandler]
    private async Task ReceiveCreateProductCommandAsync(CreateProductCommand command)
    {
        var isAddSuccess = await productService.AddProductAsync(new CreateProductRequest()
            { Name = command.Name, Price = command.Price });
        if (isAddSuccess)
        {
            await eventBus.PublishAsync(new CreateProductSuccessCommand()
                { Name = command.Name, Price = command.Price });
        }
        else
        {
            Console.WriteLine("Create product fail");
        }
    }

    [EventHandler(Order = 2)]
    private async Task ReceiveCreateProductSuccessCommandSendEmailAsync(CreateProductSuccessCommand command)
    {
        Console.WriteLine($"Now send email notify create product success, name is = {command.Name}");
        await Task.CompletedTask;
    }

    [EventHandler(Order = 1)]
    private async Task ReceiveCreateProductSuccessCommandSendSmsAsync(CreateProductSuccessCommand command)
    {
        Console.WriteLine($"Now send sms notify create product success, name is = {command.Name}");
        await Task.CompletedTask;
    }

    [EventHandler(Order = 3)]
    private void ReceiveCreateProductSuccessCommandCallPhone(CreateProductSuccessCommand command)
    {
        Console.WriteLine($"Now call phone notify create product success, name is = {command.Name}");
    }

    [EventHandler]
    private async Task ReceiveDeleteProductCommandAsync(DeleteProductCommand command)
    {
        var isRemoveSuccess = await productService.RemoveProductAsync(command.ProductId);
        Console.WriteLine(isRemoveSuccess ? "Remote product success" : "Remote product fail");
    }

    [EventHandler]
    private async Task ReceiveProductQueryAsync(ProductQuery query)
    {
        var product = await productService.QueryProductAsync(query.ProductId);
        query.Result = product;
    }

    [EventHandler]
    private async Task ReceiveAutoProductsQueryAsync(ProductsQuery query)
    {
        var products = await productService.QueryProductsAsync(query.Name);
        query.Result = products;
    }

    [EventHandler]
    private static async Task ReceiveAutoProductsQueryAsync2(ProductsQuery query)
    {
        Console.WriteLine("Test auto subscribe static method");
    }
}
  • クラス CommandAndQueryHandler には Event 属性が付与されており、IOC コンテナに注入されるとシングルトンとして注入できることを示します。
  • EventHandler 属性が付いたメソッドはイベント処理能力を持ち、そのメソッドは 1 つのイベント型パラメータのみを持つことができます。メソッドが非同期をサポートする場合も、戻り値は Task のみで、ジェネリック宣言はできません(追加しても無効)。静的イベント処理メソッドもサポートします。

IOC コンテナを使用するプログラムでは、Event 属性が付いたクラスが自動的にシングルトンとしてコンテナに注入され、イベントバスがイベント通知を受け取ると、自動的に EventHandler 属性が付いたメソッドを検索して呼び出し、イベント通知機能を実現します。

2.3.2. 手動購読

Event 属性が付いていないクラスの場合は、イベントハンドラを手動で登録できます。以下は、IOC コンテナを使用しない場合の手動登録の例です(核となるのは EventBus.Default の使用)。

internal class CommandAndQueryHandler
{
    internal void ManuSubscribe()
    {
        EventBus.Default.Subscribe<DeleteProductCommand>(ReceiveDeleteProductCommandAsync);
        EventBus.Default.Subscribe<ProductQuery>(ReceiveProductQueryAsync);
        EventBus.Default.Subscribe<ProductsQuery>(ReceiveAutoProductsQueryAsync2);
    }

    public async Task ReceiveDeleteProductCommandAsync(DeleteProductCommand command)
    {
    }

    public async Task ReceiveProductQueryAsync(ProductQuery query)
    {
    }

    private static async Task ReceiveAutoProductsQueryAsync2(ProductsQuery query)
    {
    }
}

上記のように 1 つずつハンドラを登録するのは冗長な場合があります。以下のように簡略化できます。

internal class CommandAndQueryHandler
{
    internal CommandAndQueryHandler()
    {
        EventBus.Default.Subscribe(this);
    }

    [EventHandler(Order = 2)]
    public async Task ReceiveCreateProductSuccessCommandSendEmailAsync(CreateProductSuccessCommand command)
    {
    }

    // ... その他多数のイベント処理メソッドを省略。EventBus.Default.Subscribe(this) メソッドで自動バインドされます。
}

IOC コンテナを使用している場合は、EventBus.Default の代わりに IEventBus サービスを注入して使用できます。以下のサンプルコードを参照してください。

public class EventBusTestViewModel : ViewModelBase
{
    private readonly IEventBus _eventBus;

    public MessageTestViewModel(IEventBus eventBus)
    {
        _eventBus = eventBus;
        _eventBus.Subscribe(this);
    }
    
    [EventHandler]
    public async Task ReceiveDeleteProductCommandAsync(DeleteProductCommand command)
    {
        var isRemoveSuccess = await productService.RemoveProductAsync(command.ProductId);
        Console.WriteLine(isRemoveSuccess ? "Remote product success" : "Remote product fail");
    }
}

EventBusIEventBus インターフェースのデフォルト実装であり、EventBus.Default はシングルトン参照です。そのため、どちらを使用しても同じです。IOC 注入時にはデフォルトで IEventBusEventBus がシングルトンとして注入されるため、両者は同等です。

手動購読は、WPF の XxxViewModel 内(上記コードの通り)や、IOC の他のライフサイクルのサービス内でも使用できます。

public class TimeService : ITimeService
{
    private readonly IEventBus _eventBus;

    public TimeService(IEventBus eventBus)
    {
        _eventBus = eventBus;
        _eventBus.Subscribe(this);
    }
    
	[EventHandler]
    public async Task ReceiveDeleteProductCommandAsync(DeleteProductCommand command)
    {
        var isRemoveSuccess = await productService.RemoveProductAsync(command.ProductId);
        Console.WriteLine(isRemoveSuccess ? "Remote product success" : "Remote product fail");
    }
}

手動登録は、シングルトン注入ができない、または不要な場合に使用でき、特殊な状況を補完します。

2.4. イベントの発行

コマンド(Command)とクエリ(Query)の発行には同じインターフェースを使用し、IEventBus または EventBus.DefaultPublish および PublishAsync メソッドを使用します。

_messenger.Publish(this, new DeleteProductCommand { ProductId = id });
var query = new ProductQuery { ProductId = id };
await _messenger.PublishAsync(this, query);
Console.WriteLine($"クエリ結果:製品ID {id} の製品は {query.Result} です");

B/S コントローラーの Action で発行する場合:

[ApiController]
[Route("[controller]")]
public class EventController : ControllerBase
{
    private readonly ILogger<EventController> _logger;
    private readonly IEventBus _eventBus;

    public EventController(ILogger<EventController> logger, IEventBus eventBus)
    {
        _logger = logger;
        _eventBus = eventBus;
    }

    [HttpPost("/add")]
    public async Task AddAsync([FromBody] CreateProductRequest request)
    {
        await _eventBus.PublishAsync(new CreateProductCommand { Name = request.Name, Price = request.Price });
    }

    [HttpDelete("/delete")]
    public async Task DeleteAsync([FromQuery] Guid id)
    {
        await _eventBus.PublishAsync(new DeleteProductCommand { ProductId = id });
    }

    [HttpGet("/get")]
    public async Task<ProductItemDto> GetAsync([FromQuery] Guid id)
    {
        var query = new ProductQuery { ProductId = id };
        await _eventBus.PublishAsync(query);
        return query.Result;
    }

    [HttpGet("/list")]
    public async Task<List<ProductItemDto>> ListAsync([FromQuery] string? name)
    {
        var query = new ProductsQuery { Name = name };
        await _eventBus.PublishAsync(query);
        return query.Result;
    }
}

WPF/Avalonia UIXXXViewModel での使用:

public class EventBusTestViewModel : ViewModelBase
{
    private readonly IEventBus _eventBus;

    public MessageTestViewModel(IEventBus eventBus)
    {
        _eventBus = eventBus;
    }

    public async Task ExecuteEventBusAsync()
    {
        await _eventBus.PublishAsync(this, new TestMessage(nameof(MessageTestViewModel), TestClass.CurrentTime()));
    }
}

2.5. イベントの購読解除

実際のアプリケーションでは、メモリリークを防ぐために、適切なタイミング(例:サービスの破棄時)で購読を解除する必要がある場合があります。

  1. 特定のハンドラを解除:Messenger.Default.Unsubscribe<CreateProductMessage>(this, ReceiveManuCreateProductMessage)
  2. 指定クラスのすべてのハンドラを解除:Messenger.Default.Unsubscribe(this)

3. コアインターフェースの説明

public interface IEventBus
{
    void Subscribe<T>() where T : class;
    void Subscribe(Type type);
    void Subscribe(object recipient);
    void Subscribe<TCommand>(Action<TCommand> action) where TCommand : Command;
    void Subscribe<TCommand>(Func<TCommand, Task> asyncAction) where TCommand : Command;
    void Unsubscribe<T>() where T : class;
    void Unsubscribe(object recipient);
    void Unsubscribe<TCommand>(Action<TCommand> action) where TCommand : Command;
    void Unsubscribe<TCommand>(Func<TCommand, Task> asyncAction) where TCommand : Command;
    void Publish<TCommand>(TCommand command) where TCommand : Command;
    Task PublishAsync<TCommand>(TCommand command) where TCommand : Command;
}
  • Subscribe<T>():クラス内の静的イベント処理メソッドを購読します。
  • Subscribe(Type type):指定したクラス型の静的イベント処理メソッドを購読します。
  • Subscribe(object recipient):指定したインスタンスのメンバーイベント処理メソッドを購読します。
  • Subscribe<TCommand>(Action<TCommand> action):通常のイベント処理メソッド(静的イベント処理メソッドを含む)を購読します。
  • Subscribe<TCommand>(Func<TCommand, Task> asyncAction):非同期イベント処理メソッド(静的非同期イベント処理メソッドを含む)を購読します。
  • Unsubscribe<T>():クラス内の静的イベント処理メソッドの購読を解除します。
  • Unsubscribe(object recipient):指定したインスタンスのメンバーイベント処理メソッドの購読を解除します。
  • Unsubscribe<TCommand>(Action<TCommand> action):通常のイベント処理メソッド(静的イベント処理メソッドを含む)の購読を解除します。
  • Unsubscribe<TCommand>(Func<TCommand, Task> asyncAction):非同期イベント処理メソッド(静的非同期イベント処理メソッドを含む)の購読を解除します。
  • Publish<TCommand>(TCommand command):コマンド(Command)またはクエリ(Query)を同期的に発行します。
  • PublishAsync<TCommand>(TCommand command):コマンド(Command)またはクエリ(Query)を非同期的に発行します。

4. まとめ

CodeWF.EventBus は、小型で柔軟なイベントバスの実装を提供し、CQRS パターンをサポートし、Avalonia UI、WPF、WinForms、ASP.NET Core など、さまざまなプロジェクトテンプレートに適用できます。シンプルな購読と発行操作により、モジュール間の疎結合と通信を簡単に実現できます。順序付けられたイベント処理により、イベントが適切に処理されることを保証します。

イベントバスの具体的な実装は、CodeWF.EventBus のソースコードを参照してください: https://github.com/dotnet9/CodeWF.EventBus 。具体的な使用方法の参考:

  1. ユニットテスト:CodeWF.EventBus.Tests
  2. AvaloniaUI + Prism:CodeWF.EventBus
  3. Web API:WebAPIDemoCodeWF

開発の参考としたオープンソースプロジェクト:

  1. Messenger | MvvmCross
  2. Prism.Events
  3. MediatR
  4. MASA Framework

このガイドが、CodeWF.EventBus を使用してアプリケーション内のイベントを処理する際の助けになれば幸いです。

さらに探索

関連読書

その他の記事
同じカテゴリ / 同じタグ 2025/05/27

WPFで危険警告効果を実現する

作成したプログラムをユーザーに配布した後、ユーザーが危険な操作を行っている場合、ソフトウェアは警告効果を表示する必要があります。例えば、フレームの端が赤くなるような、高徳地図のような警告効果です。

続きを読む