肉夾饃(https://github.com/inversionhourglass/Rougamo)透過靜態程式碼織入方式實現AOP的元件,其主要特點是在編譯時完成AOP程式碼織入,相比動態代理可以減少應用啟動的初始化時間讓服務更快可用,同時還能對靜態方法進行AOP。
擺爛半年又一更,感謝各位的支援,那麼就不說廢話了,下面開始介紹2.0推出的新功能吧。對於首次接觸肉夾饃的朋友,可以先檢視我之前的文章,或者直接到專案的github上檢視最新的README.
新功能
部分織入
肉夾饃在1.x經過數次迭代,新增了數個新功能,但對於絕大部分使用者來說並用不到全部的功能。比如你只想在方法執行成功或失敗的時候執行一些日誌操作,你並不需要重寫引數、修改返回值或處理異常,甚至都不需要在OnEntry
和OnExit
中執行操作,但在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
是不能直接應用到屬性上的,只能應用到getter
和setter
上,現在直接應用到屬性上是同時應用到getter
和setter
上。同樣的,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/setter
。regex
是個特例,將在正則匹配中進行單獨介紹。在表示式內容格式上,method
和execution
比getter/setter/property
多一個([parameters])
,這是因為屬性的型別即可表示屬性getter
的返回值型別和setter
的引數型別,所以相對於method
和execution
,省略了引數列表。
上面列出的六種匹配規則,除了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.Ab
和a.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簡寫
現在非同步程式設計已經是基礎的程式設計方式了,所以方法返回值為Task
或ValueTask
的方法將會非常之多,同時如果要相容Task
和ValueTask
兩種返回值,表示式還需要使用邏輯運算子||
進行連線,那將大大增加表示式的複雜性。Rougamo增加了熟悉的async
關鍵字用來匹配Task
和ValueTask
返回值,比如Task<int>
和ValueTask<int>
可以統一寫為async int
,那麼對於非泛型的Task
和ValueTask
則寫為async null
。需要注意的是,目前沒有單獨匹配async void
的方式,void
會匹配void
和async 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...
,增加的順序按declaringType
先method
後的順序,詳細可看後續的示例 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
{
// ...
}
啟用綜合可訪問性配置
首先明確一點,透過Flags
和Pattern
都可以指定匹配方法的可訪問性及其他匹配規則,但是在將MoAttribute
直接應用於方法上時,這些匹配規則是無效的,你都懟臉上了,我當然是讓你生效的。那麼在更高層次應用時就會出現一個問題,除了方法具有可訪問性,類同樣具有可訪問性,比如你方法是public
的,但是你的型別是internal
的,那實際上你的方法的綜合可訪問性還是internal
。考慮到一般我們說一個方法的可訪問性是直接說的方法本身的可訪問性,所以預設情況下可訪問性匹配的是方法本身的可訪問性,同時增加配置項composite-accessibility
,設定為true
時表示使用綜合可訪問性。需要注意的是,這個綜合可訪問性僅對Pattern
生效,對Flags
無效。
這裡僅列出了2.0新增的配置項,如果希望瞭解其他配置項或配置的方式,可檢視 README 中的說明。
最後
隨著2.0的推出,也希望大家能多在批次應用上探索一下,直接將Attribute應用到方法上是靈活的用法但也是侵入性大的方式。當然,兩種方式配合使用才能讓體驗達到最優,這個就需要大家自己探索了。那麼本次的2.0版本介紹到此結束,感謝各位的支援和反饋,我們下個版本再見。