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

nigture發表於2023-03-03

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

距離上一次發文差不多過去半年了,在這半年中其實還發布了一個1.3.0版本,不過因為感覺幾句話就介紹完了,所以上次也就沒有發文了,這次也會先簡短的介紹1.3.0中新增的功能。

重寫方法引數(v1.3.0)

1.3.0版本新增的功能支援我們在OnEntry中修改方法的引數值,透過這個功能,我們可以完成複雜的引數預設值設定,甚至可以透過該功能實現方法注入。

// 下面的例子是豐富日誌內容,每次輸出日誌時輸出時間字首
class EnrichMessageAttribute : MoAttribute
{
    public override void OnEntry(MethodContext context)
    {
        if (context.Arguments.Length == 1 && context.Arguments[0] is string message)
        {
            context.RewriteArguments = true;
            context.Arguments[0] = $"[{DateTime.Now}] {message}";
        }
    }
}

[EnrichMessage]
static void Log(string message)
{
    Console.WriteLine(message);
}

Log("test"); // [2023-3-3 00:03:17] test

MoAttributeExMoAttribute都支援重寫引數,需要注意的是,重寫引數時需要將MethodContext.RewriteArguments設定為true,僅僅修改context.Arguments元素是不生效的。

重試(v1.4.0)

1.4.0版本新增的功能可以讓我們在遇到指定異常或者返回值非預期值的情況下重新執行當前方法,實現方式是在OnExceptionOnSuccess中設定MethodContext.RetryCount值,在OnExceptionOnSuccess執行完畢後如果MethodContext.RetryCount值大於0那麼就會重新執行當前方法。

internal class RetryAttribute : MoAttribute
{
    public override void OnEntry(MethodContext context)
    {
        context.RetryCount = 3;
    }

    public override void OnException(MethodContext context)
    {
        context.RetryCount--;
    }

    public override void OnSuccess(MethodContext context)
    {
        context.RetryCount = 0;
    }
}

// 應用RetryAttribute後,Test方法將會重試3次
[Retry]
public void Test()
{
    throw new Exception();
}

使用重試功能需要注意以下幾點:

  • 在透過MethodContext.HandledException()處理異常或透過MethodContext.ReplaceReturnValue()修改返回值時會直接將MethodContext.RetryCount置為0,因為手動處理異常和修改返回值就表示你已經決定了該方法的最終結果,所以就不再需要重試了
  • OnEntryOnExit只會執行一次,不會因為重試而多次執行,OnExceptionOnSuccess根據你設定的RetryCount可能會執行多次
  • 儘量不要在ExMoAttribute中使用重試功能,除非你真的知道實際的處理邏輯。如果對ExMoAttribute不太瞭解,可以回顧一下之前1.2.0版本釋出的 .NET靜態程式碼織入——肉夾饃(Rougamo) 釋出1.2.0
    ExMoAttribute能夠讓我們無區別的對待使用和不使用async/await語法糖的Task/ValueTask返回值方法,主要應用於下面這種沒有使用async/await語法糖的場景。那麼為什麼這種情況下推薦使用重試功能呢?我們看看下面這段程式碼,可以簡單思考一下。
    public Task Test()
    {
      DoSomething();
    
      return Task.Run(() => DoOtherThings());
    }
    
    解釋這個問題,首先就要知道肉夾饃的大概工作方式,肉夾饃是靜態程式碼織入,是直接修改當前方法的IL程式碼來增加AOP功能的,重試功能也就相當於是用一個try..catch..將Test方法內部的程式碼包裹。
    那麼想想看,如果執行DoSomething丟擲了異常,那麼try..catch..很容的能抓取到這個異常並進行重試,但如果是DoOtherThings丟擲異常呢,雖然說我們還是有方法能夠獲取到這個異常(ExMoAttribute就這麼做了),但我們卻無法在Task內部出現異常後重新執行Task外面包括DoSomething的那部分程式碼。
    所以儘量不要在ExMoAttribute中使用重試功能,除非你真的知道實際的處理邏輯,並且認為這個處理邏輯是滿足你的需求的。

Rougamo.Retry

在實現重試這個功能的時候我想到,這個功能最常用的場景也就是遇到異常進行重試了,所以在完成該功能的同時我新建了Rougamo.Retry這個專案( https://github.com/inversionhourglass/Rougamo.Retry ),該專案封裝出了RetryAttributeRecordRetryAttribute兩個Attribute,我們可以簡單透過在方法上增加一個Attribute讓它在拋異常時重新執行。

快速開始

// 執行M1Async丟擲任何異常都將重試一次
[Retry]
public async Task M1Async()
{
}

// 執行M2丟擲任何異常都將重試,最多重試三次
[Retry(3)]
public void M2()
{
}

// 執行M3Async丟擲IOException或TimeoutException時將重試,最多重試五次
[Retry(5, typeof(IOException), typeof(TimeoutException))]
public static async ValueTask M3Async()
{
}

// 如果異常匹配邏輯複雜,可自定義型別實現IExceptionMatcher
class ExceptionMatcher : IExceptionMatcher
{
    public bool Match(Exception e) => true;
}
[Retry(2, typeof(ExceptionMatcher))]
public static void M4()
{
}

// 如果重試的次數也是固定的,可自定義型別實現IRetryDefinition
class RetryDefinition : IRetryDefinition
{
    public int Times => 3;

    public bool Match(Exception e) => true;
}
[Retry(typeof(RetryDefinition))]
public void M5()
{
}

記錄異常

有時候我們可能還希望在遇到異常時,重試的同時能夠將異常資訊記錄到日誌中,此時便可以實現IRecordable系列介面了

// 實現IRecordableMatcher介面將不包含重試次數定義
class RecordableMatcher : IRecordableMatcher
{
    public bool Match(Exception e) => true;

    public void TemporaryFailed(ExceptionContext context)
    {
        // 當前方法還有重試次數
        // 可透過context.Exception獲取到當前異常
    }

    public void UltimatelyFailed(ExceptionContext context)
    {
        // 當前方法重試次數已用完,最終還是執行失敗了
        // 可透過context.Exception獲取到當前異常
    }
}
[Retry(3, typeof(RecordableMatcher))]
public async ValueTask M6Async()
{
}

// 實現IRecordableRetryDefinition介面將包含重試次數定義
class RecordableRetryDefinition : IRecordableRetryDefinition
{
    public int Times => 3;

    public bool Match(Exception e) => true;

    public void TemporaryFailed(ExceptionContext context)
    {
        // 當前方法還有重試次數
        // 可透過context.Exception獲取到當前異常
    }

    public void UltimatelyFailed(ExceptionContext context)
    {
        // 當前方法重試次數已用完,最終還是執行失敗了
        // 可透過context.Exception獲取到當前異常
    }
}
[Retry(typeof(RecordableRetryDefinition))]
public async Task M7Async()
{
}

依賴注入

記錄異常的方式有很多種,比較常用的應該就是寫入日誌了,而很多日誌框架都是需要依賴注入支援的,而Rougamo.Retry本身是沒有依賴注入功能的,上面定義的型別都將使用無參構造方法建立其物件。
考慮到依賴注入的普遍性,所以增加了兩個擴充套件專案Rougamo.Retry.AspNetCoreRougamo.Retry.GeneralHost

Rougamo.Retry.AspNetCore

// 1. 定義實現IRecordableMatcher或IRecordableRetryDefinition的型別,並注入和使用ILogger
class RecordableRetryDefinition : IRecordableRetryDefinition
{
    private readonly ILogger _logger;

    public RecordableRetryDefinition(ILogger<RecordableRetryDefinition> logger)
    {
        _logger = logger;
    }

    public int Times => 3;

    public bool Match(Exception e) => true;

    public void TemporaryFailed(ExceptionContext context)
    {
        // 當前方法還有重試次數
        _logger.LogDebug(context.Exception, string.Empty);
    }

    public void UltimatelyFailed(ExceptionContext context)
    {
        // 當前方法重試次數已用完,最終還是執行失敗了
        _logger.LogError(context.Exception, string.Empty);
    }
}

// 2. 在Startup中進行初始化
class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        // 2.1. 將獲取物件的工廠改為IServiceProvider
        services.AddAspNetRetryFactory();
        // 2.2. 註冊RecordableRetryDefinition
        services.AddTransient<RecordableRetryDefinition>();
    }

    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        // 2.3. 註冊相關的Middleware,儘量放到前面,否則如果前面的Middleware有用到Rougamo.Retry可能會出現異常
        app.UseRetryFactory();
    }
}

// 3. 使用,使用還是和之前一樣,但是這裡的RecordableRetryDefinition就可以使用依賴注入了
[Retry(typeof(RecordableRetryDefinition))]
public static async Task M8Async()
{
}

Rougamo.Retry.GeneralHost

除了AspNetCore,我們可能還會建立一些通用程式,此時就可以引用Rougamo.Retry.GeneralHost了,Rougamo.Retry.GeneralHost的初始化比Rougamo.Retry.AspNetCore簡單一些,不需要註冊Middleware

// 使用部分相同,這裡省略

// 在ConfigureServices中進行初始化
public void ConfigureServices(IServiceCollection services)
{
    // 1. 將獲取物件的工廠改為IServiceProvider
    services.AddRetryFactory();
    // 2. 註冊RecordableMatcher
    services.AddTransient<RecordableMatcher>();
}

AspNetCore中之所以要註冊Middleware,原因是AspNetCore中一般有一些型別會註冊為Scoped生命週期,所以AspNetCore中會額外註冊一個Middleware處理IServiceProvider的獲取邏輯,如果你的通用程式中也存在Scoped生命週期,並且實現Rougamo.Retry的相關介面時注入了Scoped型別,那麼你可以參考Rougamo.Retry.AspNetCore也進行一些額外的處理

統一記錄異常

如果記錄異常的邏輯是通用的,那麼每次實現IRecordableMatcherIRecordableRetryDefinition介面時都要帶上這段邏輯處理會有些麻煩,雖然說可以抽象父類,但還是會稍顯麻煩而且有遺漏的可能。
考慮到這個問題Rougamo.Retry也提供了統一記錄異常的方式,那就是RecordRetryAttributeIRecordable的組合。

// 1. 實現IRecordable介面
class Recordable : IRecordable
{
    private readonly ILogger _logger;

    public Recordable(ILogger<Recordable> logger)
    {
        _logger = logger;
    }

    public void TemporaryFailed(ExceptionContext context)
    {
        // 當前方法還有重試次數
        _logger.LogDebug(context.Exception, string.Empty);
    }

    public void UltimatelyFailed(ExceptionContext context)
    {
        // 當前方法重試次數已用完,最終還是執行失敗了
        _logger.LogError(context.Exception, string.Empty);
    }
}

// 2. 註冊Recordable,注意這裡只展示了額外的步驟,如果你使用了Rougamo.Retry.AspNetCore或Rougamo.Retry.GeneralHost,那麼你同樣需要完成這些元件各自的初始化操作
public void ConfigureServices(IServiceCollection services)
{
    services.AddRecordable<Recordable>();
}

// 3. 使用,以下操作都會自動執行Recordable的異常記錄動作
[RecordRetry]
public async Task M10Async() { }

[RecordRetry(5, typeof(IOException), typeof(TimeoutException))]
public static async ValueTask M12Async() { }

class ExceptionMatcher : IExceptionMatcher
{
    public bool Match(Exception e) => true;
}
[RecordRetry(2, typeof(ExceptionMatcher))]
public static void M13() { }

Rougamo.Retry注意事項

  • 在使用RetryAttributeRecordRetryAttribute時,當前專案必須直接引用Rougamo.Retry,不可間接引用,否則程式碼無法織入。
  • Rougamo.Retry主要使用的是重試功能,同時RetryAttributeRecordRetryAttribute繼承自MoAttribute而不是ExMoAttribute,所以同樣不推薦將這些Attribute應用到沒有使用async/await語法糖的方法上

最後

感謝大家的使用和反饋,我最開始想到肉夾饃可以完成的AOP功能基本都實現了,沒錯,其實大部分功能都是在專案建立之初就想好了,只是因為比較懶,所以這1.0版本到現在1.4版本拖了一年多。
當然也不是說肉夾饃的功能就開發至此不再更新了,其實還有一個功能是計劃中的,不過這個功能屬於增強不屬於前面那種全新的功能。如果大家有想到什麼肉夾饃能做的功能,也可以到github上反饋,不過新功能的實現可能就比較拖沓了..
特別感謝在github上反饋問題的朋友們,也因為比較懶,所以測試用例的覆蓋面也不是很全,好些BUG都是靠大家反饋的。1.4.0版本現已出發,那麼BUG的探索就交給大家了,就算是懶,有BUG還是會盡快修復的。

相關文章