← 回所有專案

Side Project ‧ 開源函式庫 · 2023

EventBus(.NET)

一個輕量的 Pub/Sub 抽象層,已發布為 NuGet 套件。設計上比照 .NET 的 ServiceCollection,多做了一個 SubscriptionCollection 管理訂閱者,並以反射自動找出所有實作 IEventHandler<T> 的類別;同一組 API 可在記憶體與 RabbitMQ(EasyNetQ)之間直接切換,並支援以 Attribute 宣告事件的 Exchange、優先權與版本。

.NET 8Pub/SubRabbitMQEasyNetQDependency InjectionReflectionxUnitNuGet
EventBus(.NET)

專案概述

一個小型開源函式庫,把 .NET 中的事件發布與訂閱(Pub/Sub)抽象化:應用程式 只需要面對 IEventBus 和 IEventHandler<T> 兩個介面,不需要在意事件是透過記 憶體派送,還是透過 RabbitMQ 傳遞——換傳輸方式只需要換一行 DI 設定,不需要改 任何業務程式碼。

已透過 GeneratePackageOnBuild 發布成三個獨立的 NuGet 套件:

Shell
Install-Package EventBus -Version 0.0.1-beta            # 核心介面
Install-Package EventBus.InMemory -Version 0.0.1-beta    # 記憶體傳輸
Install-Package EventBus.RabbitMQ -Version 0.0.1-beta    # RabbitMQ 傳輸(底層為 EasyNetQ)

為什麼做這個

在事件驅動的專案裡,Pub/Sub 幾乎每個服務都會用到,但每次都直接寫 RabbitMQ 的程 式碼,業務邏輯就會和訊息佇列綁在一起:開發、單元測試時要先架好一台 RabbitMQ, 想換成別的傳輸方式也要整個重寫。所以我想要的是一個抽象化的 Pub/Sub——業務 程式碼只說「發布這個事件」「我要處理這個事件」,至於事件怎麼送過去,交給底層決 定。

市面上當然有現成的選擇,例如 Azure Service Bus 的 SDK、MassTransit,但實際評估 後發現它們對我的需求來說都做得太複雜了:

  • 功能涵蓋 Saga、排程訊息、請求/回應、多種傳輸的細部設定……真正會用到的只有 「發布」和「訂閱」,卻得先理解一整套框架的概念與設定方式
  • 框架本身的抽象層很厚,出問題時要追到框架內部才知道訊息卡在哪裡
  • 團隊裡的每個人都要先學會這套框架,才能開始寫一個簡單的事件處理

我需要的其實只是一層很薄的抽象:介面夠少、行為好預測、程式碼讀得完。所以就 自己做了一個,把實務上反覆用到的 Pub/Sub 模式抽出來,發布成獨立的函式庫,之後的 專案直接引用。

設計概念:比照 ServiceCollection

EventBus 訂閱機制示意圖:啟動時 AddEventHandlersFromAssembly 以反射掃描組件,找出實作 IEventHandler 泛型介面的類別並取出事件型別,登記到 SubscriptionCollection;執行時 IEventBus 發布事件經過傳輸層,背景服務依事件型別查表,從 DI 取出對應的 Handler 並呼叫 HandleAsync

.NET 開發者每天都在用 IServiceCollection:啟動時把服務註冊進去,執行時由 ServiceProvider 依型別解析出實例。我希望訂閱事件的方式也一樣直覺,所以比照 ServiceCollection 的設計,多做了一個 SubscriptionCollection 專門管理訂閱 者:

ServiceCollectionSubscriptionCollection
登記的是什麼服務型別 → 實作型別事件型別 → 訂閱者(Handler)型別
什麼時候登記應用程式啟動時應用程式啟動時
什麼時候用到需要服務時解析出實例收到事件時找出要通知哪些 Handler
註冊方式services.AddScoped<IFoo, Foo>()s.AddEventHandlersFromAssembly()

使用者寫起來就和註冊一般服務沒有兩樣:

C#
builder.Services.AddEventBus(o => o
    .UseSubscriptions(s => s.AddEventHandlersFromAssembly())
    .UseInMemory());

SubscriptionCollection 只負責記錄「哪個事件要交給哪些 Handler」,Handler 本 身仍然註冊在 .NET 原本的 IServiceCollection 裡。收到事件時,先查 SubscriptionCollection 找出 Handler 型別,再從 DI 建立實例——所以 Handler 一 樣可以用建構子注入 Repository、Logger 等任何服務,生命週期也交給 DI 管理。

以反射自動找出訂閱者

如果每新增一個事件處理器,都要回到 Program.cs 手動寫一行 Subscribe,久了一 定會有人忘記。所以訂閱的登記交給反射自動完成:

C#
// 示意:掃描組件,找出所有實作 IEventHandler<T> 的類別
public SubscriptionCollection AddEventHandlersFromAssembly(Assembly assembly)
{
    var handlers = assembly.GetTypes()
        .Where(t => t is { IsClass: true, IsAbstract: false })
        .SelectMany(t => t.GetInterfaces()
            .Where(i => i.IsGenericType &&
                        i.GetGenericTypeDefinition() == typeof(IEventHandler<>))
            .Select(i => (EventType: i.GetGenericArguments()[0], HandlerType: t)));

    foreach (var (eventType, handlerType) in handlers)
    {
        Add(eventType, handlerType);              // 登記到 SubscriptionCollection
        _services.AddScoped(handlerType);         // Handler 本身交給 DI 管理
    }

    return this;
}

流程是:

  1. 列出組件中所有非抽象的類別
  2. 檢查它實作的介面,找出 IEventHandler<> 這個泛型介面
  3. 用 GetGenericArguments() 取出泛型參數 T,也就是它要處理的事件型別
  4. 把「事件型別 → Handler 型別」登記到 SubscriptionCollection,同時把 Handler 註冊進 DI

一個類別也可以同時實作多個 IEventHandler<T>,處理多種事件;同一個事件也可以有 多個 Handler 訂閱,發布時會逐一通知。反射只在啟動時跑一次,執行期間派送事件 只是查表,不會有額外的效能負擔。

使用方式

註冊(記憶體版):適合單一服務內的事件,或開發、測試時使用

C#
builder.Services.AddEventBus(o => o.UseInMemory());

註冊(RabbitMQ 版):跨服務傳遞事件

C#
builder.Services.AddEventBus(o => o.UseRabbitMQ());
JSON
{
  "RabbitMqOption": {
    "ExchangeName": "sample.exchange",
    "QueueName": "sample.queue",
    "ConnectionString": "localhost:5672",
    "PublishRetryCount": 3,
    "SubscribeRetryTime": 3,
    "MaxPriority": 10,
    "ConsumePrefetchCount": 2
  }
}

發布事件

C#
app.MapGet("/weatherforecast", async (IEventBus eventBus) =>
{
    await eventBus.PublishAsync(new TestEvent { Message = "Test" });
});

訂閱事件:實作 IEventHandler<TEvent> 就好,啟動時會被自動掃描登記

C#
public class TestEventHandler : IEventHandler<TestEvent>
{
    public Task HandleAsync(TestEvent @event)
    {
        Console.WriteLine(@event.Message);
        return Task.CompletedTask;
    }
}

實作內容

  • 抽象化 IEventBus / IEventHandler<T> 介面,EventBus.InMemory 與 EventBus.RabbitMq 分別實作記憶體與 RabbitMQ(底層包裝 EasyNetQ)兩種傳輸
  • 比照 IServiceCollection 設計 SubscriptionCollection 管理訂閱者,並提供 AddEventHandlersFromAssembly() 以反射自動掃描、登記所有 IEventHandler<T>
  • RabbitMQ 版本支援以 Attribute 宣告事件的路由設定:[Exchange] 指定 Exchange 名稱與類型、[Event] 指定訊息優先權(Priority)與事件版本 (Version),路由設定跟著事件類別走,不用另外維護一份對照表
  • SubscriptionBackgroundService / SubscriberBackgroundService 以背景服務 持續消費訊息,BackgroundQueue 處理記憶體版本的非同步派送
  • 發布與消費都可設定重試次數,RabbitMQ 版本另外支援訊息優先權與 Prefetch 數量
  • 為兩種傳輸方式撰寫單元測試(EventBus.Tests、EventBus.InMemory.Tests)與 整合測試(EventBus.InMemory.IntegrationTests)

成果

  • 業務程式碼只依賴 IEventBus / IEventHandler<T>,傳輸方式從記憶體換成 RabbitMQ 只需要改一行設定
  • 單元測試與本機開發改用記憶體版本,不需要先架 RabbitMQ
  • 新增事件處理器只要實作介面,不需要再回頭修改註冊程式碼
  • 後來 電商微服務可觀測性專案 裡的事件驅動設計,也是以這套函式庫的概念為基礎