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

多年之前利用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]: 框架設計和實現原理

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章