1. 程式人生 > >AOP框架Dora.Interception 3.0 [1]: 程式設計體驗

AOP框架Dora.Interception 3.0 [1]: 程式設計體驗

.NET Core正式釋出之後,我為.NET Core度身定製的AOP框架Dora.Interception也升級到3.0。這個版本除了升級底層類庫(.NET Standard 2.1)之外,我還對它進行大範圍的重構甚至重新設計。這次重構大部分是在做減法,其目的在於使設計和使用更加簡單和靈活,接下來我們就來體驗一下在一個ASP.NET Core應用程式下如何使用Dora.Interception。

原始碼下載
例項1(Console)
例項2(ASP.NET Core MVC + 註冊可攔截服務)
例項3(ASP.NET Core MVC + 註冊InterceptableServiceProviderFactory)
例項4(ASP.NET Core MVC + 攔截策略)
例項5(ASP.NET Core MVC + 策略指令碼化)

一、演示場景

我們依然沿用“快取”這個應用場景:我們建立一個快取攔截器,並將其應用到某個方法上。快取攔截器會將目標方法的返回值快取起來。在快取過期之前,提供相同引數列表的方法呼叫會直接返回快取的資料,而無需執行目標方法。如下所示是作為快取鍵型別的CacheKey的定義,可以看出快取時針對”方法+引數列表”實施快取的。

private class Cachekey
{
    public MethodBase Method { get; }
    public object[] InputArguments { get; }

    public Cachekey(MethodBase method, object[] arguments)
    {
        Method = method;
        InputArguments = arguments;
    }
    public override bool Equals(object obj)
    {
        if (!(obj is Cachekey another))
        {
            return false;
        }
        if (!Method.Equals(another.Method))
        {
            return false;
        }
        for (int index = 0; index < InputArguments.Length; index++)
        {
            var argument1 = InputArguments[index];
            var argument2 = another.InputArguments[index];
            if (argument1 == null && argument2 == null)
            {
                continue;
            }

            if (argument1 == null || argument2 == null)
            {
                return false;
            }

            if (!argument2.Equals(argument2))
            {
                return false;
            }
        }
        return true;
    }

    public override int GetHashCode()
    {
        int hashCode = Method.GetHashCode();
        foreach (var argument in InputArguments)
        {
            hashCode = hashCode ^ argument.GetHashCode();
        }
        return hashCode;
    }
}

二、定義攔截器

作為Dora.Interception區別於其他AOP框架的最大特性,我們註冊的攔截器型別無需實現某個預定義的介面,因為我們採用基於“約定”的攔截器定義方式。基於約定方式定義的快取攔截器型別CacheInterceptor定義如下。

public class CacheInterceptor
{
    private readonly IMemoryCache _cache;
    private readonly MemoryCacheEntryOptions _options;
    public CacheInterceptor(IMemoryCache cache, IOptions<MemoryCacheEntryOptions> optionsAccessor)
    {
        _cache = cache;
        _options = optionsAccessor.Value;
    }

    public async Task InvokeAsync(InvocationContext context)
    {
        var key = new Cachekey(context.Method, context.Arguments);
        if (_cache.TryGetValue(key, out object value))
        {
            context.ReturnValue = value;
        }
        else
        {
            await context.ProceedAsync();
            _cache.Set(key, context.ReturnValue, _options);
        }
    }
}

按照約定,攔截器型別只需要定義成一個普通的“公共、例項”型別即可。攔截操作需要定義在約定的InvokeAsync方法中,該方法的返回型別為Task,並且包含一個InvocationContext型別的引數。InvocationContext型別封裝了當前方法的呼叫上下文,我們可以利用它獲取當前的方法和輸入引數等資訊。InvocationContext的ReturnValue 屬性表示方法呼叫的返回結果,CacheInterceptor正式通過設定該屬性從而實現將方法返回值進行快取的目的。

如上面的程式碼片段所示,在InvokeAsync方法中,我們先判斷針對當前的引數引數列表是否具有快取的結果,如果有的話我們直接將它作為InvocationContext上下文的ReturnValue屬性。如果從快取中找不到對應的結果,在通過呼叫InvocationContext上下文的ProceedAsync方法執行目標方法(也可能是後續攔截器),並將新的結果快取起來。

三、依賴注入

Dora.Interception是為.NET Core度身定製的輕量級AOP框架。由於依賴注入已經成為了.NET Core基本的程式設計方式,所以Dora.Interception和.NET Core的依賴注入框架進行了無縫整合。正因為如此,當我們在定義攔截器的時候可以將依賴服務直接注入到建構函式中。對於上面定義的CacheInterceptor來說,由於我們直接使用的是.NET Core提供的基於記憶體的快取框架,所以我們直接將所需的IMemoryCache 服務和提供配置選項的IOptions<MemoryCacheEntryOptions> 服務注入到建構函式中。

除了建構函式注入,我們還支援針對InvokeAsync方法的“方法注入”。也就是說我們可以將上述的兩個依賴服務以如下的方式注入到InvokeAsync方法中。

public class CacheInterceptor
{            
    public async Task InvokeAsync(InvocationContext context, IMemoryCache cache, IOptions<MemoryCacheEntryOptions> optionsAccessor)
    {
        var key = new Cachekey(context.Method, context.Arguments);
        if (cache.TryGetValue(key, out object value))
        {
            context.ReturnValue = value;
        }
        else
        {
            await context.ProceedAsync();
            cache.Set(key, context.ReturnValue, optionsAccessor.Value);
        }
    }
}

針對攔截器型別的兩種依賴注入方式並不是等效的,它們之間的差異體現在服務例項的生命週期上。由於攔截器物件自身屬於一個Singleton服務,所以我們不能在它的建構函式中注入一個Scoped服務,否則依賴服務將不能按照期望的方式被釋放。Scoped服務只能注入到InvokeAsync方法中,因為該方法注入的服務例項是根據當前Scope的IServiceProvider提供的(對於ASP.NET Core應用來說,就是當前HttpContext上下文的RequestServices)。

四、註冊攔截器

AOP的本質對方法呼叫進行攔截,並在呼叫目標方法之前執行應用的攔截器,所以我們定義的攔截器最終需要註冊到一個或者多個方法上。Dora.Interception刻意將“攔截器”和“攔截器註冊”分離開來,因為攔截器具有不同的註冊方式。

在型別或者方法上標註特性是我們常用的攔截器註冊方式,為此我們為CacheInterceptor定義瞭如下這個CacheReturnValueAttribute。CacheReturnValueAttribute繼承自抽象型別InterceptorAttribute,在重寫的Use方法中,我們只需要呼叫作為引數的IInterceptorChainBuilder物件的Use<TInterceptor>方法將指定的攔截器新增到攔截器鏈條(同一個方法上可能同時應用多個攔截器)。

[AttributeUsage(AttributeTargets.Method)]
public class CacheReturnValueAttribute : InterceptorAttribute
{
    public override void Use(IInterceptorChainBuilder builder)
    {
        builder.Use<CacheInterceptor>(Order);
    }   
}

Use<TInterceptor>方法的泛型引數表示對應攔截器的型別,它的第一個引數表示指定的攔截器在整個鏈條上的位置。這個值就是InterceptorAttribute的Order屬性值。如果攔截器型別建構函式中定義了一些無法通過依賴注入框架提供的引數,我們在呼叫Use<TInterceptor>方法時可以利用後面的params引數來指定。

如果你覺得將攔截器型別和對應的特性分開定義比較煩,也可以將兩者合二為一,我們只需要將InvokeAsync方法按照如下的方式轉移到InterceptorAttribute型別中就可以了。由於它自身就是一個攔截器,我們在Use方法中會呼叫IInterceptorChainBuilder物件非泛型Use方法,並將自身作為第一個引數。

[AttributeUsage(AttributeTargets.Method)]
public class CacheReturnValueAttribute : InterceptorAttribute
{
    public async Task InvokeAsync(InvocationContext context, IMemoryCache cache, IOptions<MemoryCacheEntryOptions> optionsAccessor)
    {
        var key = new Cachekey(context.Method, context.Arguments);
        if (cache.TryGetValue(key, out object value))
        {
            context.ReturnValue = value;
        }
        else
        {
            await context.ProceedAsync();
            cache.Set(key, context.ReturnValue, optionsAccessor.Value);
        }
    }
    public override void Use(IInterceptorChainBuilder builder)
    {
        builder.Use(this, Order);
    }
}

為了能夠很直觀地看到針對方法返回值的快取,我們定義瞭如下這個表示系統時鐘的ISystemClock的服務介面。該介面具有唯一的GetCurrentTime方法返回當前的時間,方法引數用於控制行為方法的時間型別(UTC或者Local)。實現型別SystemClock標註了我們定義的InterceptorAttribute特性。

public interface ISystemClock
{
    DateTime GetCurrentTime(DateTimeKind dateTimeKind);
}

public class SystemClock : ISystemClock
{
    [CacheReturnValue(Order = 1)]
    public DateTime GetCurrentTime(DateTimeKind dateTimeKind)
    {
        return dateTimeKind switch
        {
            DateTimeKind.Local => DateTime.UtcNow.ToLocalTime(),
            DateTimeKind.Unspecified => DateTime.Now,
            _ => DateTime.UtcNow,
        };
    }
}

五、註冊可被攔截的服務

接下來我們在一個ASP.NET Core MVC應用中演示針對ISystemClock服務提供時間的快取。如下所示的是應用承載程式和註冊Startup型別的定義。為了讓依賴注入框架提供的ISystemClock服務是可以被攔截的,我們呼叫了IServiceCollection介面的AddSingletonInterceptable<TService, TImplementation>擴充套件方法。由於CacheInterceptor利用.NET Core記憶體快取框架來儲存方法返回值,所以我們還呼叫了AddMemoryCache擴充套件方法註冊了相關服務。

public class Program
{
    public static void Main(string[] args)
    {
        Host.CreateDefaultBuilder()
                .ConfigureWebHostDefaults(buider => buider.UseStartup<Startup>())
                .Build()
                .Run();
    }
}

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services
            .AddMemoryCache()
            .AddInterception()
            .AddSingletonInterceptable<ISystemClock, SystemClock>()
            .AddRouting()
            .AddControllers();
    }

    public void Configure(IApplicationBuilder app)
    {
        app
            .UseRouting()
            .UseEndpoints(endpoints => endpoints.MapControllers());
    }
}

我們定義瞭如下這個HomeController,並在其建構函式中注入了ISystemClock服務。在Action方法Index中,我們利用ISystemClock服務在1秒時間間隔內兩次提供當前時間,並將這兩個時間呈現在瀏覽器上。呼叫ISystemClock的GetCurrentTime方法指定的時間型別(UTC或者Local)是利用查詢字串提供的。

public class HomeController : Controller
{
    private readonly ISystemClock _clock;
    public HomeController(ISystemClock clock)
    {
        _clock = clock ?? throw new ArgumentNullException(nameof(clock));
    }

    [HttpGet("/{kind?}")]
    public async Task Index(string kind="local")
    {
        DateTimeKind dateTimeKind = string.Compare(kind, "utc", true) == 0
            ? DateTimeKind.Utc
            : DateTimeKind.Local;

        Response.ContentType = "text/html";
        await Response.WriteAsync("<html><body><ul>");
        for (int i = 0; i < 2; i++)
        {
            await Response.WriteAsync($"<li>{_clock.GetCurrentTime(dateTimeKind)}</li>");
            await Task.Delay(1000);
        }  
        await Response.WriteAsync("</ul><body></html>");
    }
}

執行程式後,我們利用瀏覽器對定義在HomeController中的Action方法Index發起請求。如下圖所示,由於快取的存在,只要指定的時間型別一樣,返回的時間就是一樣的。

六、保留現有的服務註冊方式

在上面的示例演示中,為了讓依賴注入框架提供的ISystemClock服務能夠被攔截,我們不得不呼叫自定義的AddSingletonInterceptable<TService, TImplementation>擴充套件方法擴充套件方法來註冊服務。如果你不喜歡這種方式,我們還提供了另一種解決方案,那就是按照如下的方式呼叫IHostBuilder的UseInterceptableServiceProvider擴充套件方法註冊我們自定義的InterceptableServiceProviderFactory。

public class Program
{
    public static void Main(string[] args)
    {
        Host.CreateDefaultBuilder()
                .UseInterceptableServiceProvider()
                .ConfigureWebHostDefaults(buider => buider.UseStartup<Startup>())
                .Build()
                .Run();
    }
}

一旦我們按照上面的當時完成了針對InterceptableServiceProviderFactory的註冊之後,我們將可以將針對ISystemClock服務的註冊還原成我們熟悉的方式。

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        services
            .AddMemoryCache()
            .AddInterception()
            .AddSingleton<ISystemClock, SystemClock>()
            .AddRouting()
            .AddControllers();
    }

    public void Configure(IApplicationBuilder app)
    {
        app
            .UseRouting()
            .UseEndpoints(endpoints => endpoints.MapControllers());
    }
}

七、基於策略的攔截器註冊方式

Dora.Interception提供了擴充套件點使我們可以實現任意的攔截器註冊方式。除了預設提供的針對“特性標註”的方式之外,我們還提供了一種針對策略的註冊方式。這裡的策略旨在提供這樣的表達:將某種型別的攔截器應用到某個型別的某個方法或者屬性上。如果我們沒有將CacheReturnValueAttribute特性標註到SystemClock的GetCurrentTime方法上,我們可以將承載程式修改成如下的形式。

public class Program
{
    public static void Main(string[] args)
    {
        Host.CreateDefaultBuilder()
            .UseInterceptableServiceProvider(configure: Configure)
            .ConfigureWebHostDefaults(buider => buider.UseStartup<Startup>())
            .Build()
            .Run();

        static void Configure(InterceptionBuilder interceptionBuilder)
        {
            interceptionBuilder.AddPolicy(policyBuilder => policyBuilder
                .For<CacheReturnValueAttribute>(order: 1, cache => cache
                    .To<SystemClock>(target => target
                        .IncludeMethod(clock => clock.GetCurrentTime(default)))));
        }
    }
}

如上面的程式碼片段所示,我們在呼叫IHostBuilder的UseInterceptableServiceProvider擴充套件方法的時候指定了一個Action<InterceptionBuilder>物件,它通過呼叫InterceptionBuilder 物件的AddPolicy擴充套件方法通過明確的語義將CacheReturnValueAttribute應用到SystemClock的GetCurrentTime方法上。由於不論是指定型別還是方法都是採用“強型別”的方式,所以有效避免了出錯的可能性。

八、策略指令碼化

如果希望在不修改現有程式程式碼的前提下自由的修改攔截策略,我們可以將策略指令碼化。在這裡我們使用的指令碼語言就是C#,所以我們可以將上面提供的策略程式碼放在一個C#指令碼中。比如我們在根目錄下建立一個interception.dora檔案,並在其中定義如下的策略。

policyBuilder
    .For<CacheReturnValueAttribute>(1, cache => cache
        .To<SystemClock>(clock => clock
            .IncludeMethod(it => it.GetCurrentTime(default))));

為了使用這個策略指令碼,我們需要對承載程式作相應修改。如下面的程式碼片段所示,我們同樣呼叫了InterceptionBuilder 的AddPolicy方法,但是這次我們指定的是策略指令碼檔名。為了能夠識別指令碼檔案中的型別,我們提供了一個Action<PolicyFileBuilder>物件,並呼叫PolicyFileBuilder的AddReferences方法添加了程式集引用,呼叫AddImports方法匯入了名稱空間。

public class Program
{
    public static void Main(string[] args)
    {
        Host.CreateDefaultBuilder()
            .UseInterceptableServiceProvider(configure: Configure)
                .ConfigureWebHostDefaults(buider => buider.UseStartup<Startup>())
                .Build()
                .Run();

        static void Configure(InterceptionBuilder interceptionBuilder)
        {
            interceptionBuilder.AddPolicy("Interception.dora", script => script
                .AddReferences(Assembly.GetExecutingAssembly())
                .AddImports("App"));
        }
    }
}