几个Caller-特性的妙用

时间:2022-10-09 10:08:50

System.Runtime.CompilerServices命名空间下有4个以“Caller”为前缀命名的Attribute,我们可以将它标注到方法参数上自动获取当前调用上下文的信息,比如当前的方法名、某个参数的表达式、当前源文件的路径,以及当前代码在源文件中的行号。

顾名思义,如果当我们将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)打印在控制台上,具体输出如下所示。

几个Caller-特性的妙用

二、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异常被赋予了正确的参数名。

几个Caller-特性的妙用

三、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。

几个Caller-特性的妙用

四、”魔法”的背后

其实这四个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);
        }
    }
}