幾個Caller-特性的妙用

System.Runtime.CompilerServices命名空間下有4個以「Caller」為前綴命名的Attribute,我們可以將它標註到方法參數上自動獲取當前調用上下文的資訊,比如當前的方法名、某個參數的表達式、當前源文件的路徑,以及當前程式碼在源文件中的行號。

一、CallerMemberNameAttribute

顧名思義,如果當我們將CallerMemberNameAttribute特性標註到「可預設參數」上,調用方無需顯式指定參數值就可以將表示當前調用方法名賦值給該參數。如下面的程式碼片段所示,我們為ActivitySource定義了一個名為StartNewActivity的擴展方法,表示Activity名稱的name參數是一個「可預設參數」。我們在該參數上標準了CallerMemberNameAttribute特性,意味著當前調用的方法名將自動作為參數值。

public static class Extensions
{
    public static Activity? StartNewActivity(this ActivitySource activitySource, ActivityKind kind = ActivityKind.Internal, [CallerMemberName] string name = "")
   => activitySource.StartActivity(name: name, kind: kind);
}

以Activity/ActivitySource/ActivityListener為核心的模型實際上是對OpenTelemetry的實現,所有我們可以利用上面定義的這個StartNewActivity創建一個程式碼跟蹤操作的Activity(對應OpenTelemetry下的Span)。針對StartNewActivity方法調用體現在如下這個Invoker類型中,它的構造函數中注入了ActivitySource 對象。InvokeAsync方法內部調用了私有方法FooAsync、後者又調用了BarAsync方法,調用鏈InvokeAsync->FooAsync->BarAsync的跟蹤通過調用ActivitySource的StartNewActivity擴展方法被記錄下來,我們在調用此方法時並沒有指定參數。

public class Invoker
{
    private readonly ActivitySource _activitySource;
    public Invoker(ActivitySource activitySource) => _activitySource = activitySource;

    public async Task InvokeAsync()
    {
        using (_activitySource.StartNewActivity())
        {
            await Task.Delay(100);
            await FooAsync();
        }
    }

    private async Task FooAsync()
    {
        using (_activitySource.StartNewActivity())
        {
            await Task.Delay(100);
            await BarAsync();
        }
    }

    private Task BarAsync()
    {
        using (_activitySource.StartNewActivity())
        {
            return Task.Delay(100);
        }
    }
}

我們利用如下的程式碼利用依賴注入框架將Invoker對象創建出來,並調用其Invoke方法。

ActivitySource.AddActivityListener(new ActivityListener
{
    ShouldListenTo = _ => true,
    Sample = (ref ActivityCreationOptions<ActivityContext> options) => ActivitySamplingResult.AllData,
    ActivityStopped = activity => {
        Console.WriteLine(activity.DisplayName);
        Console.WriteLine($"\tTraceId:{activity.TraceId}");
        Console.WriteLine($"\tSpanId:{activity.SpanId}");
        Console.WriteLine($"\tDuration:{activity.Duration}");
        foreach (var kv in activity.TagObjects)
        {
            Console.WriteLine($"\t{kv.Key}:{kv.Value}");
        }
        Console.WriteLine();
    }
});

await new ServiceCollection()
   .AddSingleton(new ActivitySource("App"))
   .AddSingleton<Invoker>()
   .BuildServiceProvider()
   .GetRequiredService<Invoker>()
   .InvokeAsync();

我們利用註冊的ActivityListener在Activity終止時將Activity相關跟蹤資訊(操作名稱、SpanId、ParentId、執行時間和Tag)列印在控制台上,具體輸出如下所示。

image

二、CallerArgumentExpressionAttribute

CallerArgumentExpressionAttribute特性里利用目標參數將當前方法調用的某個參數(構造函數的參數表示該參數的名稱)的表達式保存下來。如果指定的是一個變數(或者參數),捕獲到的就是變數名。比如我們定義了如下這個用來驗證參數並確保它不能為Null的ArgumentNotNull<T>。除了第一個表示參數值的argumentValue參數,它還具有一個表示參數名的argumentName參數,拋出的ArgumentNullException異常的參數名就來源於此。

public static class Guard
{
    public static T ArgumentNotNull<T>(T argumentValue, [CallerArgumentExpression("argumentValue")] string argumentName = "") where T:class
    {
        if (argumentValue is null) throw new ArgumentNullException(argumentName);
        return argumentValue;
    }
}

我們修改了Invoker的構造函數,並按照如下的方式添加了針對輸出參數(ActivitySource對象)的驗證,以避免後續拋出NullReferenceException異常。可以看出,我們調用ArgumentNotNull方法時並沒有執行表示參數名稱的第二個參數。

var invoker = new Invoker(null);

public class Invoker
{
    private readonly ActivitySource _activitySource;
    public Invoker(ActivitySource activitySource) => _activitySource = Guard.ArgumentNotNull(activitySource);
   ...
}

如果我們按照如上的方式調用Invoker的構造函數,並將Null作為參數,此時會拋出如下的異常,可以看到拋出的ArgumentNullException異常被賦予了正確的參數名。

image

三、CallerFilePathAttribute &CallerLineNumberAttribute

CallerFilePathAttribute 和CallerLineNumberAttribute特性會將源程式碼的兩個屬性賦值給目標參數。具體來說,前者會將當前源文件的路徑綁定到目標參數,後者綁定的則是當前執行程式碼在源文件中的行數。下面的程式碼為StartNewActivity擴展方法額外添加了兩個參數,並標註了如上兩個特性,我們將對應的參數值作為Tag添加到創建的Activity中。

public static class Extensions
{
    public static Activity? StartNewActivity(
        this ActivitySource activitySource,
        ActivityKind kind = ActivityKind.Internal,
        [CallerMemberName] string name = "",
        [CallerFilePath] string? filePath = default,
        [CallerLineNumber] int lineNumber = default)
    => activitySource
        .StartActivity(name: name, kind: kind)
        ?.AddTag("CallerFilePath", filePath)
        ?.AddTag("CallerLineNumber", lineNumber);
}

再次執行我們的程式,控制台上就會輸出添加的兩個Tag。

image

四、」魔法」的背後

其實這四個Attribute背後並沒有什麼魔法,「語法糖」而已。對於Invoker的三個方法(InvokeAsync、FooAsync和BarAsync)針對StartNewActivity擴展方法的調用。雖然我們並沒有指定任何參數,但是編譯器在編譯後會幫助我們將參數補齊,完整的程式碼如下所示。

using System.Diagnostics;
using System.Threading.Tasks;

public class Invoker
{
    private readonly ActivitySource _activitySource;

    public Invoker(ActivitySource activitySource)
    {
        _activitySource = Guard.ArgumentNotNull(activitySource, "activitySource");
    }

    public async Task InvokeAsync()
    {
        using (_activitySource.StartNewActivity(ActivityKind.Internal, "InvokeAsync", "D:\\Projects\\App\\App\\Program.cs", 40))
        {
            await Task.Delay(100);
            await FooAsync();
        }
    }

    private async Task FooAsync()
    {
        using (_activitySource.StartNewActivity(ActivityKind.Internal, "FooAsync", "D:\\Projects\\App\\App\\Program.cs", 49))
        {
            await Task.Delay(100);
            await BarAsync();
        }
    }

    private Task BarAsync()
    {
        using (_activitySource.StartNewActivity(ActivityKind.Internal, "BarAsync", "D:\\Projects\\App\\App\\Program.cs", 58))
        {
            return Task.Delay(100);
        }
    }
}