Side Project ‧ 開源函式庫 · 2023
EventBus(.NET)
一個輕量的 Pub/Sub 抽象層,已發布為 NuGet 套件。設計上比照 .NET 的 ServiceCollection,多做了一個 SubscriptionCollection 管理訂閱者,並以反射自動找出所有實作 IEventHandler<T> 的類別;同一組 API 可在記憶體與 RabbitMQ(EasyNetQ)之間直接切換,並支援以 Attribute 宣告事件的 Exchange、優先權與版本。
專案概述
一個小型開源函式庫,把 .NET 中的事件發布與訂閱(Pub/Sub)抽象化:應用程式
只需要面對 IEventBus 和 IEventHandler<T> 兩個介面,不需要在意事件是透過記
憶體派送,還是透過 RabbitMQ 傳遞——換傳輸方式只需要換一行 DI 設定,不需要改
任何業務程式碼。
已透過 GeneratePackageOnBuild 發布成三個獨立的 NuGet 套件:
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
.NET 開發者每天都在用 IServiceCollection:啟動時把服務註冊進去,執行時由
ServiceProvider 依型別解析出實例。我希望訂閱事件的方式也一樣直覺,所以比照
ServiceCollection 的設計,多做了一個 SubscriptionCollection 專門管理訂閱
者:
| ServiceCollection | SubscriptionCollection | |
|---|---|---|
| 登記的是什麼 | 服務型別 → 實作型別 | 事件型別 → 訂閱者(Handler)型別 |
| 什麼時候登記 | 應用程式啟動時 | 應用程式啟動時 |
| 什麼時候用到 | 需要服務時解析出實例 | 收到事件時找出要通知哪些 Handler |
| 註冊方式 | services.AddScoped<IFoo, Foo>() | s.AddEventHandlersFromAssembly() |
使用者寫起來就和註冊一般服務沒有兩樣:
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,久了一
定會有人忘記。所以訂閱的登記交給反射自動完成:
// 示意:掃描組件,找出所有實作 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;
}
流程是:
- 列出組件中所有非抽象的類別
- 檢查它實作的介面,找出
IEventHandler<>這個泛型介面 - 用
GetGenericArguments()取出泛型參數T,也就是它要處理的事件型別 - 把「事件型別 → Handler 型別」登記到
SubscriptionCollection,同時把 Handler 註冊進 DI
一個類別也可以同時實作多個 IEventHandler<T>,處理多種事件;同一個事件也可以有
多個 Handler 訂閱,發布時會逐一通知。反射只在啟動時跑一次,執行期間派送事件
只是查表,不會有額外的效能負擔。
使用方式
註冊(記憶體版):適合單一服務內的事件,或開發、測試時使用
builder.Services.AddEventBus(o => o.UseInMemory());
註冊(RabbitMQ 版):跨服務傳遞事件
builder.Services.AddEventBus(o => o.UseRabbitMQ());
{
"RabbitMqOption": {
"ExchangeName": "sample.exchange",
"QueueName": "sample.queue",
"ConnectionString": "localhost:5672",
"PublishRetryCount": 3,
"SubscribeRetryTime": 3,
"MaxPriority": 10,
"ConsumePrefetchCount": 2
}
}
發布事件
app.MapGet("/weatherforecast", async (IEventBus eventBus) =>
{
await eventBus.PublishAsync(new TestEvent { Message = "Test" });
});
訂閱事件:實作 IEventHandler<TEvent> 就好,啟動時會被自動掃描登記
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
- 新增事件處理器只要實作介面,不需要再回頭修改註冊程式碼
- 後來 電商微服務可觀測性專案 裡的事件驅動設計,也是以這套函式庫的概念為基礎