2024年2月27日
用反射自動註冊泛型 Handler:找出所有 IEventHandler<T>,再在執行時依事件型別派送
問題:手動註冊一定會漏
做事件匯流排時,訂閱者的寫法通常是實作一個泛型介面:
public interface IEventHandler<in TEvent>
{
Task HandleAsync(TEvent @event, CancellationToken cancellationToken);
}
public class OrderCreatedHandler : IEventHandler<OrderCreated> { /* ... */ }
public class SendWelcomeMailHandler : IEventHandler<MemberRegistered> { /* ... */ }
接著要讓框架知道「OrderCreated 事件要交給 OrderCreatedHandler」,最直接的方式是手動註冊:
services.AddScoped<IEventHandler<OrderCreated>, OrderCreatedHandler>();
services.AddScoped<IEventHandler<MemberRegistered>, SendWelcomeMailHandler>();
// ……第 30 個 Handler
Handler 一多,就一定會有人寫好了新的 Handler、卻忘了註冊。更麻煩的是,忘記註冊不會有任何錯誤:事件照常發布,只是沒有人處理,要到資料對不起來時才會發現。
比較好的做法是:只要實作了 IEventHandler<T>,就自動被註冊。這需要用到反射。
掃描:找出所有 IEventHandler 的實作
public static IServiceCollection AddEventHandlersFromAssembly(
this IServiceCollection services, Assembly assembly)
{
var registrations =
from type in assembly.GetTypes()
where type is { IsClass: true, IsAbstract: false, IsGenericTypeDefinition: false }
from @interface in type.GetInterfaces()
where @interface.IsGenericType
&& @interface.GetGenericTypeDefinition() == typeof(IEventHandler<>)
select (ServiceType: @interface, ImplementationType: type);
foreach (var (serviceType, implementationType) in registrations)
{
// 例如:IEventHandler<OrderCreated> → OrderCreatedHandler
services.AddScoped(serviceType, implementationType);
}
return services;
}
逐步拆解:
assembly.GetTypes():列出組件中的所有型別- 篩選可以被建立的類別:
IsClass排除介面與結構,IsAbstract排除抽象類別,IsGenericTypeDefinition排除還沒指定型別參數的開放泛型類別(例如LoggingHandler<T>,它沒辦法直接被建立) GetInterfaces():取得這個類別實作的所有介面,包含從父類別繼承來的- 比對泛型定義:
IEventHandler<OrderCreated>是一個「封閉泛型型別」,它的GetGenericTypeDefinition()是「開放泛型型別」IEventHandler<>。用開放泛型比對,才能找出所有的IEventHandler<任何型別> - 註冊:以封閉泛型介面為服務型別、Handler 類別為實作型別註冊進 DI
一個類別實作了好幾個 IEventHandler<T>(例如同時處理 OrderCreated 和 PaymentPaid 的 NotifyHandler),GetInterfaces() 會回傳兩個介面,兩個都會被註冊。
如果需要事件型別本身(例如要建立「事件型別 → Handler 型別」的對照表,或是要用事件型別名稱訂閱 RabbitMQ 的 Routing Key),可以用 GetGenericArguments()[0] 取出:
var eventType = @interface.GetGenericArguments()[0]; // typeof(OrderCreated)
派送:執行時只知道 object,怎麼找到 Handler?
註冊好了,接下來是發布事件。如果發布端在編譯時就知道事件型別,直接用泛型即可:
public async Task PublishAsync<TEvent>(TEvent @event, CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var handlers = scope.ServiceProvider.GetServices<IEventHandler<TEvent>>();
foreach (var handler in handlers)
await handler.HandleAsync(@event, ct);
}
但從訊息佇列收到的訊息,反序列化之後只是一個 object(事件型別是從訊息標頭裡的型別名稱解析出來的),編譯時並不知道是 OrderCreated。這時就要在執行時組出泛型型別:
public async Task DispatchAsync(object @event, CancellationToken ct)
{
var handlerType = typeof(IEventHandler<>).MakeGenericType(@event.GetType());
// IEventHandler<> + OrderCreated → IEventHandler<OrderCreated>
using var scope = _scopeFactory.CreateScope();
foreach (var handler in scope.ServiceProvider.GetServices(handlerType))
{
var method = handlerType.GetMethod(nameof(IEventHandler<object>.HandleAsync))!;
await (Task)method.Invoke(handler, new[] { @event, ct })!;
}
}
MakeGenericType 是 GetGenericTypeDefinition 的反向操作:把開放泛型填上型別參數,變成封閉泛型。
每一個事件都建立一個新的 DI Scope,Handler 如果注入了 DbContext 這類 Scoped 服務,每個事件都會拿到獨立的實例,不會互相干擾。
效能:反射放在啟動時,執行時快取
反射的成本主要在「找」:列舉組件中的所有型別、取得介面、查詢方法。這些動作放在應用程式啟動時跑一次完全沒問題,但不應該在每次發布事件時重做。
上面的 DispatchAsync 每次都呼叫 MakeGenericType 與 GetMethod,事件量大時可以依事件型別快取起來,並把 MethodInfo.Invoke 換成編譯好的委派:
private static readonly ConcurrentDictionary<Type, Func<object, object, CancellationToken, Task>> Invokers = new();
private static Func<object, object, CancellationToken, Task> GetInvoker(Type eventType) =>
Invokers.GetOrAdd(eventType, static t =>
{
var handlerType = typeof(IEventHandler<>).MakeGenericType(t);
var method = handlerType.GetMethod("HandleAsync")!;
var handler = Expression.Parameter(typeof(object));
var @event = Expression.Parameter(typeof(object));
var ct = Expression.Parameter(typeof(CancellationToken));
var call = Expression.Call(
Expression.Convert(handler, handlerType), method,
Expression.Convert(@event, t), ct);
return Expression.Lambda<Func<object, object, CancellationToken, Task>>(call, handler, @event, ct).Compile();
});
每個事件型別只會建立一次委派,之後的呼叫和一般的方法呼叫速度相當。
實務上要注意的地方
- 掃描哪些組件:
Assembly.GetExecutingAssembly()指的是「目前執行中的程式碼所在的組件」——如果掃描的程式寫在函式庫裡,拿到的是函式庫本身,而不是使用者的應用程式。比較明確的做法是讓呼叫端傳入,例如AddEventHandlersFromAssembly(typeof(Program).Assembly)或Assembly.GetEntryAssembly() - 重複註冊:同一個組件被掃描兩次,Handler 會被註冊兩次、事件被處理兩次。可以用
TryAddEnumerable註冊,相同的服務與實作組合只會加入一次 GetTypes()可能拋出例外:組件中有型別的相依組件載入失敗時,會拋出ReflectionTypeLoadException,可以從例外的Types屬性取出成功載入的部分- 現成的工具:只是要「掃描並註冊」,Scrutor 這類套件已經做得很完整;在意啟動時間或要做 AOT 編譯的話,可以考慮用 Source Generator 在編譯時產生註冊程式碼,完全避開執行時的反射
使用情境:自製的 EventBus 函式庫
自製的 .NET EventBus 函式庫就是用這套方式處理訂閱:
- 比照
IServiceCollection,另外設計一個SubscriptionCollection,記錄「事件型別 → Handler 型別」的對照 AddEventHandlersFromAssembly()在啟動時以反射掃描組件,自動登記所有IEventHandler<T>,同時把 Handler 註冊進 DI- 從記憶體佇列或 RabbitMQ 收到事件時,依事件型別查表,建立 DI Scope 取出 Handler 並呼叫
使用者新增事件處理器時,只需要實作介面,不需要再修改任何註冊程式碼。
一句話總結
用 GetTypes() 加 GetInterfaces(),比對 GetGenericTypeDefinition() == typeof(IEventHandler<>),就能在啟動時自動找出並註冊所有 Handler;執行時只知道 object 的情況,用 MakeGenericType 組出封閉泛型再從 DI 取出 Handler,並把反射的結果依型別快取成委派,讓反射只發生一次。