.NET靜態程式碼織入——肉夾饃(Rougamo)釋出2.0

nigture發表於2023-10-10

肉夾饃(https://github.com/inversionhourglass/Rougamo)透過靜態程式碼織入方式實現AOP的元件,其主要特點是在編譯時完成AOP程式碼織入,相比動態代理可以減少應用啟動的初始化時間讓服務更快可用,同時還能對靜態方法進行AOP。

擺爛半年又一更,感謝各位的支援,那麼就不說廢話了,下面開始介紹2.0推出的新功能吧。對於首次接觸肉夾饃的朋友,可以先檢視我之前的文章,或者直接到專案的github上檢視最新的README.

新功能

部分織入

肉夾饃在1.x經過數次迭代,新增了數個新功能,但對於絕大部分使用者來說並用不到全部的功能。比如你只想在方法執行成功或失敗的時候執行一些日誌操作,你並不需要重寫引數、修改返回值或處理異常,甚至都不需要在OnEntryOnExit中執行操作,但在1.x版本中,無論你是否需要,都會把這段處理程式碼織入到目標方法中。這在無形中增加了目標程式集的大小,同時也會在執行時使你多執行幾個分支判斷。在2.0版本中,可以透過重寫Features屬性來選擇你使用到的功能。Features可選值如下表所示:

列舉值 功能
All 包含全部功能,預設值
OnEntry 僅OnEntry,不可修改引數值,不可修改返回值
OnException 僅OnException,不可處理異常
OnSuccess 僅OnSuccess,不可修改返回值
OnExit OnExit
RewriteArgs 包含OnEntry,同時可以在OnEntry中修改引數值
EntryReplace 包含OnEntry,同時可以在OnEntry中修改返回值
ExceptionHandle 包含OnException,同時可以在OnEntry中處理異常
SuccessReplace 包含OnSuccess,同時可以在OnSuccess中修改返回值
ExceptionRetry 包含OnException,同時可以在OnException中進行重試
SuccessRetry 包含OnSuccess,同時可以在OnSuccess中進行重試
Retry 包含OnException和OnSuccess,同時可以在OnException和OnSuccess中進行重試
Observe 包含OnEntry、OnException、OnSuccess和OnExit,常用於日誌、APM埋點等操作
NonRewriteArgs 包含除修改引數外的所有功能
NonRetry 包含除重試外的所有功能

比如我只想在string型別的方法引數為null時給其賦初值,此時重寫Features指定為RewriteArgs,甚至可以省略try..catch..finally的生成:

public class DefaultArgsAttribute : MoAttribute
{
    // 可以反編譯對比一下重寫和不重寫Features的區別
    public override Feature Features => Feature.RewriteArgs;

    public override void OnEntry(MethodContext context)
    {
        var parameters = context.Method.GetParameters();
        for (var i = 0; i < parameters.Length; i++)
        {
            if (parameters[i].ParameterType == typeof(string) && context.Arguments[i] == null)
            {
                context.Arguments[i] = string.Empty;
                context.RewriteArguments = true;
            }
        }
    }
}

支援屬性和構造方法

屬性這個是老欠賬了,構造方法相反是趕上了末班車。在1.x版本MoAttribute是不能直接應用到屬性上的,只能應用到gettersetter上,現在直接應用到屬性上是同時應用到gettersetter上。同樣的,1.x版本是不支援應用到構造方法上,現在是可以的。不過在應用到構造方法時需要謹慎使用,不當的使用容易出現欄位/屬性未初始化的情況。

除了能夠直接將MoAttribute應用到屬性和方法上,在將MoAttribute應用到類或程式集時也可以透過Flags屬性來選擇到屬性和構造方法。Flags新增列舉值Method/PropertyGetter/PropertySetter/Property/Constructor分別代表普通方法、屬性getter、屬性setter、屬性getter&setter、構造方法。需要注意的是,在不指定這些值中的任意一個時,預設值為Method|Property,至於為什麼,因為在沒推出這個功能前,預設就是這樣,現在保持與之前的邏輯一致。當不重寫Flags屬性時,預設匹配所有public的例項方法和屬性。

public class TestAttribute : MoAttribute
{
    // 匹配所有普通方法(除屬性和構造方法外的方法)
    public override AccessFlags Flags => AccessFlags.All | AccessFlags.Method;

    // 匹配所有例項屬性getter
    public override AccessFlags Flags => AccessFlags.Instance | AccessFlags.PropertyGetter;

    // 匹配所有非public的構造方法
    public override AccessFlags Flags => AccessFlags.NonPublic | AccessFlags.Constructor;
}

支援排序

如果有朋友使用肉夾饃實現了多個AOP元件,當多個元件同時對一個方法產生織入時,他們的執行順序是什麼樣的,多個Attribute直接方法級別應用那肯定是按你程式碼從上到下的順序,那如果你還有應用到類、程式集或者透過代理Attribute、IRougamo實現的呢,此時順序又是什麼樣的。其實即使我現在告訴大家是什麼樣的,大家也記不住,我也記不住,所以直接設定一個排序值才是最直觀的方式。

public class SortTestAttribute : MoAttribute
{
    // 直接重寫Order屬性,不重寫時值為0,執行時按從小到大的順序執行
    public override double Order => 1.23;
}

// 當然也可以應用的時候指定
[assembly: SortTest(Order = 3.14)]

表示式匹配

好了,讓版本號變成2.0而不是1.5的功能來了。

Flags在第一個版本的時候就推出了,目的是希望能夠在批次應用的場景下提供一些過濾功能。但如你所見,即使到了2.0版本,它能夠過濾的特徵依然有限,這個限制是列舉給到的,無法使用列舉實現很複雜的過濾功能,這會讓列舉變成窮舉,體驗極差。其實在方法檢索上早已有了一個很優秀的方案,那就是java一個廣為認知的AOP元件aspectj,字串表示式的可擴充套件性是列舉遠不能比的。所以肉夾饃採用了同樣的方式和相似的語法實現了C#的方法表示式匹配。熟悉aspectj的朋友可能會很容易上手,不過推薦還是看完一遍介紹後再使用,肉夾饃新增了一些針對C#的語法格式。

先來一個簡單的示例。

public class PatternAttribute : MoAttribute
{
    // 使用表示式匹配,可以輕鬆進行方法名稱匹配
    // 匹配所有方法名以Get開頭的方法
    public override string? Pattern => "method(* *.Get*(..))";

    // 覆蓋了特徵匹配功能(除了構造方法)
    // 匹配所有public靜態方法
    public override string? Pattern => "method(public static * *(..))";

    // 匹配所有getter
    public override string? Pattern => "getter(* *)";

    // 還能進行子類匹配
    // 匹配所有返回值是int集合的方法
    public override string? Pattern => "method(int[]||System.Collections.Generic.IEnumerable<int>+ *(..))";

    // 更多匹配規則,請檢視後面的介紹
}

基礎概念

特徵匹配是重寫Flags屬性,對應的表示式匹配是重寫Pattern屬性,由於表示式匹配和特徵匹配都是用於過濾/匹配方法的,所以兩個不能同時使用,Pattern優先順序高於Flags,當Pattern不為null時使用Pattern,否則使用Flags

表示式共支援六種匹配規則,表示式必須是六種的其中一種:

  • method([modifier] returnType declaringType.methodName([parameters]))
  • getter([modifier] propertyType declaringType.propertyName)
  • setter([modifier] propertyType declaringType.propertyName)
  • property([modifier] propertyType declaringType.propertyName)
  • execution([modifier] returnType declaringType.methodName([parameters]))
  • regex(REGEX)

上面的六種規則中,getter, setter, property分別表示匹配屬性的getter, setter和全部匹配(getter+setter),method表示匹配普通方法(非getter/setter/constructor),execution表示匹配所有方法,包含getter/setterregex是個特例,將在正則匹配中進行單獨介紹。在表示式內容格式上,methodexecutiongetter/setter/property多一個([parameters]),這是因為屬性的型別即可表示屬性getter的返回值型別和setter的引數型別,所以相對於methodexecution,省略了引數列表。

上面列出的六種匹配規則,除了regex的格式特殊,其他的五種匹配規則的內容主要包含以下五個(或以下)部分:

  • [modifier],訪問修飾符,可以省略,省略時表示匹配所有,訪問修飾符包括以下七個:
    • private
    • internal
    • protected
    • public
    • privateprotected,即private protected
    • protectedinternal,即protected internal
    • static,需要注意的是,省略該訪問修飾符表示既匹配靜態也匹配例項,如果希望僅匹配例項,可以與邏輯修飾符!一起使用:!static
  • returnType,方法返回值型別或屬性型別,型別的格式較為複雜,詳見型別匹配格式
  • declaringType,宣告該方法/屬性的類的型別,型別匹配格式
  • methodName/propertyName,方法/屬性的名稱,名稱可以使用*進行模糊匹配,比如*Async,Get*,Get*V2等,*匹配0或多個字元
  • [parameters],方法引數列表,Rougamo的引數列表匹配相對簡單,沒有aspectj那麼複雜,僅支援任意匹配和全匹配
    • 使用..表示匹配任意引數,這裡說的任意是指任意多個任意型別的引數
    • 如果不進行任意匹配,那麼就需要指定引數的個數及型別,當然型別是按照型別匹配格式進行匹配的。Rougamo不能像aspectj一樣進行引數個數模糊匹配,比如int,..,double是不支援的

在上面列出的六種匹配規則中不包含構造方法的匹配,主要原因在於構造方法的特殊性。對構造方法進行AOP操作其實是很容易出現問題的,比較常見的就是在AOP時使用了還未初始化的欄位/屬性,所以我一般認為,對構造方法進行AOP時一般是指定特定構造方法的,一般不會進行批次匹配織入。所以目前對於構造方法的織入,推薦直接在構造方法上應用Attribute進行精確織入。另外由於Flags對構造方法的支援和表示式匹配都是在2.0新增的功能,目前並沒有想好構造方法的表示式格式,等大家使用一段時間後,可以綜合大家的建議再考慮,也為構造方法的表示式留下更多的操作空間。

型別匹配格式

型別格式

首先我們明確,我們表達某一個型別時有這樣幾種方式:型別名稱;名稱空間+型別名稱;程式集+名稱空間+型別名稱。由於Rougamo的應用上限是程式集,同時為了嚴謹,Rogamo選擇使用名稱空間+型別名稱來表達一個型別。名稱空間和型別名稱之間的連線採用我們常見的點連線方式,即名稱空間.型別名稱

巢狀類

巢狀類雖然使用不多,但該支援的還是要支援到。Rougamo使用/作為巢狀類連線符,這裡與平時程式設計習慣裡的連線符+不一致,主要是考慮到+是一個特殊字元,表示子類,為了方便閱讀,所以採用了另一個符號。比如a.b.c.D/E就表示名稱空間為a.b.c,外層類為D的巢狀類E。當然巢狀類支援多層巢狀。

泛型

需要首先宣告的是,泛型和static一樣,在不宣告時匹配全部,也就是既匹配非泛型型別也匹配泛型型別,如果希望僅匹配非泛型型別或僅匹配泛型型別時需要額外定義,泛型的相關定義使用<>表示。

  • 僅匹配非泛型型別:a.b.C<!>,使用邏輯非!表示不匹配任何泛型
  • 匹配任意泛型:a.b.C<..>,使用兩個點..表示匹配任意多個任意型別的泛型
  • 匹配指定數量任意型別泛型:a.b.C<,,>,示例表示匹配三個任意型別泛型,每新增一個,表示額外匹配一個任意型別的泛型,你可能已經想到了a.b.C<>表示匹配一個任意型別的泛型
  • 開放式與封閉式泛型型別:未確定泛型型別的稱為開放式泛型型別,比如List<T>,確定了泛型型別的稱為封閉式泛型型別,比如List<int>,那麼在編寫匹配表示式時,如果希望指定具體的泛型,而不是像上面介紹的那種任意匹配,那麼對於開放式未確定的泛型型別,可以使用我們常用的T1,T2,TA,TX等表示,對於封閉式確定的泛型型別直接使用確定的型別即可。
    // 比如我們有如下泛型型別
    public class Generic<T1, T2>
    {
        public static void M(T1 t1, int x, T2 t2) { }
    }
    
    // 定義匹配表示式時,對於開放式泛型型別,並不需要與型別定義的泛型名稱一致,比如上面叫T1,T2,表示式裡用TA,TB
    public class TestAttribute : MoAttribute
    {
        public override string? Pattern => "method(* *<TA,TB>.*(TA,int,TB))";
    }
    
  • 泛型方法:除了類可以定義泛型引數,方法也可以定義泛型引數,方法的泛型引數與型別的泛型引數使用方法一致,就不再額外介紹了
    // 比如我們有如下泛型型別
    public class Generic<T1, T2>
    {
        public static void M<T3, T4>(T1 t1, T2 t2, T3 t3, T4 t4) { }
    }
    
    // 定義匹配表示式時,對於開放式泛型型別,並不需要與型別定義的泛型名稱一致,比如上面叫T1,T2,表示式裡用TA,TB
    public class TestAttribute : MoAttribute
    {
        public override string? Pattern => "method(* *<TA,TB>.*<TX, TY>(TA,TB,TX,TY))";
    
        // 同樣可以使用非泛型匹配、任意匹配和任意型別匹配
        // public override string? Pattern => "method(* *<TA,TB>.*<..>(TA,TB,*,*))";
    }
    

模糊匹配

在前面介紹過兩種模糊匹配,一種是名稱模糊匹配*,一種是引數/泛型任意匹配..。在型別的模糊匹配上依舊使用的是這兩個符號。

型別格式中介紹到,型別格式由兩部分組成名稱空間.型別名稱,所以型別的模糊匹配可以分為:名稱空間匹配、型別名稱匹配、泛型匹配、子類匹配,其中泛型匹配在上一節剛介紹過,子類匹配將在下一節介紹,本節主要講述型別基本的模糊匹配規則。

  • 型別名稱匹配:型別名稱的模糊匹配很簡單,可以使用*匹配0或多個字元,比如*Service,Mock*,Next*Repo*V2等。需要注意的是,*並不能直接匹配任意巢狀型別,比如期望使用*Service*來匹配AbcService+Xyz是不可行的,巢狀型別需要明確指出,比如*Service/*,匹配名稱以Service結尾的型別的巢狀類,如果是二層巢狀類,也需要明確指出*Service/*/*
  • 名稱空間匹配
    • 預設匹配:在名稱空間預設的情況下表示匹配任意名稱空間,也就是隻要型別名稱即可,比如表示式Abc可以匹配l.m.n.Abc也可以匹配x.y.z.Abc
    • 完全匹配:不使用任何萬用字元,編寫完全的名稱空間,即可進行完全匹配
    • 名稱模糊:名稱空間有一或多段,每一段之間用.連線,和型別名稱匹配一樣,每一段的字元都可以使用*自行匹配,比如*.x*z.ab*.vv
    • 多段模糊:使用..可以匹配0或多段名稱空間,比如*..xyz.Abc可以匹配a.b.xyz.Abc也可以匹配lmn.xyz.Abc..也可以多次使用,比如使用a..internal..t*..Ab匹配a.internal.tk.Aba.b.internal.c.t.u.Ab

子類匹配

在前面介紹介面織入時有聊到,我們可以在父類/基礎介面實現一個空介面IRougamo<>,這樣繼承/實現了父類/基礎介面的型別的方法在條件匹配的情況下就會進行程式碼織入。那麼這種方式是需要修改父類/基礎介面才行,如果父類/基礎介面是引用的第三方庫或者由於流程原因不能直接修改,又該如何最佳化操作呢。此時就可以結合assembly attribute和子類匹配表示式來完成匹配織入了,定義匹配表示式method(* a.b.c.IService+.*(..)),這段表示式表示可匹配所有a.b.c.IService子類的所有方法,然後再透過[assembly: Xx]XxAttribute應用到整個程式集即可。

如上面的示例所示,我們使用+表示進行子類匹配。除了方法的宣告型別,返回值型別、引數型別都可以使用子類匹配。另外子類匹配還可以與萬用字元一起使用,比如method(* *(*Provider+))表示匹配方法引數僅一個且引數型別是以Provider結尾的型別的子類。

特殊語法

基礎型別簡寫

對於常用基礎型別,Rougamo支援型別簡寫,讓表示式看起來更簡潔清晰。目前支援簡寫的型別有bool, byte, short, int, long, sbyte, ushort, uint, ulong, char, string, float, double, decimal, object, void

Nullable簡寫

正如我們平時程式設計一樣,我們可以使用?表示Nullable型別,比如int?即為Nullable<int>。需要注意的是,不要將引用型別的Nullable語法也當做Nullable型別,比如string?其實就是string,在Rougamo裡面直接寫string,而不要寫成string?

ValueTuple簡寫

我們在編寫C#程式碼時,可以直接使用括號表示ValueTuple,在Rougamo中同樣支援該比如,比如(int,string)即表示ValueTuple<int, string>Tuple<int, string>

Task簡寫

現在非同步程式設計已經是基礎的程式設計方式了,所以方法返回值為TaskValueTask的方法將會非常之多,同時如果要相容TaskValueTask兩種返回值,表示式還需要使用邏輯運運算元||進行連線,那將大大增加表示式的複雜性。Rougamo增加了熟悉的async關鍵字用來匹配TaskValueTask返回值,比如Task<int>ValueTask<int>可以統一寫為async int,那麼對於非泛型的TaskValueTask則寫為async null。需要注意的是,目前沒有單獨匹配async void的方式,void會匹配voidasync void

型別及方法簡寫

前面有介紹到,型別的表達由名稱空間.型別名稱組成,如果我們希望匹配任意型別時,標準的寫法應該是*..*,其中*..表示任意名稱空間,後面的*表示任意型別名稱,對於任意型別,我們可以簡寫為*。同樣的,任意型別的任意方法的標準寫法應該是*..*.*,其中前面的*..*表示任意型別,之後的.是連字元,最後的*表示任意方法,這種我們同樣可以簡寫為*。所以method(*..* *..*.*(..))method(* *(..))表達的意思相同。

正則匹配

對於每個方法,Rougamo都會為其生成一個字串簽名,正則匹配即是對這串簽名的正則匹配。其簽名格式與method/execution的格式類似modifiers returnType declaringType.methodName([parameters])

  • modifiers包含兩部分,一部分是可訪問性修飾符,即private/protected/internal/public/privateprotected/protectedinternal,另一部分是是否靜態方法static,非靜態方法省略static關鍵字,兩部分中間用空格分隔。
  • returnType/declaringType均為名稱空間.型別名稱的全寫,需要注意的是,在正則匹配的簽名中所有的型別都是全名稱,不可使用類似int去匹配System.Int32
  • 泛型,型別和方法都可能包含泛型,對於封閉式泛型型別,直接使用型別全名稱即可;對於開放式泛型型別,我們遵守以下的規定,泛型從T1開始向後增加,即T1/T2/T3...,增加的順序按declaringTypemethod後的順序,詳細可看後續的示例
  • parameters,引數按每個引數的全名稱展開即可
  • 巢狀型別,巢狀型別使用/連線
namespace a.b.c;

public class Xyz
{
    // 簽名:public System.Int32 a.b.c.Xyz.M1(System.String)
    public int M1(string s) => default;

    // 簽名:public static System.Void a.b.c.Xyz.M2<T1>(T1)
    public static void M2<T>(T value) { }

    public class Lmn<TU, TV>
    {
        // 簽名:internal System.Threading.Tasks.Task<System.DateTime> a.b.c.Xyz/Lmn<T1,T2>.M3<T3,T4>(T1,T2,T3,T4)
        internal Task<DateTime> M3<TO, TP>(TU u, TV v, TO o, TP p) => Task.FromResult(DateTime.Now);

        // 簽名:private static System.Threading.Tasks.ValueTask a.b.c.Xyz/Lmn<T1,T2>.M4()
        private static async ValueTask M4() => await Task.Yeild();
    }
}

正則匹配存在編寫複雜的問題,同時也不支援子類匹配,所以一般不編寫正則匹配規則,其主要是作為其他匹配規則的一種補充,可以支援一些更為複雜的名稱匹配。由於Rougamo支援邏輯運演演算法,所以也給到正則更多輔助的空間,比如我們想要查詢方法名不以Async結尾的Task/ValueTask返回值方法method(async null *(..)) && regex(^\S+ (static )?\S+ \S+?(?<!Async)\()

最佳化、修復及配置

織入程式碼最佳化

由於我們可以在一個方法上應用多個MoAttribute,所以在1.x版本中使用陣列儲存所有的Mo。但大多數情況下,我們一個方法只有一個Mo,此時使用陣列來儲存顯得有些浪費,即使有三個Mo同時使用,實際上使用陣列儲存也不划算,因為陣列的操作指令比較多,相比而言單變數操作指令就簡單很多。所以在2.0版本中,預設4個Mo以下的情況下為每個Mo單獨定義變數,4個及以上使用陣列,該設定可以透過配置項moarray-threshold修改。修改方式參考 README 中的說明。

修復應用Attribute時指定Flags無效

這是社群反饋的 issue,感謝各位反饋的bug和建議。

// issue反饋的是這種應用時指定Flags無效
[FlagsTest(Flags = AccessFlags.Instance)]
public class Test
{
    // ...
}

啟用綜合可訪問性配置

首先明確一點,透過FlagsPattern都可以指定匹配方法的可訪問性及其他匹配規則,但是在將MoAttribute直接應用於方法上時,這些匹配規則是無效的,你都懟臉上了,我當然是讓你生效的。那麼在更高層次應用時就會出現一個問題,除了方法具有可訪問性,類同樣具有可訪問性,比如你方法是public的,但是你的型別是internal的,那實際上你的方法的綜合可訪問性還是internal。考慮到一般我們說一個方法的可訪問性是直接說的方法本身的可訪問性,所以預設情況下可訪問性匹配的是方法本身的可訪問性,同時增加配置項composite-accessibility,設定為true時表示使用綜合可訪問性。需要注意的是,這個綜合可訪問性僅對Pattern生效,對Flags無效。

這裡僅列出了2.0新增的配置項,如果希望瞭解其他配置項或配置的方式,可檢視 README 中的說明。

最後

隨著2.0的推出,也希望大家能多在批次應用上探索一下,直接將Attribute應用到方法上是靈活的用法但也是侵入性大的方式。當然,兩種方式配合使用才能讓體驗達到最優,這個就需要大家自己探索了。那麼本次的2.0版本介紹到此結束,感謝各位的支援和反饋,我們下個版本再見。

相關文章