.NET 中有多种日志记录诊断信息的机制,包括 TraceSource
、EventSource
、ILogger
和 DiagnosticSource
(本文的重点)。
TraceSource
是一种较旧的选项,已很少用于新代码。ILogger
是一种简单的结构化日志记录抽象,适用于许多应用程序,但在某些情况下(例如类库项目的开发)需要额外的库依赖。
EventSource
和 DiagnosticSource
之间的区别较为微妙,并没有明确的指导来说明何时选择哪种方式。不过,这篇讨论对此有所探讨,尽管结论并不完全确定。
为了避免使本文过于冗长,我不会深入探讨它们,哦哈哈~~~
最近,我一直在研究 DiagnosticSource
,并希望深入了解其实现方式,以理解其工作原理及一些细节。因此,我计划在这篇文章中分享我的学习成果,当然也请大家指点。
先来说说 DiagnosticSource
和 DiagnosticListener
类型,这可能涉及 IObservable<T>
接口。
注意:本文基于 .NET 8 运行时仓库中的代码。
我们先来看 DiagnosticSource
类的代码:
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
)传递。由于 DiagnosticSource
是抽象的,我们需要一个具体的实现来使用它。框架提供了 DiagnosticListener
类型,它继承自 DiagnosticSource
,并实现了 IObservable<T>
和 IDisposable
接口。
public class DiagnosticListener : DiagnosticSource, IDisposable, IObservable<KeyValuePair<string, object>>
{
public DiagnosticListener(string name)
{
Name = name;
...
}
}
DiagnosticListener
只有一个构造函数,接收一个字符串参数作为名称,用于标识通知源。
以上定义可以发现DiagnosticListener
是DiagnosticSource
的具体实现,并且它是一个可观察主题,实现观察者模式。
自己创建可观察主题时一般按如下操作:
DiagnosticListener
需要提供 Subscribe
方法,以允许观察者注册接收通知。
public virtual IDisposable Subscribe(IObserver<KeyValuePair<string, object?>> observer)
{
return SubscribeInternal(observer, null, null, null, null);
}
该方法调用 SubscribeInternal
并返回其返回值。
在 SubscribeInternal
方法中,我们看到 DiagnosticListener
采用了 链表(linked list) 来管理订阅者,而非 List<IObserver<T>>
:
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;
}
观察者可以通过 Dispose
取消订阅,Dispose
方法会从链表中移除订阅项:
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
。DiagnosticSource
定义了 API,使得实现类可以写入通知并检查是否被监听。DiagnosticListener
继承自 DiagnosticSource
,并实现 IObservable<T>
,支持观察者模式。Subscribe
采用 无锁链表 管理订阅者,Dispose
允许观察者取消订阅。在后续介绍中,我们将进一步探讨 DiagnosticListener
的高级特性和最佳实践。
我们已经讨论了如何订阅和取消订阅来自 DiagnosticListener
的通知。在下面代码中,我们通过编写带有 DiagnosticListener
的代码并创建一个订阅者来接收通知。
注意: 以下的使用示例是有效的,但不是推荐的订阅 DiagnosticSource
通知的方式。我们将在深入研究一些实现细节后,介绍推荐的模式。如果你有一个提供者(DiagnosticSource
)与其通知观察者位于同一程序集,这种方式是合适的。
我们将从创建一个类和 DiagnosticListener
开始。
using System.Diagnostics;
namespaceMyNamespace;
publicclassDoStuff
{
internalstaticreadonlyDiagnosticListener Listener =new("MyNamespace.MySource");
}
在上面的代码中,我们定义了一个 DoStuff
类。我们有一个名为 Listener
的静态字段,它是 DiagnosticListener
的实例。
记住,DiagnosticListener
继承自 DiagnosticSource
,所以它也是我们的通知源。我们在构造函数中为该提供者提供了一个名称。名称应该是全局唯一的,因此推荐的最佳实践是使用包含的命名空间作为前缀。
上述代码中有两点未遵循推荐的标准模式。
Listener
字段被声明为 DiagnosticListener
类型,而通常我们将其类型声明为 DiagnosticSource
,因为我们只需要调用 DiagnosticSource
上的方法来写入事件。internal
,而通常它应该是 private
。这个建议存在的原因是,订阅通知有一种更通用的解耦方式,外部的代码不需要直接访问 DiagnosticListener
。为了本示例,我忽略了这些指导原则,专注于我们目前所探讨的内容。一旦我们有了 DiagnosticSource
,我们就可以使用它来发出诊断通知。
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
也是可以的。在这个虚构的示例中,我们没有太多数据要传递,因此直接传递字符串是合适的。当我们有更多数据要包含时,我们可以传递一个包含数据的类型实例,或者使用匿名类型,如下所示。
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
中可用。
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
字段执行快速的空值检查,这是评估是否有任何订阅者的最快方式。
public bool IsEnabled()
{
return _subscriptions != null;
}
这种方法被微软库中的代码(例如 Microsoft.Extensions.Hosting
)所使用。
我们来看看消费代码,如何创建并订阅一个观察者来接收 DiagnosticListener
的通知。我们将从定义观察者开始。
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
连接起来。
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
。
当我们运行这段代码时,我们会看到以下控制台输出:
DoIt: After subscribing 1
DoIt: After subscribing 2
DONE!
注意,我们只观察到在订阅后和处理订阅之前的两个通知。每个通知都会传递 DoIt
调用的字符串参数,并且我们在控制台消息中看到了它。
上述代码未隔离订阅者到另外的程序集,如果隔离开,那么需要我们实现两步,首先为发布者注册订阅者,然后获取订阅者获取发布的消息,听起来就不简单。
实现订阅者类,然后通过一系列复杂的操作,才能完成消息订阅,然后还要自己获取发布的消息,解析具体的消息值,总之操作流程非常繁琐。
微软也意识到了这个问题,于是乎给我提供了一个关于实现订阅者的便利方法,编辑项目文件引入DiagnosticAdapter包
<PackageReference Include="Microsoft.Extensions.DiagnosticAdapter" Version="3.1.7" />
或者通过包管理器直接搜索安装。
通过这个包解决了我们两个痛点,首先是关于订阅者的注册难问题,其次解决了关于发布消息解析难的痛点。
我们可以直接订阅一个适配类来充当订阅者的载体,定义方法模拟订阅去订阅消息,而这个方法的参数就是我们发布的消息内容。上代码。
public class MyDiagnosticListener
{
//发布的消息主题名称
[DiagnosticName("MySource")]
//发布的消息参数名称和发布的属性名称要一致
public void MyLog(string name,string address)
{
System.Console.WriteLine($"监听名称:MyTest.Log");
System.Console.WriteLine($"获取的信息为:{name}的地址是{address}");
}
}
我们可以随便定义一个类来充当订阅者载体,类里面可以自定义方法来实现获取解析消息的实现。想要让方法可以订阅消息,需要在方法上声明DiagnosticName,然后名称就是你要订阅消息的名称,而方法的参数就是你发布消息的字段属性名称,这里需要注意的是订阅的参数名称需要和发布声明属性名称一致。
然后我们直接可以通过这个类去接收订阅消息
DiagnosticListener.AllListeners.Subscribe(new MyObserver<DiagnosticListener>(listener =>
{
if (listener.Name == "DoIt")
{
//适配订阅
listener.SubscribeWithAdapter(new MyDiagnosticListener());
}
}));
上面的代码也可以简化为一下方式
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
的方式。
如果你忘记了自己发布的事件,可以使用这种方式枚举底层组件发布的事件。
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();
}
下面代码演示了使用Microsoft.Extensions.DiagnosticAdapter 编写的观察者。
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);
}
}
你学废了吗?