← 回文章列表

2024年2月27日

用反射自動註冊泛型 Handler:找出所有 IEventHandler<T>,再在執行時依事件型別派送

.NETC#ReflectionDependency Injection事件驅動

問題:手動註冊一定會漏

做事件匯流排時,訂閱者的寫法通常是實作一個泛型介面:

C#
public interface IEventHandler<in TEvent>
{
    Task HandleAsync(TEvent @event, CancellationToken cancellationToken);
}

public class OrderCreatedHandler : IEventHandler<OrderCreated> { /* ... */ }
public class SendWelcomeMailHandler : IEventHandler<MemberRegistered> { /* ... */ }

接著要讓框架知道「OrderCreated 事件要交給 OrderCreatedHandler」,最直接的方式是手動註冊:

C#
services.AddScoped<IEventHandler<OrderCreated>, OrderCreatedHandler>();
services.AddScoped<IEventHandler<MemberRegistered>, SendWelcomeMailHandler>();
// ……第 30 個 Handler

Handler 一多,就一定會有人寫好了新的 Handler、卻忘了註冊。更麻煩的是,忘記註冊不會有任何錯誤:事件照常發布,只是沒有人處理,要到資料對不起來時才會發現。

比較好的做法是:只要實作了 IEventHandler<T>,就自動被註冊。這需要用到反射。

掃描:找出所有 IEventHandler 的實作

反射註冊流程示意圖:① 以 assembly.GetTypes 列出組件中的型別,只留下非抽象的類別,排除不是 Handler 的類別、抽象類別與介面本身;② 以 GetInterfaces 找出 IsGenericType 且泛型定義等於 IEventHandler 的介面,NotifyHandler 同時實作了兩個;③ 以 GetGenericArguments 取出事件型別;④ 登記事件型別對應 Handler 的對照表,並註冊進 DI。反射只在應用程式啟動時跑一次,之後發布事件時是查表加上從 DI 取出 Handler

C#
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;
}

逐步拆解:

  1. assembly.GetTypes():列出組件中的所有型別
  2. 篩選可以被建立的類別:IsClass 排除介面與結構,IsAbstract 排除抽象類別,IsGenericTypeDefinition 排除還沒指定型別參數的開放泛型類別(例如 LoggingHandler<T>,它沒辦法直接被建立)
  3. GetInterfaces():取得這個類別實作的所有介面,包含從父類別繼承來的
  4. 比對泛型定義:IEventHandler<OrderCreated> 是一個「封閉泛型型別」,它的 GetGenericTypeDefinition() 是「開放泛型型別」IEventHandler<>。用開放泛型比對,才能找出所有的 IEventHandler<任何型別>
  5. 註冊:以封閉泛型介面為服務型別、Handler 類別為實作型別註冊進 DI

一個類別實作了好幾個 IEventHandler<T>(例如同時處理 OrderCreated 和 PaymentPaid 的 NotifyHandler),GetInterfaces() 會回傳兩個介面,兩個都會被註冊。

如果需要事件型別本身(例如要建立「事件型別 → Handler 型別」的對照表,或是要用事件型別名稱訂閱 RabbitMQ 的 Routing Key),可以用 GetGenericArguments()[0] 取出:

C#
var eventType = @interface.GetGenericArguments()[0];   // typeof(OrderCreated)

派送:執行時只知道 object,怎麼找到 Handler?

註冊好了,接下來是發布事件。如果發布端在編譯時就知道事件型別,直接用泛型即可:

C#
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。這時就要在執行時組出泛型型別:

C#
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 換成編譯好的委派:

C#
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,並把反射的結果依型別快取成委派,讓反射只發生一次。