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

nigture發表於2021-12-21

肉夾饃是什麼

肉夾饃通過靜態程式碼織入方式實現AOP的元件。.NET常用的AOP有Castle DynamicProxyAspectCore等,以上兩種AOP元件都是通過執行時生成一個代理類執行AOP程式碼的,肉夾饃則是在程式碼編譯時直接修改原始方法IL程式碼,在原始方法內織入AOP程式碼的。.NET靜態AOP的元件或許有人使用過PostSharp,這是一個功能完善且強大的靜態程式碼織入元件,Postsharp有社群版,但可惜的是社群版不支援非同步方法,肉夾饃的實現方式與Postsharp類似,同時也支援了非同步方法,如果你僅僅使用了Postsharp方法層級的AOP程式碼織入功能,可以嘗試使用肉夾饃來替代Postsharp。

快速開始

# 新增NuGet引用
dotnet add package Rougamo.Fody
// 1.定義類繼承MoAttribute,在該類中定義你在方法執行各階段需要織入的程式碼
public class LoggingAttribute : MoAttribute
{
    public override void OnEntry(MethodContext context)
    {
        // 從context物件中能取到包括入參、類例項、方法描述等資訊
        Log.Info("方法執行前");
    }

    public override void OnException(MethodContext context)
    {
        Log.Error("方法執行異常", context.Exception);
    }

    public override void OnExit(MethodContext context)
    {
        Log.Info("方法退出時,不論方法執行成功還是異常,都會執行");
    }

    public override void OnSuccess(MethodContext context)
    {
        Log.Info("方法執行成功後");
    }
}

// 2.在需要織入程式碼的方法上應用LoggingAttribute
public class Service
{
    [Logging]
    public static int Sync(Model model)
    {
        // ...
    }

    [Logging]
    private async Task<Data> Async(int id)
    {
        // ...
    }
}

通過實現空介面的方式進行程式碼織入

在上面的示例中,我們通過在方法上應用Attribute進行AOP,這種方式目標明確但有些AOP程式碼我們可能希望應用於某一場景或某一層級,每個方法都去應用Attribute很繁瑣,而且程式碼侵入嚴重。此時就可以考慮使用實現空介面(IRougamo<>)的方式進行批量Attribute應用

public interface IService : IRougamo<LoggingAttribute> { }

public interface IMyService : IService { }

public class MyService : IMyService
{
}

上面的示例中,MyService所有的public例項方法都將應用LoggingAttribute,你可能注意到我標紅的部分了,為什麼是public例項方法呢?這是預設值,你可以在繼承MoAttribute時通過重寫Flags屬性來修改這一預設值,比如下面的示例中FullLoggingAttribute將會應用於所有方法。另外需要注意的是Flags屬性在Attribute直接應用到方法上時是無效的,比如LoggingAttribute預設僅應用public例項方法,但像快速開始裡的程式碼那樣Async方法雖然是private的但還是會應用LoggingAttribute

public class FullLoggingAttribute : LoggingAttribute
{
    public override AccessFlags Flags => AccessFlags.All;
}

例項-Rougamo.OpenTelemetry

快速開始裡介紹了肉夾饃兩種常用的使用方式,更多的使用方式可以到github檢視readme,在本篇文章中就不再做更多介紹了,接下來我將介紹使用肉夾饃的一個專案Rougamo.OpenTelemetry,如果你準備使用肉夾饃,但你還是不太清楚具體應該怎麼使用,可以參考這個專案的程式碼實現。

關於OpenTelemetry

在瞭解OpenTelemetry前,你需要先了解APM(Application Performance Management/Monitor),在這個微服務的時代,APM已經成為了必不可少的一部分,沒有它整個系統對我們而言就是一個黑盒,你無法得知一個請求在微服務之間是如何呼叫如何完成,難以排查一個使用者超時是哪個服務超時或出錯。現在市面上有很多開源的APM比如Pinpoint, Zipkin, SkyWalking, CAT, jaeger等,雖說大家基本都是參考google的dapper論文設計出來的,但實現和功能側重卻大相徑庭,為了對此形成一個規範,先後出現了OpenTracingOpenCensus,並在此後合併為現在的OpenTelemetry。OpenTelemetry的出現為APM的接入提供了一種可能“應用不需要在意具體的APM服務端使用的是Zipkin還是jaeger或是其他的情況下,應用只需要使用OpenTelemetry的SDK進行埋點,APM通過實現OTLP(OpenTelemetry Protocol)來支援OpenTelemetry資料格式即可”,當前已經有些APM完全採用OpenTelemetry SDK作為預設的SDK比如jaeger,也有部分支援的APM比如skywalking。

關於Rougamo.OpenTelemetry

現在大部分流行的APM都有對應語言的SDK並且還實現了常用的I/O元件埋點,opentelemetry-dotnet也已經提供了包括HttpClientSqlClientAspNetCore等I/O埋點。雖說一般而言服務的耗時一般就在I/O部分,但由於開發人員的程式碼習慣不同、程式碼水平不同以及業務複雜度等情況,某些非I/O程式碼也會產生一定的耗時,同時在一個介面中可能會執行多次I/O操作,如果僅僅只有I/O埋點,可能很難分辨層次關係,此時可能需要一些本地輔助埋點,Rougamo.OpenTelemetry便是用於新增本地埋點的元件。

快速開始

# 啟動專案引用Rougamo.OpenTelemetry.Hosting
dotnet add package Rougamo.OpenTelemetry.Hosting
# 新增埋點的專案引用Rougamo.OpenTelemetry
dotnet add package Rougamo.OpenTelemetry
public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        // ...

        services.AddOpenTelemetryTracing(builder =>
        {
            builder
                .AddRougamoSource() // 初始化Rougamo.OpenTelemetry
                .AddAspNetCoreInstrumentation()
                .AddJaegerExporter();
        });

        // 修改Rougamo.OpenTelemetry預設配置
        services.AddOpenTelemetryRougamo(options =>
        {
            options.ArgumentsStoreType = ArgumentsStoreType.Tag;
        });
    }
}

class Service
{
    [return: ApmIgnore]     // 返回值不記錄
    [Otel] // 預設記錄引數和返回值,需要通過ApmIgnoreAttribute來忽略不需要記錄的引數或返回值
    public async Task<string> M1(
            [ApmIgnore] string uid, // 該引數不記錄
            DateTime time)
    {
        // do something
        return string.Empty;
    }

    [PureOtel] // 預設不記錄引數和返回值,需要通過ApmRecordAttribute來記錄指定的引數或返回值
    public void M2(
            [ApmRecord] double d1,  // 記錄該引數
            double d2)
    {
        // do something
    }
}

// 通過實現空介面織入
public interface ITestService : IRougamo<FullOtelAttribute>
{
    // ...
}
public class TestService : ITestService
{
    // ...
}

Rougamo.OpenTelemetry的埋點會對應生成一個名稱為方法全名稱(ClassFullName.MethodName)的LocalSpan,根據你使用的是OtelAttribute還是PureOtelAttribute決定預設是否記錄引數和返回值。Rougamo.OpenTelemetry是用來豐富APM埋點的,但是切記不要過度新增埋點,過多的埋點會讓你的trace看起來很臃腫。
關於Rougamo.OpenTelemetry更多的使用說明,詳見github,github上的程式碼中包含了一個jaeger的示例程式碼,你可以從jaeger官網上下載一個all-in-one包快速執行一個jaeger服務端,然後啟動示例專案,訪問http://localhost:5000/test介面,最後訪問jaeger uihttp://localhost:16686檢視剛剛訪問的test介面的trace資料。

更多關於

關於肉夾饃的應用情況

寫肉夾饃的動機是公司在使用postsharp做AOP,起初公司的程式碼是framework的並且基本使用同步方法,所以postsharp的免費版本是足足夠用的,隨著.NET的發展,公司的程式碼也逐漸從同步發展到非同步從framework發展到core,然後我們通過購買付費版本的postsharp也能繼續維持著,不過由於個人對postsharp的實現產生了興趣,所以悄悄的建立了這個專案,但是由於個人比較懶,這個早在19年就建立了的專案直到21年才完成。
在釋出1.0.1之前,專案一直處於閉源狀態,但在閉源狀態下已經在公司內部發布了幾個測試版本,其中1.0.0版本已經在公司測試環境沉澱了一個季度有餘,現在已經將1.0.0版本釋出到了線上使用中,釋出在nuget.org上的1.0.1版本相對於1.0.0版本在程式碼上沒有任何修改。Rougamo專案的TargetFramework是netstandard2.0,公司應用了Rougamo的專案都是.NET Core3.1的,所以如果你的專案是.NET Core3.1的,你可以相對放心的使用(如果不著急應用,也推薦測試環境沉澱一下),如果你是其他版本,那麼推薦你在測試環境沉澱一段時間,肉夾饃作為一個新專案,可能還會存在一些未知BUG,如果有任何BUG請反饋到github issue中

關於.NET的靜態程式碼織入

.NET的靜態程式碼織入其實我瞭解的也不是特別多,我知道鼻祖應該是Mono.Cecil,百度也能搜到很多它的介紹,然後就是很強大(但大部分功能收費)的Postsharp,以及對Mono.Cecil進行封裝,使其更易用的Fody,肉夾饃便是使用Fody實現AOP程式碼織入的。
靜態程式碼織入在我觀察下來使用得並不是很普遍,這或許是因為動態代理早已成熟的緣故吧。那麼靜態織入相對於動態代理有什麼優勢呢?說實話,開發肉夾饃很大一部分原因是個人興趣,但這並不代表它沒有優勢,靜態織入是在編譯時進行的,靜態織入只會讓編譯時間稍長些許,而動態代理的方式都是在應用啟動時動態生成代理類來實現的,這個過程必定會佔用些許時間,並且在這個初始化動作完成前,服務是不會進入就緒狀態的,也就是這個服務暫時為不可用狀態的,服務初始化時間越短,服務整體的可用性就會越好,這就是靜態織入帶來的優勢。當然,有些朋友可能會認為這是在鑽牛角尖,確實,很多時候我們可能認為這種耗時是微乎其微的,事實也確實如此,但做基礎架構關注的就是這些微乎其微耗時,我們經常能看到java的一些技術博文上會寫到他們做了很多位元組碼層面的優化,他們的這種優化很多時候只是優化了那麼幾個指令,單拎出來看著似乎沒有多大的效能提升,然而在大流量高吞吐的服務中,這樣優化的效果將會顯現出來,靜態織入也是如此,效能就是這樣一點一點扣出來的。

關於Fody

.NET的開發者應該或多或少都聽說甚至使用過ABP,它是.NET中非常流行的一套DDD框架了,如果你還看過ABP的原始碼,你或許見過Fody的影子,是的ABP也有使用到Fody,使用的是ConfigureAwait.Fody,我們在編寫非同步方法的時候經常會增加一個.ConfigureAwait(false)ConfigureAwait.Fody的功能就是為非同步呼叫預設加上這個方法呼叫。
進入到Fody的github首頁你將能看到很多借助於Fody開發的元件,我們也可以直接在nuget.org上以Fody為關鍵字進行搜尋,你將能看到更多以Fody開發的元件,同時你可能還會發現,在下載量很高的NuGet包中有兩個AOP相關實現MethodDecorator.FodyMethodBoundaryAspect.Fody,早在我建立肉夾饃這個專案前我就看到了這兩個專案,但當時的他們沒有對非同步方法的支援,就在這篇文章寫到這裡的時候我再次去檢視了這兩個專案,他們對非同步的支援依舊不能滿足我的需求,他們的OnExit方法都是在狀態機在第一次返回也就是在遇到第一個await的時候執行的,這時候這個非同步方法實際上可能並沒有執行完畢,下面我會給一個例子,各位可以自己進行嘗試。關於為什麼我沒有直接參與他們的專案,而是自己新建了一個專案,主要有兩個原因:一是我有一丟丟懶,不確定這個專案我會投入多少精力並且什麼時候去完成,事實也正如我的預期,兩年過去了,二是我的英語有一丟丟差,IL方面我也不算老手,我擔心有些問題交流起來有困難,所以最終也就獨立建了肉夾饃這個專案了。

dotnet add package Rougamo.Fody
dotnet add package MethodDecorator.Fody
dotnet add package MethodBoundaryAspect.Fody
<!--FodyWeavers.xml-->
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
  <Rougamo />
  <MethodDecorator />
  <MethodBoundaryAspect />
</Weavers>
public sealed class RougamoLogAttribute : MoAttribute
{
    public override void OnEntry(MethodContext context)
    {
        Console.WriteLine($"[Rougamo] on entry");
    }

    public override void OnSuccess(MethodContext context)
    {
        Console.WriteLine($"[Rougamo] on success");
    }

    public override void OnException(MethodContext context)
    {
        Console.WriteLine($"[Rougamo] on exception: {context.Exception.Message}");
    }

    public override void OnExit(MethodContext context)
    {
        Console.WriteLine($"[Rougamo] on exit");
    }
}

[AttributeUsage(AttributeTargets.Method)]
public class MethodDecoratorLogAttribute : Attribute, IMethodDecorator
{
    public void Init(object instance, MethodBase method, object[] args)
    {
        Console.WriteLine($"[MethodDecorator] on init");
    }

    public void OnEntry()
    {
        Console.WriteLine($"[MethodDecorator] on entry");
    }

    public void OnExit()
    {
        Console.WriteLine($"[MethodDecorator] on exit");
    }

    public void OnException(Exception exception)
    {
        Console.WriteLine($"[MethodDecorator] on exception: {exception.Message}");
    }
}

public sealed class MethodBoundaryAspectLogAttribute : OnMethodBoundaryAspect
{
    public override void OnEntry(MethodExecutionArgs args)
    {
        Console.WriteLine($"[MethodBoundaryAspect] on entry");
    }

    public override void OnExit(MethodExecutionArgs args)
    {
        Console.WriteLine($"[MethodBoundaryAspect] on exit");
    }

    public override void OnException(MethodExecutionArgs args)
    {
        Console.WriteLine($"[MethodBoundaryAspect] on exception: {args.Exception.Message}");
    }
}

class Program
{
    static async Task Main(string[] args)
    {
        try
        {
            await Async();
        }
        catch
        {
        }
    }

    [RougamoLog]
    //[MethodDecoratorLog]
    //[MethodBoundaryAspectLog]
    static async Task Async()
    {
        Console.WriteLine(1);
        await Task.Delay(10);
        Console.WriteLine(2);
        throw new NotImplementedException("not implemented");
    }
}

分別用三個Attribute執行上面的程式你會得到下面的輸出,肉夾饃的異常資訊是在輸出2之後輸出,exit資訊在最後輸出(也就是非同步方法執行完畢後);MethodDecorator沒有捕獲到非同步的異常,並且exit資訊在輸出2之前就輸出了;MethodBoundaryAspect捕獲到了非同步的異常資訊,但是exit資訊在輸出2之前輸出了,也就是你無法在非同步方法真正執行完畢後織入程式碼。

[Rougamo] on entry
1
2
[Rougamo] on exception: not implemented
[Rougamo] on exit

[MethodDecorator] on init
[MethodDecorator] on entry
1
[MethodDecorator] on exit
2

[MethodBoundaryAspect] on entry
1
[MethodBoundaryAspect] on exit
2
[MethodBoundaryAspect] on exception: not implemented

關於使用肉夾饃開發元件的注意事項

最後如果你準備使用肉夾饃,並且你準備使用肉夾饃開發一個供他人使用的NuGet元件,那麼你需要把專案檔案(.csproj)中Rougamo.Fody的引用改成下面這樣,不然你釋出的NuGet其他人引用後將需要額外引用Fody,否則將無法進行程式碼織入,具體可以參考Rougamo.OpenTelemetry

<PackageReference Include="Rougamo.Fody" Version="1.0.1" IncludeAssets="all" PrivateAssets="contentfiles;analyzers" />

最後的最後,即使你不準備使用肉夾饃,也希望通過此文讓你瞭解到靜態程式碼織入,瞭解到Mono.CecilFody,如果.NET能夠發展壯大起來,那麼靜態程式碼織入也終將得到更大的發展。這篇文章中不論是Rougamo還是Rougamo.OpenTelemetry都沒有進行完整的介紹,如果你準備使用它們,請移步github瞭解更多。

相關文章