全新升級的AOP框架Dora.Interception[1]: 程式設計體驗

2022-06-20 09:00:50

多年之前利用IL Emit寫了一個名為Dora.Interception(github地址,覺得不錯不妨給一顆星)的AOP框架。前幾天利用Roslyn的Source Generator對自己為公司寫的一個GraphQL框架進行改造,效能得到顯著的提高,覺得類似的機制同樣可以用在AOP框架上,實驗證明這樣的實現方式不僅僅極大地改善效能(包括執行耗時和GC記憶體分配),而且讓很多的功能特性變得簡單了很多。這並不是說IL Emit效能不好(其實恰好相反),而是因為這樣的實現太複雜,面向IL程式設計比寫組合差不多。由於AOP攔截機制涉及的場景很多(比如非同步等待、泛型型別和泛型方法、按地址傳遞引數等等),希望完全利用IL Emit高效地實現所有的功能特性確實很難,但是從C#程式碼的層面去考慮就簡單多了。(拙著《ASP.NET Core 6框架揭祕》於日前上市,加入讀者群享6折優惠)

目錄
一、Dora.Interception的設計特點
二、基於約定的攔截器定義
三、基於特性的攔截器註冊方式
四、基於表示式的攔截器註冊方式
五、更好的攔截器定義方式
六、方法注入
七、攔截的遮蔽
八、在ASP.NET Core程式中的應用

一、Dora.Interception的設計特點

徹底改造升級後的Dora.Interception直接根據.NET 6開發,不再支援之前.NET (Core)版本。和之前一樣,Dora.Interception的定位是一款輕量級的AOP框架,同樣建立在.NET的依賴注入框架上,可攔截的物件必需由依賴注入容器來提供。

除了效能的提升和保持低侵入性,Dora.Interception在程式設計方式上於其他所有的AOP框架都不太相同。在攔截器的定義上,我們並沒有提供介面和基礎類別來約束攔截方法的實現,而是採用「基於約定」的程式設計模式將攔截器定義成一個普通的類,攔截方法上可以任意注入依賴的物件。

在如何應用定義的攔截器方面,我們提供了常見的「特性標註」的程式設計方式將攔截器與目標型別、方法和屬性建立關聯,我們還提供了一種基於「表示式」的攔截器應用方式。Dora.Interception主張將攔截器「精準」地應用到具體的目標方法上,所以提供的這兩種方式針對攔截器的應用都是很「明確的」。如果希望更加靈活的攔截器應用方式,通過提供的擴充套件可以自由發揮。

接下來我們通過一個簡單範例來演示一下Dora.Interception如何使用。在這個範例中,我們利用AOP的方式來快取某個方法的結果,我們希望達到的效果很簡單:目標方法將返回值根據參數列進行快取,以避免針對方法的重複執行。

二、基於約定的攔截器定義

我們建立一個普通的控制檯程式,並新增如下兩個NuGet包的參照。前者正是提供Dora.Interception框架的NuGet包,後者提供的基於記憶體快取幫助我們快取方法返回值。

  • Dora.Interception
  • Microsoft.Extensions.Caching.Memory

由於方法的返回值必須針對輸入引數進行快取,所以我們定義瞭如下這個型別Key作為快取的鍵。作為快取鍵的Key物件是對作為目標方法的MethodInfo物件和作為參數列的物件陣列的封裝。

internal class Key : IEquatable<Key>
{
    public Key(MethodInfo method, IEnumerable<object> arguments)
    {
        Method = method;
        Arguments = arguments.ToArray();
    }

    public MethodInfo Method { get; }
    public object[] Arguments { get; }
    public bool Equals(Key? other)
    {
        if (other is null) return false;
        if (Method != other.Method) return false;
        if (Arguments.Length != other.Arguments.Length) return false;
        for (int index = 0; index < Arguments.Length; index++)
        {
            if (!Arguments[index].Equals(other.Arguments[index]))
            {
                return false;
            }
        }
        return true;
    }
    public override int GetHashCode()
    {
        var hashCode = new HashCode();
        hashCode.Add(Method);
        for (int index = 0; index < Arguments.Length; index++)
        {
            hashCode.Add(Arguments[index]);
        }
        return hashCode.ToHashCode();
    }
    public override bool Equals(object? obj) => obj is Key key && key.Equals(this);
}

如下所示的就是用來快取目標方法返回值的攔截器型別CachingInterceptor的定義。正如上面所示,Dora.Interception提供的是「基於約定」的程式設計方式。這意味著作為攔截器的型別不需要實現既定的介面或者繼承既定的基礎類別,它僅僅是一個普通的公共範例型別。由於Dora.Interception建立在依賴注入框架之上,所以我們可以在建構函式中注入依賴的物件,在這裡我們就注入了用來快取返回值的IMemoryCache 物件。

public class CachingInterceptor
{
    private readonly IMemoryCache _cache;
    public CachingInterceptor(IMemoryCache cache) => _cache = cache;

    public async ValueTask InvokeAsync(InvocationContext invocationContext)
    {
        var method = invocationContext.MethodInfo;
        var arguments = Enumerable.Range(0, method.GetParameters().Length).Select(index => invocationContext.GetArgument<object>(index));
        var key = new Key(method, arguments);

        if (_cache.TryGetValue<object>(key, out var value))
        {
            invocationContext.SetReturnValue(value);
            return;
        }
        await invocationContext.ProceedAsync();
        _cache.Set(key, invocationContext.GetReturnValue<object>());
    }
}

具體的「切面(Aspect)」邏輯實現在一個面向約定的InvokeAsync方法中,該方法只需要定義成返回型別為ValueTask的公共實體方法即可。InvokeAsync方法提供的InvocationContext 物件是針對當前方法呼叫的上下文,我們利用其MethodInfo屬性得到代表目標方法的MethodInfo物件,呼叫泛型方法GetArgument<TArgument>根據序號得到傳入的引數。在利用它們生成程式碼快取鍵的Key物件之後,我們利用建構函式中注入的IMemoryCache 物件確定是否存在快取的返回值。如果存在,我們直接呼叫InvocationContext 物件的SetReturnValue<TReturnValue>方法將它設定為方法返回值,並直接「短路」返回,目標方法將不再執行。

如果返回值尚未被快取,我們呼叫InvocationContext 物件的ProceedAsync方法,該方法會幫助我們呼叫後續的攔截器或者目標方法。在此之後我們利用上下文的SetReturnValue<TReturnValue>方法將返回值提取出來進行快取就可以了。

三、基於特性的攔截器註冊方式

攔截器最終需要應用到某個具體的方法上。為了能夠看到上面定義的CachingInterceptor針對方法返回值快取功能,我們定義瞭如下這個用來提供系統時間戳的SystemTimeProvider服務型別和對應的介面ISystemTimeProvider,定義的GetCurrentTime方法根據作為引數的DateTimeKind列舉返回當前時間。實現在SystemTimeProvider中的GetCurrentTime方法上利用預定義的InterceptorAttribute特性將上面定義的CachingInterceptor攔截器應用到目標方法上,該特性提供的Order屬性用來控制應用的多個攔截器的執行順序。

public interface ISystemTimeProvider { DateTime GetCurrentTime(DateTimeKind kind); }

public class SystemTimeProvider : ISystemTimeProvider { [Interceptor(typeof(CachingInterceptor),Order = 1)] public virtual DateTime GetCurrentTime(DateTimeKind kind) => kind switch { DateTimeKind.Utc => DateTime.UtcNow, _ => DateTime.Now }; }

雖然大部分AOP框架都支援將攔截器應用到介面上,但是Dora.Interception傾向於避免這樣做,因為介面是服務消費的契約,面向切面的橫切(Crosscutting)功能體現的是服務實現的內部行為,所以攔截器應該應用到實現型別上。如果你一定要做麼做,只能利用提供的擴充套件點來實現,實現方式其實也很簡單。

Dora.Interception直接利用依賴注入容器來提供可被攔截的範例。如下面的程式碼片段所示,我們建立了一個ServiceCollection物件並完成必要的服務註冊,最終呼叫BuildInterceptableServiceProvider擴充套件方法得到作為依賴注入容器的IServiceProvider物件。

var timeProvider = new ServiceCollection()
    .AddMemoryCache()
    .AddSingleton<ISystemTimeProvider, SystemTimeProvider>()
    .AddSingleton<SystemTimeProvider>()
    .BuildInterceptableServiceProvider()
    .GetRequiredService<SystemTimeProvider>();

Console.WriteLine("Utc time:");
for (int index = 0; index < 5; index++)
{
    Console.WriteLine($"{timeProvider.GetCurrentTime(DateTimeKind.Utc)}[{DateTime.UtcNow}]");
    await Task.Delay(1000);
}


Console.WriteLine("Utc time:");
for (int index = 0; index < 5; index++)
{
    Console.WriteLine($"{timeProvider.GetCurrentTime(DateTimeKind.Local)}[{DateTime.Now}]");
    await Task.Delay(1000);
}

在利用BuildInterceptableServiceProvider物件得到用於提供當前時間戳的ISystemTimeProvider服務範例,並在控制上以UTC和本地時間的形式輸出時間戳。由於輸出的間隔被設定為1秒,如果方法的返回值被快取,那麼輸出的時間是相同的,下圖所示的輸出結果體現了這一點(原始碼)。

image

四、基於Lambda表示式的攔截器註冊方式

如果攔截器應用的目標型別是由自己定義的,我們可以在其型別或成員上標註InterceptorAttribute特性來應用對應的攔截器。如果對那個的程式集是由第三方提供的呢?此時我們可以採用提供的第二種基於表示式的攔截器應用方式。這裡的攔截器是一個呼叫目標型別某個方法或者提取某個屬性的Lambda表示式,我們採用這種強型別的程式設計方式得到目標方法,並提升程式設計體驗。對於我們演示的範例來說,攔截器最終應用到SystemTimeProvider的GetCurrentTime方法上,所以我們可以按照如下的形式來代替標註在該方法上的InterceptorAttribute特性(原始碼)。

var timeProvider = new ServiceCollection()
    .AddMemoryCache()
    .AddSingleton<ISystemTimeProvider, SystemTimeProvider>()
    .AddSingleton<SystemTimeProvider>()
    .BuildInterceptableServiceProvider(interception => interception.RegisterInterceptors(RegisterInterceptors))
    .GetRequiredService<SystemTimeProvider>();

static void RegisterInterceptors(IInterceptorRegistry registry)
{
    registry.For<CachingInterceptor>().ToMethod<SystemTimeProvider>(1, it => it.GetCurrentTime(default));
}

五、更好的攔截器定義方式

全新的Dora.Interception在提升效能上做了很多考量。從上面定義的CachingInterceptor可以看出,作為方法呼叫上下文的InvocationContext型別提供的大部分方法都是泛型方法,其目的就是避免裝箱帶來的記憶體分配。但是CachingInterceptor為了適應所有方法,只能將引數和返回值轉換成object物件,所以這樣會程式碼一些效能損失。為了解決這個問題,我們可以針對引數的個數相應的泛型攔截器。比如針對單一引數方法的攔截器就可以定義成如下的形式,我們不僅可以直接使用 Tuple<MethodInfo, TArgument>元組作為快取的Key,還可以直接呼叫泛型的GetArgument<TArgument>方法和SetReturnValue<TReturnValue>提起引數和設定返回值。

public class CachingInterceptor<TArgument, TReturnValue>
{
    private readonly IMemoryCache _cache;
    public CachingInterceptor(IMemoryCache cache) => _cache = cache;

    public async ValueTask InvokeAsync(InvocationContext invocationContext)
    {
        var key = new Tuple<MethodInfo, TArgument>(invocationContext.MethodInfo, invocationContext.GetArgument<TArgument>(0));
        if (_cache.TryGetValue<TReturnValue>(key, out var value))
        {
            invocationContext.SetReturnValue(value);
            return;
        }

        await invocationContext.ProceedAsync();
        _cache.Set(key, invocationContext.GetReturnValue<TReturnValue>());
    }
}

具體的引數型別只需要按照如下的方式在應用攔截器的時候指定就可以了(原始碼)。

public class SystemTimeProvider : ISystemTimeProvider
{
    [Interceptor(typeof(CachingInterceptor<DateTimeKind,DateTime>), Order = 1)]
    public virtual DateTime GetCurrentTime(DateTimeKind kind) => kind switch
    {
        DateTimeKind.Utc => DateTime.UtcNow,
        _ => DateTime.Now
    };
}

六、方法注入

攔截器定義的時候可以在建構函式中注入依賴物件,其實更方便不是採用建構函式注入,而是採用方法注入,也就是直接將物件注入到InvokeAsync方法中。由於攔截器物件具有全域性生命週期(從建立到應用關閉),所以Scoped服務不能注入到建構函式中,此時只能採用方法注入,因為方法中注入的物件是在方法呼叫時實時提供的。上面定義的攔截器型別改寫成如下的形式(原始碼)。

public class CachingInterceptor<TArgument, TReturnValue>
{
    public async ValueTask InvokeAsync(InvocationContext invocationContext, IMemoryCache cache)
    {
        var key = new Tuple<MethodInfo, TArgument>(invocationContext.MethodInfo, invocationContext.GetArgument<TArgument>(0));
        if (cache.TryGetValue<TReturnValue>(key, out var value))
        {
            invocationContext.SetReturnValue(value);
            return;
        }

        await invocationContext.ProceedAsync();
        cache.Set(key, invocationContext.GetReturnValue<TReturnValue>());
    }
}

七、攔截的遮蔽

除了「精準地」將某個攔截器應用到目標方法上,我們也可以採用「排除法」先將攔截器批次應用到一組候選的方法上(比如應用到某個型別設定是程式集上),然後將某些不需要甚至不能被攔截的方法排除掉。此外我們使用這種機制避免某些不能被攔截(比如在一個迴圈中重複呼叫)的方法被錯誤地與某些攔截器進行對映。針對攔截的遮蔽也提供了兩種程式設計方式,一種方式就是在型別、方法或者屬性上直接標註NonInterceptableAttribute特性。由於針對攔截的遮蔽具有最高優先順序,如果我們按照如下的方式在SystemTimeProvider型別上標註NonInterceptableAttribute特性,針對該型別的所有方法的呼叫將不會被攔截(原始碼)。

[NonInterceptable]
public class SystemTimeProvider : ISystemTimeProvider
{
    [Interceptor(typeof(CachingInterceptor<DateTimeKind, DateTime>), Order = 1)]
    public virtual DateTime GetCurrentTime(DateTimeKind kind) => kind switch
    {
        DateTimeKind.Utc => DateTime.UtcNow,
        _ => DateTime.Now
    };
}

我們也可以採用如下的方式呼叫SuppressType<TTarget>方法以表示式的方式提供需要遮蔽的方式。除了這個方法,IInterceptorRegistry介面還提供了其他方法,我們會在後續的內容進行系統介紹。

var timeProvider = new ServiceCollection()
    .AddMemoryCache()
    .AddSingleton<ISystemTimeProvider, SystemTimeProvider>()
    .AddSingleton<SystemTimeProvider>()
    .BuildInterceptableServiceProvider(interception => interception.RegisterInterceptors(RegisterInterceptors))
    .GetRequiredService<SystemTimeProvider>();

...

static void RegisterInterceptors(IInterceptorRegistry registry) => registry.SupressType<SystemTimeProvider>();

八、在ASP.NET Core程式中的應用

由於ASP.NET Core框架建立在依賴注入框架之上,Dora.Interception針對方法的攔截也是通過動態改變服務註冊的方式實現的,所以Dora.Interception在ASP.NET Core的應用更加自然。現在我們將上面定義的ISystemTimeProvider/SystemTimeProvider服務應用到如下這個HomeController中。兩個採用路由路徑「/local」和「utc」的Action方法會利用注入的ISystemTimeProvider物件返回當前時間。為了檢驗返回的時間是否被快取,方法還會返回當前的真實時間戳

public class HomeController
{
    [HttpGet("/local")]
    public string GetLocalTime([FromServices] ISystemTimeProvider provider) => $"{provider.GetCurrentTime(DateTimeKind.Local)}[{DateTime.Now}]";

    [HttpGet("/utc")]
    public string GetUtcTime([FromServices] ISystemTimeProvider provider) => $"{provider.GetCurrentTime(DateTimeKind.Utc)}[{DateTime.UtcNow}]";
}

ASP.NET Core針對Dora.Interception的整合是通過呼叫IHostBuilder的UseInterception擴充套件方法實現的,該擴充套件方法由「Dora.Interception.AspNetCore」提供(原始碼)。

using App;

var builder = WebApplication.CreateBuilder(args);
builder.Host.UseInterception();
builder.Services
    .AddHttpContextAccessor()
    .AddMemoryCache()
    .AddSingleton<ISystemTimeProvider, SystemTimeProvider>()
    .AddControllers();
var app = builder.Build();
app
    .UseRouting()
    .UseEndpoints(endpint => endpint.MapControllers());
app.Run();

程式啟動後,我們請求路徑「local」和「utc」得到的時間戳都將被快取起來,如下的輸出結果體現了這一點(原始碼)。

image

全新升級的AOP框架Dora.Interception[1]: 程式設計體驗
全新升級的AOP框架Dora.Interception[2]: 基於約定的攔截器定義方式
全新升級的AOP框架Dora.Interception[3]: 基於「特性標註」的攔截器註冊方式
全新升級的AOP框架Dora.Interception[4]: 基於「Lambda表示式」的攔截器註冊方式
全新升級的AOP框架Dora.Interception[5]: 實現任意的攔截器註冊方式
全新升級的AOP框架Dora.Interception[6]: 框架設計和實現原理