幾個Caller-特性的妙用

2022-10-09 12:04:20

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);
        }
    }
}