前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >分布式服务中的链式日志跟踪基础——理解和使用 DiagnosticSource 和 DiagnosticListener

分布式服务中的链式日志跟踪基础——理解和使用 DiagnosticSource 和 DiagnosticListener

作者头像
郑子铭
发布2025-04-19 23:52:43
发布2025-04-19 23:52:43
5500
代码可运行
举报
运行总次数:0
代码可运行
  • 📢欢迎点赞 :👍 收藏 ⭐留言 📝 如有错误敬请指正,赐人玫瑰,手留余香!
  • 📢本文作者:由webmote 原创
  • 📢作者格言:新的征程,用爱发电,去丈量人心,是否能达到人机合一?

1.NET 日志诊断机制概览

.NET 中有多种日志记录诊断信息的机制,包括 TraceSourceEventSourceILoggerDiagnosticSource(本文的重点)。

TraceSource 是一种较旧的选项,已很少用于新代码。ILogger 是一种简单的结构化日志记录抽象,适用于许多应用程序,但在某些情况下(例如类库项目的开发)需要额外的库依赖。

EventSourceDiagnosticSource 之间的区别较为微妙,并没有明确的指导来说明何时选择哪种方式。不过,这篇讨论对此有所探讨,尽管结论并不完全确定。

为了避免使本文过于冗长,我不会深入探讨它们,哦哈哈~~~

最近,我一直在研究 DiagnosticSource,并希望深入了解其实现方式,以理解其工作原理及一些细节。因此,我计划在这篇文章中分享我的学习成果,当然也请大家指点。

先来说说 DiagnosticSourceDiagnosticListener 类型,这可能涉及 IObservable<T> 接口。

注意:本文基于 .NET 8 运行时仓库中的代码。


在这里插入图片描述
在这里插入图片描述

2. DiagnosticSource

我们先来看 DiagnosticSource 类的代码:

代码语言:javascript
代码运行次数:0
运行
复制
public abstractpartialclassDiagnosticSource
{
    internalconststring WriteRequiresUnreferencedCode ="The type of object being written "+
        "to DiagnosticSource cannot be discovered statically.";
    internalconststring WriteOfTRequiresUnreferencedCode ="Only the properties of the T "+
        "type will be preserved. Properties of referenced types and properties of derived types may be trimmed.";

    [RequiresUnreferencedCode(WriteRequiresUnreferencedCode)]
    publicabstractvoidWrite(string name,object?value);

    [RequiresUnreferencedCode(WriteOfTRequiresUnreferencedCode)]
    publicvoidWrite<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] T>(string name,Tvalue)=>
        Write(name,(object?)value);

    publicabstractboolIsEnabled(string name);

    publicvirtualboolIsEnabled(string name,object? arg1,object? arg2 =null)
    {
        returnIsEnabled(name);
    }
}

DiagnosticSource 是 .NET 中一个用于创建、发出和传递诊断信息的类,它是整个诊断跟踪系统的一部分。DiagnosticSource 允许应用程序在代码执行期间发布诊断事件,这些事件可以被外部的监听器(如 DiagnosticListener)捕获和处理,从而为应用程序的监控、调试和性能分析提供支持。

DiagnosticSource 是一个抽象类,定义了 Write 方法来记录复杂的数据载荷(payload)。每个通知都有一个名称用于标识,同时可选地携带一个对象作为信息载荷。

此外,该类定义了 IsEnabled 方法,用于检查特定的通知是否被监听。虽然最佳实践建议生产者在发送通知前检查 IsEnabled,但这并非强制,订阅者需要自己处理未被检查的通知。

DiagnosticSource 类的主要功能

  • 事件记录: DiagnosticSource 可以记录应用程序的特定事件。例如,数据库操作、HTTP 请求、用户操作等。这些事件可以被任何订阅了相应 DiagnosticListener 的组件捕获和处理。
  • 跨进程诊断: DiagnosticSource 可以用于跨进程诊断,它是 .NET 中分布式跟踪的基础。例如,微服务架构中各个服务间的通信可以通过 DiagnosticSource 传递诊断信息,以便在日志和监控系统中追踪请求链路。
  • 低开销、高性能: 由于DiagnosticSource设计时考虑到性能,因此它的开销非常低。如果没有订阅者,事件的记录几乎不会产生性能损失。它还支持条件检查 (IsEnabled),只有在有订阅者的情况下才记录事件,从而进一步减少不必要的性能损失。
  • 灵活的数据结构: DiagnosticSource 提供了一种灵活的方式来将诊断数据传递给订阅者。它支持将诊断信息包装在一个 KeyValuePair<string, object> 中,并允许开发者将数据对象作为事件的有效负载(payload)传递。

在这里插入图片描述
在这里插入图片描述

3. DiagnosticListener

由于 DiagnosticSource 是抽象的,我们需要一个具体的实现来使用它。框架提供了 DiagnosticListener 类型,它继承自 DiagnosticSource,并实现了 IObservable<T>IDisposable 接口。

代码语言:javascript
代码运行次数:0
运行
复制
public class DiagnosticListener : DiagnosticSource, IDisposable, IObservable<KeyValuePair<string, object>>
{
public DiagnosticListener(string name)
{
    Name = name;
    ...
}

}

DiagnosticListener 只有一个构造函数,接收一个字符串参数作为名称,用于标识通知源。

3.1 订阅机制

以上定义可以发现DiagnosticListenerDiagnosticSource的具体实现,并且它是一个可观察主题,实现观察者模式。

自己创建可观察主题时一般按如下操作:

  1. 创建发布者,
  2. 创建观察者并将其注册到发布者上。

DiagnosticListener 需要提供 Subscribe 方法,以允许观察者注册接收通知。

代码语言:javascript
代码运行次数:0
运行
复制
public virtual IDisposable Subscribe(IObserver<KeyValuePair<string, object?>> observer)
{
    return SubscribeInternal(observer, null, null, null, null);
}

该方法调用 SubscribeInternal 并返回其返回值。

SubscribeInternal 方法中,我们看到 DiagnosticListener 采用了 链表(linked list) 来管理订阅者,而非 List<IObserver<T>>

代码语言:javascript
代码运行次数:0
运行
复制
private DiagnosticSubscriptionSubscribeInternal(IObserver<KeyValuePair<string, object?>> observer,
    Predicate<string>? isEnabled1Arg,Func<string, object?, object?, bool>? isEnabled3Arg,
    Action<Activity, object?>? onActivityImport,Action<Activity, object?>? onActivityExport)
{
    if(_disposed)
    {
        returnnewDiagnosticSubscription(){ Owner =this};
    }

    DiagnosticSubscription newSubscription =newDiagnosticSubscription()
    {
        Observer = observer,
        IsEnabled1Arg = isEnabled1Arg,
        IsEnabled3Arg = isEnabled3Arg,
        OnActivityImport = onActivityImport,
        OnActivityExport = onActivityExport,
        Owner =this,
        Next = _subscriptions
    };

    while(Interlocked.CompareExchange(ref _subscriptions, newSubscription, newSubscription.Next)!= newSubscription.Next)
        newSubscription.Next = _subscriptions;

    return newSubscription;
}

3.2 取消订阅

观察者可以通过 Dispose 取消订阅,Dispose 方法会从链表中移除订阅项:

代码语言:javascript
代码运行次数:0
运行
复制
public voidDispose()
{
    while(true)
    {
        DiagnosticSubscription? subscriptions = Owner._subscriptions;
        DiagnosticSubscription? newSubscriptions =Remove(subscriptions,this);

        if(Interlocked.CompareExchange(ref Owner._subscriptions, newSubscriptions, subscriptions)== subscriptions)
        {
            break;
        }
    }
}
  • Remove 方法递归移除目标订阅项,并返回新的链表。
  • CompareExchange 确保线程安全地更新 _subscriptions

3.3. 小结上述内容

  1. DiagnosticSource 定义了 API,使得实现类可以写入通知并检查是否被监听。
  2. DiagnosticListener 继承自 DiagnosticSource,并实现 IObservable<T>,支持观察者模式。
  3. Subscribe 采用 无锁链表 管理订阅者,Dispose 允许观察者取消订阅。

在后续介绍中,我们将进一步探讨 DiagnosticListener 的高级特性和最佳实践。

4. 使用 DiagnosticSource 和 DiagnosticListener

我们已经讨论了如何订阅和取消订阅来自 DiagnosticListener 的通知。在下面代码中,我们通过编写带有 DiagnosticListener 的代码并创建一个订阅者来接收通知。

注意: 以下的使用示例是有效的,但不是推荐的订阅 DiagnosticSource 通知的方式。我们将在深入研究一些实现细节后,介绍推荐的模式。如果你有一个提供者(DiagnosticSource)与其通知观察者位于同一程序集,这种方式是合适的。

我们将从创建一个类和 DiagnosticListener 开始。

代码语言:javascript
代码运行次数:0
运行
复制
using System.Diagnostics;

namespaceMyNamespace;

publicclassDoStuff
{
    internalstaticreadonlyDiagnosticListener Listener =new("MyNamespace.MySource");
}

在上面的代码中,我们定义了一个 DoStuff 类。我们有一个名为 Listener 的静态字段,它是 DiagnosticListener 的实例。

记住,DiagnosticListener 继承自 DiagnosticSource,所以它也是我们的通知源。我们在构造函数中为该提供者提供了一个名称。名称应该是全局唯一的,因此推荐的最佳实践是使用包含的命名空间作为前缀。

上述代码中有两点未遵循推荐的标准模式。

  1. 首先,Listener 字段被声明为 DiagnosticListener 类型,而通常我们将其类型声明为 DiagnosticSource,因为我们只需要调用 DiagnosticSource 上的方法来写入事件。
  2. 第二点偏离是字段是 internal,而通常它应该是 private。这个建议存在的原因是,订阅通知有一种更通用的解耦方式,外部的代码不需要直接访问 DiagnosticListener。为了本示例,我忽略了这些指导原则,专注于我们目前所探讨的内容。

一旦我们有了 DiagnosticSource,我们就可以使用它来发出诊断通知

代码语言:javascript
代码运行次数:0
运行
复制
public classDoStuff
{
    internalstaticreadonlyDiagnosticListener Listener =new("MyNamespace.MySource");
     
    privatestaticreadonlyDiagnosticSource _diagnostics = Listener;

    publicstaticvoidDoIt(string msg)
    {
        if(_diagnostics.IsEnabled("DoIt"))
            _diagnostics.Write("DoIt", msg);
    }
}

在上面的代码中,我添加了一个静态只读的 DiagnosticSource 字段,以更接近使用指导。在这里,我将它赋值为 DiagnosticListener 实例的引用。

当我们有代码要进行插桩时,我们可以使用 DiagnosticSource 上定义的方法。

DoIt 方法可以通过调用 Write 方法来发出诊断通知。

正如我们在探索 DiagnosticSource 代码时了解到的,这个方法接受一个必需的字符串和一个可选的对象。这个字符串应该是正在写入事件的名称。推荐的做法是这些名称应该简短,以减少诊断的性能开销。名称应该在所有通过命名的 DiagnosticListener 写入的事件中是唯一的。不同的 DiagnosticListener 实例可以重用事件名称,而不会有任何问题。这一点很重要,因为它意味着运行时代码、第三方库和应用代码都可以使用 DiagnosticSource 插桩,而不会发生事件冲突。因此,确保 DiagnosticListener 的名称唯一并以命名空间为前缀是至关重要的。

我们在上面的代码中将字符串 msg 作为第二个参数传递。通常,传递一个对象来提供与通知相关的附加数据是合理的。如果没有这样的数据要求,传递 null 也是可以的。在这个虚构的示例中,我们没有太多数据要传递,因此直接传递字符串是合适的。当我们有更多数据要包含时,我们可以传递一个包含数据的类型实例,或者使用匿名类型,如下所示。

代码语言:javascript
代码运行次数:0
运行
复制
public static void DoIt(string msg)
{
    if (_diagnostics.IsEnabled("DoIt"))
        _diagnostics.Write("DoIt", new { Message = msg });
}

在上面的代码中,我们创建了一个匿名类型,其中包含一个 Message 属性,并将其赋值为 msg 参数。这对这个示例没有实际的用处,但足以描述用法。为了简便,我们将在后续代码中重新使用直接传递字符串消息对象的方式。

匿名类型是推荐的默认选择,原因有很多。首先,您可以随时向它们添加新属性,且兼容性良好。其次,订阅者不必直接依赖您的程序集来引用处理通知所需的类型信息(尽管可以通过反射避免这一点)。

然而,这个建议确实带来了一些挑战。最大的问题是,订阅者需要使用反射来访问匿名类型中的数据。这会引入开销。

指导建议考虑使用显式类型的对象实例作为通知有效载荷。其主要价值在于,消费代码可以将对象强制转换为已知的有效载荷类型,更容易进行处理。这避免了昂贵的反射操作,也避免了查看生产者源代码来了解有效载荷上暴露的属性。

唯一的缺点是,这种类型需要由被插桩的程序集公开,且任何更改都可能导致消费代码出现问题。实际上,我认为这是一个小代价。在某些情况下,这个选择还允许缓存和复用只读有效载荷,这可以避免为每个新写入的事件分配不必要的对象。

还值得提到的是,由于有效载荷最终作为对象处理,因此用于有效载荷的任何值类型都会被装箱,这会带来一些小的性能损失。遗憾的是,即使是更新后的 Write<T>(string name, T value) 重载也无法解决这个问题,因为它只是为了支持一些修剪属性而添加的。由于它是非抽象的,它最终会调用抽象的 Write 方法,将 T 强制转换为对象。

上面代码中的最后一点是,在写入事件之前,我们对 IsEnabled 方法进行了检查。**这是推荐的性能优化。**如果 DiagnosticListener 没有任何订阅者在观察其通知,那么就没有必要写入事件。

此外,如果没有 IsEnabled 检查,上面的代码每次调用 DoIt 时都会创建一个新的匿名类型实例,即使没有任何人观察通知。为了避免不必要的分配,我们应该事先检查是否至少有一个观察者对 DoIt 事件感兴趣。我们尚未看到观察者的代码(我们将在本系列的未来文章中进行讨论),但除了订阅所有事件,观察者还可以使用谓词来订阅他们感兴趣的通知。

为了完整性,下面有一个略微优化的检查方式,可以在写入事件之前检查是否有观察者。这个方法不在 DiagnosticSource 中,但在 DiagnosticListener 中可用。

代码语言:javascript
代码运行次数:0
运行
复制
namespace DiagnosticsExample;

publicclassDoStuff
{
    internalstaticreadonlyDiagnosticListener Listener =new("MySource");

    publicstaticvoidDoIt(string msg)
    {
        if(Listener.IsEnabled && Listener.IsEnabled("DoIt"))
            Listener.Write("DoIt", msg);
    }
}

当我们将字段类型设为 DiagnosticListener 时,选择调用 IsEnabled() 重载方法而不是 IsEnabled(string),这是一个优势。此实现对 _subscriptions 字段执行快速的空值检查,这是评估是否有任何订阅者的最快方式。

代码语言:javascript
代码运行次数:0
运行
复制
public bool IsEnabled()
{
    return _subscriptions != null;
}

这种方法被微软库中的代码(例如 Microsoft.Extensions.Hosting)所使用。

5.小结

我们来看看消费代码,如何创建并订阅一个观察者来接收 DiagnosticListener 的通知。我们将从定义观察者开始。

代码语言:javascript
代码运行次数:0
运行
复制
public classObserver:IObserver<KeyValuePair<string, object?>>
{
    publicvoidOnCompleted()=>
        Console.WriteLine("DiagnosticListener 被释放了!");

    publicvoidOnError(Exception error){}

    publicvoidOnNext(KeyValuePair<string, object?>value)=>
        Console.WriteLine($"{value.Key}: {(string)value.Value!}");
}

上述代码定义了一个名为 Observer 的类,它实现了 IObserver<KeyValuePair<string, object?>> 接口。观察者必须实现三个方法,这些方法会被可观察对象调用,以向订阅者推送通知。

OnNext 是这些回调方法中最重要的一个。每当可观察对象有新数据要提供给订阅者时,它会调用这个方法。

对于 DiagnosticListener 来说,每当写入一个诊断通知时,都会调用这个方法。在上面的代码中,我们将一个字符串记录到控制台。我们使用一个插值字符串来包含 KeyValuePair 中的键,这将是通知的字符串名称。我们的可观察对象发送一个字符串作为通知的有效载荷,因此我们可以将 KeyValuePair 中的 Value 强制转换回字符串并包含在控制台消息中。

由于我们同时拥有可观察对象和观察者代码,所以我们可以安全地使用 null 忽略操作符来忽略潜在的 null 对象。在生产代码中,我们需要更小心地进行 null 检查并进行强制类型转换。

OnCompleted 只有在 DiagnosticListener 被释放时才会被调用。此时它将不再向订阅者提供通知。上面的示例代码只会记录此情况到控制台。

DiagnosticListener 实现中,没有代码调用 OnError 回调,因此我们不需要在这里实现它。

定义了 IObserver 后,我们可以在程序文件中将其与 DiagnosticListener 连接起来。

代码语言:javascript
代码运行次数:0
运行
复制
using System.Diagnostics;
using DiagnosticsExample;

DoStuff.DoIt("Before subscribing");
var subscription = DoStuff.Listener.Subscribe(new Observer());
DoStuff.DoIt("After subscribing 1");
DoStuff.DoIt("After subscribing 2");
subscription.Dispose();
DoStuff.DoIt("After unsubscribing");

Console.WriteLine("DONE!");

在上面的代码中,首先调用 DoIt 方法几次。在第一次调用后,我们将一个新的 Observer 直接订阅到 DoStuff 方法中的 DiagnosticListener 实例。

这种方法不太常见,因为它需要直接依赖 DiagnosticListener。在我们的示例中,由于所有类型都定义在同一个程序集内,并且我们将 DiagnosticListener 定义为 internal,所以这是可能的。稍后我们会讨论一种更常见的订阅方式,不需要直接依赖 DiagnosticListener

订阅后,代码调用 DoIt 两次,然后我们处理订阅。处理后,再次调用 DoIt

当我们运行这段代码时,我们会看到以下控制台输出:

代码语言:javascript
代码运行次数:0
运行
复制
DoIt: After subscribing 1
DoIt: After subscribing 2
DONE!

注意,我们只观察到在订阅后和处理订阅之前的两个通知。每个通知都会传递 DoIt 调用的字符串参数,并且我们在控制台消息中看到了它。

6. 自定义订阅者

上述代码未隔离订阅者到另外的程序集,如果隔离开,那么需要我们实现两步,首先为发布者注册订阅者,然后获取订阅者获取发布的消息,听起来就不简单。

实现订阅者类,然后通过一系列复杂的操作,才能完成消息订阅,然后还要自己获取发布的消息,解析具体的消息值,总之操作流程非常繁琐。

微软也意识到了这个问题,于是乎给我提供了一个关于实现订阅者的便利方法,编辑项目文件引入DiagnosticAdapter包

<PackageReference Include="Microsoft.Extensions.DiagnosticAdapter" Version="3.1.7" />

或者通过包管理器直接搜索安装。

通过这个包解决了我们两个痛点,首先是关于订阅者的注册难问题,其次解决了关于发布消息解析难的痛点。

我们可以直接订阅一个适配类来充当订阅者的载体,定义方法模拟订阅去订阅消息,而这个方法的参数就是我们发布的消息内容。上代码。

代码语言:javascript
代码运行次数:0
运行
复制
public class MyDiagnosticListener
{
    //发布的消息主题名称
    [DiagnosticName("MySource")]
    //发布的消息参数名称和发布的属性名称要一致
    public void MyLog(string name,string address)
    {
        System.Console.WriteLine($"监听名称:MyTest.Log");
        System.Console.WriteLine($"获取的信息为:{name}的地址是{address}");
    }
}

我们可以随便定义一个类来充当订阅者载体,类里面可以自定义方法来实现获取解析消息的实现。想要让方法可以订阅消息,需要在方法上声明DiagnosticName,然后名称就是你要订阅消息的名称,而方法的参数就是你发布消息的字段属性名称,这里需要注意的是订阅的参数名称需要和发布声明属性名称一致。

然后我们直接可以通过这个类去接收订阅消息

代码语言:javascript
代码运行次数:0
运行
复制
DiagnosticListener.AllListeners.Subscribe(new MyObserver<DiagnosticListener>(listener =>
{
    if (listener.Name == "DoIt")
    {
        //适配订阅
        listener.SubscribeWithAdapter(new MyDiagnosticListener());
    }
}));

上面的代码也可以简化为一下方式

代码语言:javascript
代码运行次数:0
运行
复制
DiagnosticListener diagnosticListener =newDiagnosticListener("MySource");
DiagnosticSource diagnosticSource = diagnosticListener;
//直接去适配订阅者
diagnosticListener.SubscribeWithAdapter(newMyDiagnosticListener());

string pubName ="DoIt";
if(diagnosticSource.IsEnabled(pubName))
{
    diagnosticSource.Write(pubName,new{ Name ="old王", Address="隔壁"});
}

这种方式也是我们比较推荐的使用方式,极大的节省了工作的方式,而且代码非常的简洁。但是存在唯一不足,这种写法只能针对特定的DiagnosticListener进行订阅处理,如果你需要监听所有发布者,就需要使用DiagnosticListener.AllListeners.Subscribe的方式。

如果你忘记了自己发布的事件,可以使用这种方式枚举底层组件发布的事件。

代码语言:javascript
代码运行次数:0
运行
复制
 static voidMain(string[] args)
        {
            var observer =newMyObserver<DiagnosticListener>(x =>
            {

                Console.WriteLine(x.Name);

            });
            // 可以看看底层组件发布了哪些listener
            DiagnosticListener.AllListeners.Subscribe(observer);
            // 查看listener 中发布了哪些事件
            //DiagnosticListener.AllListeners.Subscribe(new ExampleDiagnosticObserver());

            // 注册sqlclient观察者
            //DiagnosticListener.AllListeners.Subscribe(new SqlClientObserver());

            Console.WriteLine(Get());
            Console.ReadKey();
        }
代码语言:javascript
代码运行次数:0
运行
复制
   下面代码演示了使用Microsoft.Extensions.DiagnosticAdapter 编写的观察者。
代码语言:javascript
代码运行次数:0
运行
复制
      public classHttpClientObserver:IObserver<DiagnosticListener>
   {
       privatereadonlyList<IDisposable> _subscriptions =newList<IDisposable>();
       privatereadonlyAsyncLocal<Stopwatch> _stopwatch =newAsyncLocal<Stopwatch>();

       publicvoidOnCompleted()
       {
       }

       publicvoidOnError(Exception error)
       {
       }

       publicvoidOnNext(DiagnosticListenervalue)
       {
           if(value.Name =="HttpHandlerDiagnosticListener")
           {
               var subscription =value.SubscribeWithAdapter(this);
               _subscriptions.Add(subscription);
           }
       }

       [DiagnosticName("System.Net.Http.Request")]
       publicvoidHttpRequest(HttpRequestMessage request)
       {
           Console.WriteLine($"request url: {request.RequestUri.AbsoluteUri}");
           Console.WriteLine($"request method: {request.Method}");
       }


       [DiagnosticName("System.Net.Http.Response")]
       publicvoidHttpResponse(HttpResponseMessage response)
       {
           Console.WriteLine($"response status code: {response.StatusCode}");
           Console.WriteLine($"response version: {response.Version}");
       }

       [DiagnosticName("System.Net.Http.Exception")]
       publicvoidHttpException(HttpRequestMessage request,Exception exception)
       {
           Console.WriteLine(exception.Message);
       }
   }

你学废了吗?

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2025-04-19,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 DotNet NB 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1.NET 日志诊断机制概览
  • 2. DiagnosticSource
  • 3. DiagnosticListener
    • 3.1 订阅机制
    • 3.2 取消订阅
    • 3.3. 小结上述内容
  • 4. 使用 DiagnosticSource 和 DiagnosticListener
  • 5.小结
  • 6. 自定义订阅者
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档