[料理佳餚] C# 一個 Open Source 的 Compile-time AOP 框架 - AspectInjector

看到 Bill 叔在 twMVC#39 講的 Compile-time weaving 的 AOP 框架 - PostSharp,勾起了我的 AOP 魂,隨手 Google 了一下,讓我找到了一個 Open Source 的框架 - AspectInjector(看名字我還以為是某個 Dependency Injection 的套件),看它在 GitHub 的介紹裡面下了 postsharp 的標籤,似乎有向 PostSharp 看齊的目標。

AOP 的概念我就不多做解釋了,之前有嘗試使用過 PostSharp 一陣子,但是對我來說 CP 值不高,所以之後一直都是使用 Castle DynamicProxy 來實作 AOP,不過 Castle DynamicProxy 除了效能耗損之外,另外一個比較大的問題是無法直接針對單一方法做 Proxy,而 AspectInjector 則沒有這些副作用,雖然不如 PostSharp 那樣的精緻,但是對我所面臨的需求而言,已經非常夠用了。

接著介紹一下我的實驗情境,我需要一個 LoggingAspect 來幫我記錄目標方法的名稱參數回傳值,那麼以一般的攔截器來說,能取得方法的名稱、參數、回傳值,大致上就能做一些事來滿足需求了。

Aspect

我們就從零開始,用 AspectInjector 打造一個 LoggingAspect,並且在這個過程中,一一解釋 AspectInjector 所提供的各種 Attribute 的作用,首先就是 Aspect

Aspect 是定義一個攔截器,它只能是類別。

[Aspect(Scope.Global)]
public class LoggingAspectAttribute : Attribute
{
    ...
}

Scope 參數是一個列舉型別,有 GlobalPerInstance,選擇 Global 的話,Aspect 會被建成 Singleton;選擇 PerInstance 的話,Aspect 的生命週期就會隨著被攔截的目標方法而生滅。

Injection

Injection 是選擇要注入的 Aspect,如果我們的 Aspect 跟最終操作的 Attribute 是同一個的話,Injection 的 Aspect 就是同一個。

[Aspect(Scope.Global)]
[Injection(typeof(LoggingAspectAttribute))]
public class LoggingAspectAttribute : Attribute
{
    ...
}

Inject 的 Aspect 可以有多個,Aspect 也可以另外宣告,最後再透過 Injection 合在一起,像這樣:

[Aspect(Scope.Global)]
public class LoggingAspect
{
    ...
}

[Aspect(Scope.Global)]
public class MeasurementAspect
{
    ...
}

[Injection(typeof(LoggingAspect))]
[Injection(typeof(MeasurementAspect))]
public class LoggingAttribute : Attribute
{
}

以上面這個例子來說,最終操作的是 Logging 這個 Attribute,它會組合注入的兩個 Aspect,而這樣的設計方式讓 Aspect 規劃上可以更有彈性。

Advice

Advice 是定義最終要與目標方式縫合(Weaving)的方法

[Aspect(Scope.Global)]
[Injection(typeof(LoggingAspectAttribute))]
public class LoggingAspectAttribute : Attribute
{
    [Advice(Kind.Before, Targets = Target.Method)]
    public void Before()
    {
        throw new NotImplementedException();
    }

    [Advice(Kind.After, Targets = Target.Method)]
    public void After()
    {
        throw new NotImplementedException();
    }

    [Advice(Kind.Around, Targets = Target.Method)]
    public object Around()
    {
        throw new NotImplementedException();
    }
}

AdviceAttribute 有兩個屬性,KindTargets,都是列舉型別。

  • Kind:有三個列舉值 BeforeAfterAround,分別是目標方法執行前目標方法執行後包著目標方法執行(需手動執行目標方法)
  • Targets:限定特定的目標方法,這個列舉值就多了,我就不一一列出來了,它最主要的作用是定義 Advice 可以與具有什麼樣特性的目標方法縫合,預設值是 Any,就是不限定。

縫合順序

中間我安插一個篇幅來說明當多個 Injection 加上多個 Advice 時,它的縫合順序會是怎麼樣? 原則上 Before → Around → After 這個順序是不變的,當多個相同類型的 Advice 時,規則是這樣的,依 Attribute 標記的順序,由上而下:

  • Before:依序將 Advice 插入在目標方法的最上面
  • Around:依序將 Advice 往內包裝目標方法。
  • After:依序將 Advice 插入在目標方法的最下面

底下有一個示意圖,輔助我的說明。

多個 Injection 加上多種類型的 Advice 時,原則沒變,先按照 Advice 類型的順序,再按照 Injection 的順序由上而下與目標方法縫合。

基本上,把握住縫合順序的原則,就不會亂了。

Argument

Argument 用來定義取得目標方法的資訊,包括名稱、參數、回傳值、目標方法實例、...等,需要傳入一個 Source 參數,也是一個列舉型別,用來定義參數的類型。

[Aspect(Scope.Global)]
[Injection(typeof(LoggingAspectAttribute))]
public class LoggingAspectAttribute : Attribute
{
    [Advice(Kind.Before, Targets = Target.Method)]
    public void Before([Argument(Source.Name)]string name, [Argument(Source.Arguments)]object[] arguments)
    {
        Console.WriteLine("On Before");
    }

    [Advice(Kind.After, Targets = Target.Method)]
    public void After([Argument(Source.Name)] string name, [Argument(Source.Arguments)] object[] arguments, [Argument(Source.ReturnValue)] object returnValue)
    {
        Console.WriteLine("On After");
    }

    [Advice(Kind.Around, Targets = Target.Method)]
    public object Around(
        [Argument(Source.Name)] string name,
        [Argument(Source.Arguments)] object[] arguments,
        [Argument(Source.Target)] Func<object[], object> target)
    {
        Console.WriteLine("On Around Before");

        var result = target(arguments);

        Console.WriteLine("On Around After");

        return result;
    }
}

大致上這樣就完成了,我們就可以把 LoggingAspect 標記在我們的目標方法上,這樣就會在建置的時候,把 Advice 跟目標方法縫起來。

以下是執行結果

如果要套用到整個目標類別上,就直接將 LoggingAspect 標記在目標類別就好了,這樣 Advice 就會依照 Targets 屬性,來決定要不要縫進目標類別內的方法裡面。

非同步方法

在 AspectInjector 的 GitHub 上有一小段字引起了我的注意:

you can have also After (async-aware), and Around(Wrap/Instead) kinds

重點是 async-aware 這個字 - 感知非同步,試了一下,果真非同步方法也能支援,Aspect 的程式碼一個字都不用改,而且 Advice 的縫合順序都沒有亂。

Mixin

Mixin 可以將介面及介面的實作混進 Aspect,讓有標記 Aspect 的目標類別直接是實作好該介面的狀態,舉個例子,有在 WPF 或 Xamarin.Forms 實作過 MVVM 模式朋友,應該都對 INotifyPropertyChanged 這個介面不陌生。

這個介面的實際作用我不多介紹,有興趣的朋友就自行 Google,它的綁定機制所引發的問題是造成大量重覆的程式碼,AOP 就可以用來消除這些重覆的程式碼,而 Mixin 的設計又讓整個過程變得更簡便。

來看個範例,假定我的 WPF 應用程式上有一個 TextBlock,與 MainWindowViewModel 的 MyText 屬性做 OneWay 綁定,指定用 PropertyChanged 的方式來做更新,MyText 的初始值為 "Hello World",我有一個 Button 會去將 MyText 的值改為 "abc"。

我們的 MainWindowViewModel 必須實作 INotifyPropertyChanged 介面,並且在 MyText 的 Setter 中呼叫 PropertyChanged 事件,與 MyText 相關的綁定才會生效。

public class MainWindowViewModel : INotifyPropertyChanged
{
    private string myText;

    public MainWindowViewModel()
    {
        this.MyText = "Hello World";
    }

    public event PropertyChangedEventHandler PropertyChanged;

    public string MyText
    {
        get => this.myText;
        set
        {
            this.myText = value;
            this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(this.MyText)));
        }
    }
}

所以,我們就可以想像到為什麼會有大量重覆的程式碼,因為一個被綁定的 Property 就要寫一次,而 Mixin 可以幫我們省掉這些工作,底下我們就用 Mixin 製作一個 NotifyAspectAttribute,關鍵的地方在於 NotifyAspectAttribute 要實作 INotifyPropertyChanged,並且用 MixinAttribute 將 INotifyPropertyChanged 混進來。

[AttributeUsage(AttributeTargets.Class)]
[Injection(typeof(NotifyAspectAttribute))]
[Aspect(Scope.PerInstance)]
[Mixin(typeof(INotifyPropertyChanged))]
public class NotifyAspectAttribute : Attribute, INotifyPropertyChanged
{
    public event PropertyChangedEventHandler PropertyChanged;

    [Advice(Kind.After, Targets = Target.Public | Target.Setter)]
    public void AfterSetter([Argument(Source.Instance)] object sender, [Argument(Source.Name)] string propertyName)
    {
        this.PropertyChanged?.Invoke(sender, new PropertyChangedEventArgs(propertyName));
    }
}

然後我們就在 MainWindowViewModel 上標記 [NotifyAspect],就大功告成了,可以看到 MainWindowViewModel 的程式碼看起來舒服很多了,而且 NotifyAspectAttribute 是可以重覆利用的,放到任何的 ViewModel 都有作用,這個就是 Mixin 的威力。

[NotifyAspect]
public class MainWindowViewModel
{
    public MainWindowViewModel()
    {
        this.MyText = "Hello World";
    }

    public string MyText { get; set; }
}

另外,我覺得 AspectInjector 有一點做得很好,就是它在開發時期的提示,舉個例子,我如果宣告 Around Advice 是一個 void,它就提示為錯誤,而且建置不會過。

類似的提示在開發過程中都會不時地跳出來,跟著提示去處理,幾乎不會踩到雷,這樣一個用心的 AOP 框架,推薦給各位朋友。

參考資料

 < Source Code >

C# 指南 ASP.NET 教學 ASP.NET MVC 指引
Azure SQL Database 教學 SQL Server 教學 Xamarin.Forms 教學