【.NET Core框架】诊断日志(DiagnosticSource)
介绍
DiagnosticSource它本身是一个基于发布订阅模式的工作模式,由于它本身的实现方式是异步的,所以不仅仅可以把它用到日志上,还可以用它实现异步操作,或者用它简化实现发布订阅的功能。DiagnosticSource本身是一个抽象类,我们最常用到的是它的子类DiagnosticListener,通过DiagnosticSource的Write方法实现发布一条有具体名称的消息,然后通过IObserver去订阅消息。DiagnosticListener可以实现不同的实例,每个实例可以有自己的名称,每个实例还可以发布不同名称的消息,好比一个在写代码的时候我们可以定义多个程序集,一个程序集下面可以包含多个命名空间。
使用方式
发布消息
//声明DiagnosticListener并命名为MyTest
DiagnosticSource diagnosticSource = new DiagnosticListener("MyTest");
string pubName = "MyTest.Log";
//判断是否存在MyTest.Log的订阅者
if (diagnosticSource.IsEnabled(pubName))
{
//发送名为MyTest.Log的消息,包含Name,Address两个属性
diagnosticSource.Write(pubName, new { Name = "old王", Address = "隔壁" });
}
通过这种方式,我们就可以完成针对消息的发布,其中用到了IsEnabled方法,这个方法用于判断是够存在对应名称的消费者,这样可以有效的避免发送消息浪费。
订阅消息
上面我们提到了订阅消息是通过IObserver接口实现的,IObserver代表了订阅者。虽然我们通过DiagnosticSource去发布消息,但是真正描述发布者身份的是IObservable接口,IObservable的唯一方法Subscribe是用来注册订阅者IObserver,但是默认系统并没有为我们提供一个具体的实现类,所以我们需要定义一个IObserver订阅者的实现类
public class MyObserver<T>:IObserver<T>
{
private Action<T> _next;
public MyObserver(Action<T> next)
{
_next = next;
}
public void OnCompleted()
{
}
public void OnError(Exception error)
{
}
public void OnNext(T value) => _next(value);
}
有了具体的订阅者实现类,我们就可以为发布者注册订阅者了
//AllListeners获取所有发布者,它的类型是IObservable<DiagnosticListener>,Subscribe为发布者注册订阅者MyObserver
DiagnosticListener.AllListeners.Subscribe(new MyObserver<DiagnosticListener>(listener =>
{
//判断发布者的名字
if (listener.Name == "MyTest")
{
//获取订阅信息
listener.Subscribe(new MyObserver<KeyValuePair<string, object>>(listenerData =>
{
System.Console.WriteLine($"监听名称:{listenerData.Key}");
dynamic data = listenerData.Value;
//打印发布的消息
System.Console.WriteLine($"获取的信息为:{data.Name}的地址是{data.Address}");
}));
}
}));
具体实现可总结为两步,首先为发布者注册订阅者,然后获取订阅者获取发布的消息。这种写法还是比较复杂的,首先需要实现订阅者类,然后通过一系列复杂的操作,才能完成消息订阅,然后还要自己获取发布的消息,解析具体的消息值,总之操作流程非常繁琐。微软似乎也意识到了这个问题,于是乎给我提供了一个关于实现订阅者的便利方法,编辑项目文件引入DiagnosticAdapter包
<PackageReference Include="Microsoft.Extensions.DiagnosticAdapter" Version="3.1.22" />
通过这个包解决了我们两个痛点,首先是关于订阅者的注册难问题,其次解决了关于发布消息解析难的痛点。我们可以直接订阅一个适配类来充当订阅者的载体,其次我们可以定义方法模拟订阅去订阅消息,而这个方法的参数就是我们发布的消息内容。
public class MyDiagnosticListener
{
//发布的消息主题名称
[DiagnosticName("MyTest.Log")]
//发布的消息参数名称和发布的属性名称要一致
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 =>
{
//适配订阅
listener.SubscribeWithAdapter(new MyDiagnosticListener());
}));
可能你觉得这样还是不够好,因为还是没有脱离需要自定义订阅者,这里还有更简洁的实现方式。细心的你可能已经发现了SubscribeWithAdapter是DiagnosticListener的扩展方法,而我们声明DiagnosticSource就是使用的DiagnosticListener实例,所以上面的代码可以简化为一下方式
DiagnosticListener diagnosticListener = new DiagnosticListener("MyTest");
DiagnosticSource diagnosticSource = diagnosticListener;
//直接去适配订阅者
diagnosticListener.SubscribeWithAdapter(new MyDiagnosticListener());
string pubName = "MyTest.Log";
if (diagnosticSource.IsEnabled(pubName))
{
diagnosticSource.Write(pubName, new { Name = "old王", Address="隔壁" });
}
这种方式也是我们比较推荐的使用方式,极大的节省了工作的方式,而且代码非常的简洁。但是存在唯一的不足,这种写法只能针对特定的DiagnosticListener进行订阅处理,如果你需要监听所有发布者,就需要使用DiagnosticListener.AllListeners.Subscribe的方式。
多个消息类型演示
发布消息:
//MyTest
DiagnosticSource diagnosticSource = new DiagnosticListener("MyTest");
string pubName = "MyTest.Log";
//判断是否存在MyTest.Log的订阅者
if (diagnosticSource.IsEnabled(pubName))
{
//发送名为MyTest.Log的消息,包含Name,Address两个属性
diagnosticSource.Write(pubName, new { Name = "old王", Address = "隔壁" });
}
//MyTest2
diagnosticSource = new DiagnosticListener("MyTest2");
pubName = "MyTest2.Log";
//判断是否存在MyTest.Log的订阅者
if (diagnosticSource.IsEnabled(pubName))
{
//发送名为MyTest.Log的消息,包含Name,Address两个属性
diagnosticSource.Write(pubName, new { Name = "old王2", Address = "隔壁2" });
}
订阅:
DiagnosticListener.AllListeners.Subscribe(new AnonymousObserver<DiagnosticListener>(listener =>
{
listener.SubscribeWithAdapter(new MyDiagnosticListener());
}));
public class MyDiagnosticListener
{
//发布的消息主题名称
[DiagnosticName("MyTest.Log")]
//发布的消息参数名称和发布的属性名称要一致
public void MyLog(string name, string address)
{
System.Console.WriteLine($"监听名称:MyTest.Log");
System.Console.WriteLine($"获取的信息为:{name}的地址是{address}");
}
//发布的消息主题名称
[DiagnosticName("MyTest2.Log")]
//发布的消息参数名称和发布的属性名称要一致
public void MyLog2(string name, string address)
{
System.Console.WriteLine($"监听名称:MyTest2.Log");
System.Console.WriteLine($"获取的信息为:{name}的地址是{address}");
}
}
DotNetCore源码中诊断日志的埋点
在.Net Core的源码中,微软默认在涉及到网络请求或处理请求等许多重要的节点都使用了DiagnosticListener来发布拦截的消息,接下来就罗列一些我知道的比较常见的埋点
在ASP.NET Core中日志埋点
当我们通过ConfigureWebHostDefaults配置Web主机的时候,程序就已经默认给我们注入了诊断名称为Microsoft.AspNetCore的DiagnosticListener和DiagnosticSource,这样我们就可以很方便的在程序中直接获取DiagnosticListener实例去发布消息或者监听发布的内部消息,具体注入逻辑位于可以去GenericWebHostBuilder类中查看
var listener = new DiagnosticListener("Microsoft.AspNetCore");
services.TryAddSingleton<DiagnosticListener>(listener);
services.TryAddSingleton<DiagnosticSource>(listener);
我们可以看下在处理请求的过程中DiagnosticListener都发布了哪些消息,我们找到发送诊断跟踪的位置位于HostingApplicationDiagnostics中[点击查看源码👈],这是集中处理请求相关的诊断跟踪,接下来我们就大致查看一下它发布了哪些事件消息,首先找到定义发布名称的属性
private const string ActivityName = "Microsoft.AspNetCore.Hosting.HttpRequestIn";
private const string ActivityStartKey = ActivityName + ".Start";
private const string ActivityStopKey = ActivityName + ".Stop";
private const string DeprecatedDiagnosticsBeginRequestKey = "Microsoft.AspNetCore.Hosting.BeginRequest";
private const string DeprecatedDiagnosticsEndRequestKey = "Microsoft.AspNetCore.Hosting.EndRequest";
private const string DiagnosticsUnhandledExceptionKey = "Microsoft.AspNetCore.Hosting.UnhandledException";
通过这些发布消息的名称我们就可以看出,在请求开始、请求进入、请求结束、请求停止、请求异常等都发布了诊断消息,我们以BeginRequest为例查看一下具体发送的消息
if (_diagnosticListener.IsEnabled(DeprecatedDiagnosticsBeginRequestKey))
{
startTimestamp = Stopwatch.GetTimestamp();
RecordBeginRequestDiagnostics(httpContext, startTimestamp);
}
找到RecordBeginRequestDiagnostics方法的实现
[MethodImpl(MethodImplOptions.NoInlining)]
private void RecordBeginRequestDiagnostics(HttpContext httpContext, long startTimestamp)
{
_diagnosticListener.Write(
DeprecatedDiagnosticsBeginRequestKey,
new
{
httpContext = httpContext,
timestamp = startTimestamp
});
}
从这里我们可以看出在BeginRequest中诊断日志发出的消息中包含了HttpContext和开始时间戳信息
订阅aspnetcore消息:
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
var listener = app.ApplicationServices.GetRequiredService<DiagnosticListener>();
listener.SubscribeWithAdapter(new AspNetCoreDiagnosticListener());
}
public class AspNetCoreDiagnosticListener
{
[DiagnosticName("Microsoft.AspNetCore.Hosting.HttpRequestIn.Start")]
public void Start(HttpContext httpContext, long startTimestamp)
{
System.Console.WriteLine($"Start,url:{ httpContext.Request.Path.Value}");
}
[DiagnosticName("Microsoft.AspNetCore.Hosting.BeginRequest")]
public void BeginRequest(HttpContext httpContext, long startTimestamp)
{
System.Console.WriteLine($"BeginRequest,url:{ httpContext.Request.Path.Value}");
}
[DiagnosticName("Microsoft.AspNetCore.Hosting.EndRequest")]
public void EndRequest(HttpContext httpContext, long startTimestamp)
{
System.Console.WriteLine($"EndRequest,url:{ httpContext.Request.Path.Value}");
}
[DiagnosticName("Microsoft.AspNetCore.Hosting.UnhandledException")]
public void Exception(HttpContext httpContext, Exception exception)
{
System.Console.WriteLine($"EndRequest,url:{ httpContext.Request.Path.Value}");
}
}
HttpClinet
订阅:
DiagnosticListener.AllListeners.Subscribe(new MyObserver<DiagnosticListener>(listener =>
{
listener.SubscribeWithAdapter(new HttpClienDiagnosticListener());
}));
public class HttpClienDiagnosticListener
{
[DiagnosticName("System.Net.Http.Request")]
public void HttpRequest(System.Net.Http.HttpRequestMessage request)
{
Console.WriteLine($"request,url:{request.RequestUri.AbsoluteUri}");
}
[DiagnosticName("System.Net.Http.Response")]
public void HttpResponse(System.Net.Http.HttpResponseMessage response)
{
Console.WriteLine($"response,code:{response.StatusCode.ToString()},content:{response.Content.ReadAsStringAsync().Result}");
}
[DiagnosticName("System.Net.Http.Exception")]
public void HttpException(System.Net.Http.HttpRequestMessage request, Exception exception)
{
}
}
其他
在.Net Core相关的源码中还有许多其他关于DiagnosticListener的埋点信息比如请求执行到Action的时候或者出现全局异常的时候都有类似的处理。同样在EFCore中也存在这些埋点信息,有兴趣的可以自行查阅相关源码和SkyAPM-dotnet源码
案例1、统计http请求耗时
public void Configure(
IApplicationBuilder app
, IWebHostEnvironment env
, DiagnosticListener diagnosticListener)
{
//HTTP耗时记录
diagnosticListener.SubscribeWithAdapter(new HttpContextDiagnosticObserver<T>(logger));
}
订阅者:
public sealed class HttpContextDiagnosticObserver<T> : IDisposable
{
private readonly ConcurrentDictionary<string, long> _startTimeDict = new ConcurrentDictionary<string, long>();
private readonly ILogger<T> _logger;
public HttpContextDiagnosticObserver(ILogger<T> logger)
{
this._logger = logger;
}
[DiagnosticName("Microsoft.AspNetCore.Hosting.BeginRequest")]
public void BeginRequest(HttpContext httpContext, long timestamp)
{
var request = httpContext.Request;
if (SkipPath(request.Path))
{
return;
}
string requestID = httpContext.TraceIdentifier;
this._startTimeDict.TryAdd(requestID, timestamp);//记录请求开始时间
this._logger.LogDebug($"RequestID=>[{requestID}], Method=>[{request.Method}], Begin=>[{request.GetDisplayUrl()}], User=>[{this.GetUser(httpContext)}]");
}
[DiagnosticName("Microsoft.AspNetCore.Hosting.EndRequest")]
public void EndRequest(HttpContext httpContext, long timestamp)
{
var request = httpContext.Request;
if (SkipPath(request.Path))
{
return;
}
string requestID = httpContext.TraceIdentifier;
this._startTimeDict.TryGetValue(requestID, out long startTime);
var elapsedMs = (timestamp - startTime) / TimeSpan.TicksPerMillisecond;//计算耗时
this._startTimeDict.TryRemove(httpContext.TraceIdentifier, out _);
this._logger.LogDebug($"RequestID=>[{requestID}], Method=>[{request.Method}], End=>[{request.GetDisplayUrl()}], StatusCode=>[{httpContext.Response.StatusCode}], Elapsed=>[{elapsedMs} ms], User=>[{this.GetUser(httpContext)}]");
}
private static bool SkipPath(string path)
{
return path == null
|| path.EndsWith(".css", StringComparison.CurrentCultureIgnoreCase)
|| path.EndsWith(".js", StringComparison.CurrentCultureIgnoreCase)
|| path.EndsWith(".jpg", StringComparison.CurrentCultureIgnoreCase)
|| path.EndsWith(".png", StringComparison.CurrentCultureIgnoreCase)
|| path.EndsWith(".gif", StringComparison.CurrentCultureIgnoreCase)
|| path.EndsWith(".svg", StringComparison.CurrentCultureIgnoreCase)
|| path.EndsWith(".woff", StringComparison.CurrentCultureIgnoreCase)
|| path.EndsWith(".woff2", StringComparison.CurrentCultureIgnoreCase)
|| path.EndsWith(".eot", StringComparison.CurrentCultureIgnoreCase)
|| path.EndsWith(".ttf", StringComparison.CurrentCultureIgnoreCase)
|| path.Contains("/check/", StringComparison.CurrentCultureIgnoreCase)
|| path.Contains("_health_", StringComparison.CurrentCultureIgnoreCase)
|| path.EndsWith(".ico", StringComparison.CurrentCultureIgnoreCase)
|| path.Equals("/")
;
}
/// <summary>
/// 登录用户
/// </summary>
/// <param name="httpContext"></param>
/// <returns></returns>
private string GetUser(HttpContext httpContext)
{
string email = null;
if (httpContext.User.Identity.IsAuthenticated)
{
email = httpContext.User.Identities.First(u => u.IsAuthenticated).FindFirst(ClaimTypes.Email).Value;
}
else
{
email = "no-login";
}
return email;
}
public void Dispose()
{
this._startTimeDict.Clear();
}
}