.NET靜態程式碼編織——肉夾饃(Rougamo)5.0

nigture發表於2024-12-10

肉夾饃(https://github.com/inversionhourglass/Rougamo),一款編譯時AOP元件。相比動態代理AOP需要在應用啟動時進行初始化,編譯時完成程式碼編織的肉夾饃減少了應用啟動初始化的時間,同時肉夾饃還支援所有種類的方法,無論方法是同步還是非同步、靜態還是例項、構造方法還是屬性都是支援的。肉夾饃無需初始化,編寫好切面型別後直接應用到對應方法上即可,同時肉夾饃還提供了方法特徵匹配和類AspectJ表示式匹配的批次應用規則。

前言

肉夾饃已經步入第五個大版本了,主要功能及最佳化已基本補全,在該版本之後,將在很長一段時間裡不再發布新的大版本。如果你還在考慮肉夾饃是否值得一試,不妨看看 PostSharp 精選的“2024主要AOP框架”(https://www.postsharp.net/solutions/aspect-oriented-programming#list-of-aop-frameworks-for.net)

是的,肉夾饃也在其列。對於這一結果,我個人是非常驚訝的,其他入選框架在 nuget.org 上的下載量都是肉夾饃的幾十上百倍,而且還有很多高下載量的框架沒有入選(MethodBoundaryAspect.Fody, MethodDecorator.Fody,
MrAdvice 等)。另外,對於 PostSharp 能夠發現肉夾饃這個專案,我也是感覺非常意外的,肉夾饃從未在外網做過宣發,同時由於初建專案時的靈機一動,給專案取了"Rougamo"這麼個怪名字,導致在各種搜尋引擎上不論搜尋"Rougamo"還是"AOP",都很難見到我這個肉夾饃。

如果你認可 PostSharp,那麼你也可以選擇嘗試肉夾饃。另外,肉夾饃在2024年連續釋出了三個大版本,現在肉夾饃的實際表現要遠遠超出 PostSharp 撰寫 aspect-oriented-programming 的時候。隨著 5.0 版本的釋出,大版本或將長期穩定在 5.x,現在便是入手的最佳時機。

5.0

好了,王婆賣瓜環節結束,現在進入正文。

5.0 的主要內容是最佳化,由於本次最佳化對切面型別和MethodContext的基本結構都有改動,無法相容 4.0 及之前的版本,所以作為一個大版本釋出。當然,除了最佳化還提供了一些新的功能,歡迎閱讀全文了解更多。

效能最佳化

切面型別屬性成員縮減

目前的切面型別包含眾多屬性,這些屬性均為配置項,基本只在編譯時供肉夾饃使用,在執行時並不需要,而切面型別在例項化時這些屬性都會佔用記憶體空間,所以 5.0 版本將刪除切面型別中的所有屬性成員,並提供對應的 Attribute 和介面,用於實現相同的功能。

在介紹具體改動之前,先再次回顧一下切面型別的基本情況。所有切面型別均實現IMo介面,所以刪除切面型別中的所有屬性成員,也就是刪除IMo中定義的所有屬性成員,以下實現IMo介面的型別都將受到影響:

IMo                        # 切面型別基礎介面,所有切面型別都需要實現該介面
├── RawMoAttribute         # 繼承Attribute,可自定義同步和非同步切面方法
│   ├── MoAttribute        # 僅可自定義同步切面方法,非同步切面方法使用呼叫同步切面方法的預設實現
│   └── AsyncMoAttribute   # 僅可自定義非同步切面方法,同步切面方法使用呼叫非同步切面方法的預設實現
└── RawMo                  # 與RawMoAttribute功能相同,唯一差別是RawMo未繼承Attribute
    ├── Mo                 # 與MoAttribute功能相同,唯一差別是Mo未繼承Attribute
    └── AsyncMo            # 與AsyncMoAttribute功能相同,唯一差別是AsyncMo未繼承Attribute

升級前的屬性與升級後的 Attribute 及介面的具體對應關係如下:

5.0 之前切面型別屬性 5.0 對應的Attribute 5.0 對應的Interface
Flags PointcutAttribute IFlexibleModifierPointcut
Pattern PointcutAttribute IFlexiblePatternPointcut
Features AdviceAttribute /
MethodContextOmits OptimizationAttribute /
ForceSync OptimizationAttribute /
Order / IFlexibleOrderable

使用程式碼展示升級前後的差異:

// 5.0之前的切面型別定義
public class TestAttribute : MoAttribute
{
    public override AccessFlags Flags => AccessFlags.All | AccessFlags.Method;

    public override string Pattern => "method(* *(..))";

    public override Feature Features => Feature.OnEntry;

    public override ForceSync ForceSync => ForceSync.All;

    public override Omit MethodContextOmits => Omit.None;

    public override double Order => 2;
}

// 5.0的切面型別定義
[Pointcut("method(* *(..))")] // Pattern 屬性和 Flags 屬性合併為該屬性,由於 Pattern 優先順序高於 Flags,在 Pattern 有值的情況下忽略 Flags 配置
[Advice(Feature.OnEntry)]     // Features 屬性遷移為該屬性
[Optimization(ForceSync = ForceSync.All, MethodContext = Omit.None)] // ForceSync 和 MethodContextOmits 合併為該屬性
public class T1Attribute : MoAttribute, IFlexibleOrderable           // 如果需要定義 Order,需要實現 IFlexibleOrderable 介面
{
    public double Order { get; set; } = 2;
}

看完上面的列表和程式碼後,你或許有個疑問“為什麼升級後有的只有 Attribute,有的只有介面,而有的兩個都有”。

這是結合用途綜合考慮的。前面介紹到,刪減屬性成員是為了最佳化切面型別例項化後的記憶體佔用。那麼 Attribute 作為後設資料,並不會在切面型別例項化時為每個例項單獨建立,所以基本所有屬性都可以使用 Attribute 代替。那麼什麼樣的配置需要提供介面呢?在 5.0 之前的版本可以透過new關鍵字為屬性增加 setter,然後在應用切面型別時透過屬性動態配置,如下程式碼所示:

// 5.0之前的用法
public class TestAttribute : MoAttribute
{
    // 預設Pattern只有getter,透過new關鍵字為Pattern增加setter
    public new Pattern { get; set; }
}

[Test(Pattern = "method(* *.Try*(..))")] // 應用Attribute動態指定Pattern
public class Cls { }

這種方式提供了一定的靈活性,在 5.0 版本中,對於需要繼續保持這種靈活性的配置提供了對應的介面。對於沒有提供對應介面的配置,表示該配置不會在應用 Attribute 動態配置(如果你有這種使用場景,可以新建 issue 具體聊聊)。

Roslyn程式碼分析器

本次的屬性成員變動較大,升級後手動修改會比較繁瑣,同時還可能出現遺漏。雖然肉夾饃在編譯時會對切面型別進行檢查,並在發現不符合規範的切面型別時產生一個編譯錯誤。但 5.0 提供了更好的升級體驗,新增 Roslyn 程式碼分析器和程式碼修復程式,可以在編寫程式碼時直接發現問題並提供快捷修復。

ref struct引數及返回值處理

在 5.0 版本中,MethodContextOmits屬性被遷移到OptimizationAttribute中。MethodContextOmits除了可以用來瘦身MethodContext,還可以用來處理ref struct檔案,詳見 #61. 雖然可以透過OptimizationAttribute設定Omit,但由於只提供了 Attribute 沒有提供介面,所以無法在應用 Attribute 時動態配置。不過 5.0 版本提供了更加方便的配置方式。

SkipRefStructAttribute

在 5.0 版本中新增SkipRefStructAttribute用於處理ref struct

public class TestAttribute : MoAttribute { }

[SkipRefStruct]
[Test]
public ReadOnlySpan<char> M(ReadOnlySpan<char> value) => default;

這種方式更加合理,如果方法上應用了多個切面型別,不再需要為每個切面型別指定MethodContextOmits,同時SkipRefStructAttribute還可以應用在類和程式集上,可以在更大範圍上宣告忽略ref struct

配置項skip-ref-struct

除了SkipRefStructAttribute的方式,在確定當前程式集預設忽略ref struct的情況下,還可以透過配置項skip-ref-struct為當前程式集應用這個設定,配合 Cli4Fody 可以實現非侵入式配置,skip-ref-struct設定為true的效果等同於[assembly: SkipRefStruct]

<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
	<Rougamo skip-ref-struct="true" />
</Weavers>

自定義切面型別生命週期

在 2.2 版本中,肉夾饃新增了 結構體,用於最佳化切面型別 GC。前段時間又編寫了一個 Pooling 元件,透過該元件可以在編譯時將切面型別的new操作替換為物件池操作(詳見部落格:.NET無侵入式物件池解決方案),原本計劃使用 Pooling 完成對肉夾饃 GC 的整體最佳化,但思來想去,還是決定將物件池功能內建,同時新增單例模式。

在 5.0 版本中,可以透過LifetimeAttribute指定切面型別的生命週期:

[Lifetime(Lifetime.Transient)] // 臨時,每次建立都是直接new。在沒有應用LifetimeAttribute時,預設為Transient
public class Test1Attribute : MoAttribute { }

[Lifetime(Lifetime.Pooled)] // 物件池,每次建立都從物件池中獲取
public class Test2Attribute : MoAttribute { }

[Lifetime(Lifetime.Singleton)] // 單例
public class Test3Attribute : MoAttribute { }

需要注意的是,不是所有切面型別無腦設定為物件池或單例模式即可完成最佳化。選擇生命週期時需要注意以下幾點:

  1. Singleton要求切面型別必須是無狀態的,必須包含無參構造方法,且應用切面型別時不可呼叫有參構造方法或設定屬性

    [Lifetime(Lifetime.Singleton)]
    public class SingletonAttribute : MoAttribute
    {
        public SingletonAttribute() { }
    
        public SingletonAttribute(int value) { }
    
        public int X { get; set; }
    }
    
    [Singleton(1)]     // 編譯時報錯,不可呼叫有參構造方法
    [Singleton(X = 2)] // 編譯時報錯,不可設定屬性
    
  2. Pooled要求切面型別必須包含無參構造方法,且應用切面型別時不可呼叫有參構造方法。如果切面型別有狀態可實現Rougamo.IResettable介面,並在TryReset方法中完成狀態重置,或在OnExit / OnExitAsync中完成狀態重置

    [Lifetime(Lifetime.Pooled)]
    public class PooledAttribute : MoAttribute, IResettable
    {
        public SingletonAttribute() { }
    
        public SingletonAttribute(int value) { }
    
        public int X { get; set; }
    
        public override void OnExit(MethodContext context)
        {
            // 可以在OnExit中狀態重置,比如將X重置為0
            X = 0;
        }
    
        public bool TryReset()
        {
            // 也可以實現IResettable介面,在該方法中完成狀態重置
            X = 0;
    
            // 返回true表示重置成功,返回false,當前物件將會直接拋棄,不會返回到物件池中
            return true;
        }
    }
    
    [Pooled(1)]     // 編譯時報錯,不可呼叫有參構造方法
    [Pooled(X = 2)] // 支援的操作,所以如果需要在應用時設定一些狀態,可以用屬性的方式而不要用構造方法引數的方式
    
  3. 結構體切面型別無法定義生命週期

總結來說,如果可以將切面型別設計為無狀態的,推薦使用Singleton;如果無法保證無狀態,但可以管理好狀態的重置,推薦使用Pooled;如果無法很好的管理狀態,可以使用結構體(但結構體無法繼承 Attribute,所以無法在應用時像 Attribute 那樣透過構造引數和屬性動態配置);最後,如果對 GC 最佳化要求沒那麼嚴格,使用預設的無拘無束的Transient即可。

MethodContext物件池化

在 5.0 版本中,MethodContext將預設從物件池中獲取,這一預設行為將在較大程度上最佳化Rougamo產生的GC。

MethodContext的物件池和切面型別的物件池用的是同一個,可以透過環境變數設定物件池最大持有數量,預設為CPU邏輯核心數 * 2(不同型別分開)。

環境變數 說明
NET_ROUGAMO_POOL_MAX_RETAIN 物件池最大持有物件數量,對所有型別生效
NET_ROUGAMO_POOL_MAX_RETAIN_<TypeFullName> 指定型別物件池最大持有物件數量,覆蓋NET_ROUGAMO_POOL_MAX_RETAIN配置,<TypeFullName>為指定型別的全名稱,名稱空間分隔符.替換為_

異常堆疊資訊最佳化

關聯 [#82]

Rougamo 自 4.0 版本開始全面使用代理織入的方式,由於該方式會為被攔截方法額外生成一個代理方法,所以在堆疊資訊中會額外產生一層呼叫堆疊,在程式丟擲異常時,呼叫堆疊會顯得複雜且冗餘:

// 測試程式碼
public class TestAttribute : MoAttribute { }

try
{
    await M1();
}
catch (Exception e)
{
    Console.WriteLine(e);
}

[Test]
public static async Task M1() => await M2();

[Test]
public static async ValueTask M2()
{
    await Task.Yield();
    M3();
}

[Test]
public static void M3() => throw new NotImplementedException();

在 5.0 之前,上面程式碼在 .NET 6.0 中執行的結果為(不同.NET版本堆疊資訊可能有些差異,早期的Framework版本將更加冗長):

System.NotImplementedException: The method or operation is not implemented.
   at X.Program.$Rougamo_M3() in D:\X\Y\Z\Program.cs:line 49
   at X.Program.M3()
   at X.Program.$Rougamo_M2() in D:\X\Y\Z\Program.cs:line 43
   at X.Program.M2()
   at X.Program.M2()
   at X.Program.$Rougamo_M1() in D:\X\Y\Z\Program.cs:line 36
   at X.Program.M1()
   at X.Program.M1()
   at X.Program.Main(String[] args) in D:\X\Y\Z\Program.cs:line 13

5.0 版本之後,執行結果為:

System.NotImplementedException: The method or operation is not implemented.
   at X.Program.$Rougamo_M3() in D:\X\Y\Z\Program.cs:line 49
   at X.Program.$Rougamo_M2() in D:\X\Y\Z\Program.cs:line 43
   at X.Program.$Rougamo_M1() in D:\X\Y\Z\Program.cs:line 36
   at X.Program.Main(String[] args) in D:\X\Y\Z\Program.cs:line 13

這種異常堆疊最佳化在 .NET 6.0 及之後的 .NET 版本中是預設的,不需要任何操作,但對於 .NET 6.0 之前的版本,需要呼叫Exception的擴充套件方法ToNonRougamoString來獲取最佳化後的ToString字串,或者呼叫Exception的擴充套件方法GetNonRougamoStackTrace獲取最佳化後的呼叫堆疊。

之所以 .NET 6.0 之後預設支援異常堆疊最佳化,是因為 .NET 6.0 之後呼叫堆疊會預設排除應用了StackTraceHiddenAttribute的方法。

關於最佳化後堆疊資訊預設方法名自帶$Rougamo_字首的說明

代理織入使得實際方法全部增加$Rougamo_字首,所以只有$Rougamo_字首的方法菜與 PDB 資訊對應,可以獲取行號等資訊。不做額外處理去除字首是因為沒有必要,字首固定不會影響閱讀分析,額外操作去除字首影響效能,同時也會導致 .NET 6.0 及以上版本無法無感知完成最佳化。如果確實想要去除該字首,請自行處理。

另外,如果你有特殊需求不需要這種堆疊資訊最佳化,可以將配置項pure-stacktrace設定為false

<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
	<Rougamo pure-stacktrace="false" />
</Weavers>

AspectN新語法

新增ctorcctor,用於用於快速匹配構造方法和靜態構造方法。

  • ctor(<declaring-type>([<parameter-type>..]))

    // 匹配所有構造方法
    [Pointcut("ctor(*(..))")]
    
    // 匹配所有非泛型型別的構造方法
    [Pointcut("ctor(*<!>(..))")]
    
    // 匹配IService子類的構造方法
    [Pointcut("ctor(IService+(..))")]
    
    // 匹配所有無參構造方法
    [Pointcut("ctor(*())")]
    
    // 匹配所有包含三個引數(任意型別)的構造方法
    [Pointcut("ctor(*(,,))")]
    
    // 匹配兩個引數分別為int和Guid的構造方法
    [Pointcut("ctor(*(int,System.Guid))")]
    
  • cctor(<declaring-type>)

    // 匹配所有靜態構造方法
    [Pointcut("cctor(*)")]
    
    // 匹配類名包含Singleton的靜態構造方法
    [Pointcut("cctor(*Singleton*)")]
    

配置化非侵入式織入

5.0 版本可以透過配置FodyWeavers.xml完成切面型別應用,而不必再新增/修改C#程式碼。

<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
  <Rougamo>
    <Mos>
      <Mo assembly="Rougamo.OpenTelemetry" type="Rougamo.OpenTelemetry.OtelAttribute" pattern="method(* *Service.*(..))"/>
    </Mos>
  </Rougamo>
</Weavers>

上面的配置中,每一個Mo節點為一條應用規則,Mos節點下可以定義多個Mo節點,下面是Mo節點的屬性說明:

  • type,切面型別全名稱
  • assembly,切面型別所在程式集名稱(不要.dll字尾)
  • pattern,AspectN 表示式,切面型別type將應用到該表示式匹配的方法上。該配置可選,未設定時將採用切面型別type自身的匹配規則

配置結合 Cli4Fody 和 CI 流程可以實現零侵入式的程式碼織入,詳細可參考 .NET無侵入式物件池解決方案——零侵入式池化操作

其他更新

  • 刪除配置項moarray-threshold

    該配置項是用陣列最佳化大量切面型別應用到方法時,用遍歷陣列執行切面方法的方式代替每個切面型別單獨執行切面方法,以達到精簡MSIL的目的。

    但隨著Rougamo的功能完善,在 4.0 版本中因非同步切面的加入,使得非同步方法無法使用陣列達到預期最佳化。在 5.0 版本中,隨著物件池的加入,同步方法也難以使用陣列完成預期最佳化。

    綜合複雜度和實際最佳化效果考慮,最終決定在 5.0 版本中移除配置項moarray-threshold

  • 新增ISyncMoIAsyncMo介面

    由於結構體無法繼承父類/父結構體,所以在定義結構體切面型別時只能直接實現IMo介面,但該介面包含全部同步/非同步切面方法,全部實現比較繁瑣。

    肉夾饃在 5.0 版本中新增ISyncMoIAsyncMo,透過 預設介面方法 對部分方法提供預設實現。

    預設介面方法需要 SDK 最低 .NET Core 3.0 的版本,所以只有 .NET Core 3.0 及以上版本才有ISyncMoIAsyncMo兩個介面。

    // 實現ISyncMo介面可以不用實現非同步切面方法
    [Pointcut("method(* *(..))")]
    public struct SyncMo : ISyncMo
    {
        public void OnEntry(MethodContext context) { }
    
        public void OnException(MethodContext context) { }
    
        public void OnExit(MethodContext context) { }
    
        public void OnSuccess(MethodContext context) { }
    }
    
    // 實現IAsyncMo介面可以不用實現同步切面方法
    [Pointcut("method(* *(..))")]
    public struct AsyncMo : IAsyncMo
    {
        public ValueTask OnEntryAsync(MethodContext context) => default;
    
        public ValueTask OnExceptionAsync(MethodContext context) => default;
    
        public ValueTask OnExitAsync(MethodContext context) => default;
    
        public ValueTask OnSuccessAsync(MethodContext context) => default;
    }
    
    // 應用切面型別
    [assembly: Rougamo<SyncMo>]
    [assembly: Rougamo(typeof(AsyncMo))]
    

Unity相關

如果要拿 Rougamo 與 PostSharp 進行對比並討論其優勢,那麼第一個鮮為人知的優勢就是肉夾饃開源免費,而另一個比較大的優勢可能就是 Unity 了。

根據我查詢的資料顯示,PostSharp 以及新推出的 Metalama 並不支援 Unity.

  • https://stackoverflow.com/a/20647529/3614672
  • https://support.postsharp.net/request/20888-support-for-unity3d

此前曾有朋友到 GitHub 中詢問如何在 Unity 中使用肉夾饃,但很可惜,我並不瞭解 Unity,所以當時並給有給出解決方案。後來直到 @gaozhou 帶著他的解決方案出現了。現在,我可以擲地有聲的回答——是的,肉夾饃支援 Unity.

具體解決方案,請檢視 https://github.com/inversionhourglass/Rougamo/issues/86#issuecomment-2378505655 。由於本人對 Unity 一竅不通,所以無法提供一個開箱即用的 Package,有興趣的朋友可以參考解決方案製作一個開箱即用的 Package 分享出來。

鳴謝

每次大版本釋出的時候,就是 Rougamo 發部落格刷存在的時候,但隨著 5.0 的釋出,大版本的釋出將陷入停滯(小版本還會發,但一般不會特意發部落格通告),肉夾饃的宣發也將隨之減少。感謝各位朋友長期以來的關注,感謝提供使用反饋的朋友們,感謝選擇使用肉夾饃的各開源專案。

相關文章