ASP.NET Core 沉思录 - 结构化日志
在 《ASP.NET Core 沉思錄 - Logging 的兩種介入方法》中我們介紹了 ASP.NET Core 中日志的基本設計結構。這一次我們來觀察日志記錄的格式,并進一步考慮如何在應用程序中根據不同的需求選擇不同的日志記錄形式。
太長不讀:直接飛到文章最后 :-D
Microsoft.Extension.Logging 體系下的日志格式
為了便于閱讀,我們仍然將 Microsoft.Extension.Logging 的基本設計結構放在這里:
奇怪的不合理之處
注:所謂奇怪就是這種不合理是只是從某一種特定視角看的。
對于記錄日志而言,雖然一些具體的日志記錄目標和記錄的格式會有一些聯系,但是日志記錄的目標和日志記錄的格式應該是兩件事情。貌似 Microsoft.Extension.Logging 在此處進行了一些抽象。首先,日志具體的記錄地點和記錄格式全部由具體的 ILoggerProvider 創建的 ILogger 來完成。而對于日志的格式化方法,則使用 ILogger.Log 方法中的委托來完成。該委托中包含了一個 formatter 委托參數。該委托接收需要記錄的對象,關聯的異常實例并返回日志字符串。我們可以在其中定義自己的格式化邏輯。總結起來感覺是:
特定的 ILoggerProvider 創建將日志記錄到特定種類的目的地的日志記錄器。例如,ConsoleLogger。
指定 ILogger.Log 方法中的 formatter 參數對日志對象進行格式化。
ILogger.Log 方法除了 formatter 之外還包含如下的參數:
logLevel:日志的級別。
eventId:當前事件的標識。
state:日志對象。
exception:關聯的異常對象。
而 formatter 參數將使用其中的 state 參數和 exception 參數對日志進行格式化。這樣通過替換 formatter 的邏輯就可以更改日志的形式了。例如,使用如下的邏輯就可以將 state 格式化為 JSON 形式:
//?Capture?output?so?that?we?can?assert?its?contentvar?writer?=?new?StringWriter();
Console.SetOut(writer);
//?Normal?initialization?logic
var?serviceCollection?=?new?ServiceCollection();
serviceCollection.AddLogging(
????config?=>?config.SetMinimumLevel(LogLevel.Debug).AddConsole());
ServiceProvider?provider?=?serviceCollection.BuildServiceProvider();
var?loggerFactory?=?provider.GetService<ILoggerFactory>();
var?logger?=?loggerFactory.CreateLogger("category");
//?Write?log?message
logger.Log(
????LogLevel.Information,
????1,
????new?{message?=?"Hello?{name}",?name?=?"World"},
????null,
????(state,?exception)?=>?JsonConvert.SerializeObject(state));
//?This?is?very?important.?The?console?logger?using?a?async?processor?to?consume
//?the?queued?log?message.
Thread.Sleep(1000);
Assert.Equal("...(omitted)...",?writer.ToString())
如果您嘗試了上述范例程序就會感到這個設計好像有問題,而如果聯系整個 Extension.Logging 體系則感覺問題就更大了:
formatter只是解決了日志 message 部分的格式化問題,而無法影響其他信息的格式化,例如 eventId、logLevel、exception 等。
我們根本不會用到具體的 ILoggerProvider 而是會使用 ILoggerFactory 提供的 Logger 門面。這個 Logger 是一個組合 Logger,也就是它會將 ILogger<>.Log 調用分發出來。但是我們很少使用 ILogger<>.Log 方法,而會使用擴展方法使用 template message 進行日志記錄,這意味著所有的子 ILogger 實現都會接到同樣的 formatter。自此,不同的目標采用不同的消息格式的理想破滅了。
總結一下就是,日志的記錄目標和日志的格式混合了起來。職責區分不清。message 的格式化職責交給了門面擴展方法;而另一部分格式化職責交給了具體的 ILoggerProvider。
從另一種視角看的合理之處
我們換一個視角可能就會得到不一樣的體驗。首先我們更改分析問題的策略。從端到端的角度來思考問題。當我們記錄日志的時候希望有哪幾類信息呢?日志作為追蹤一個事件的依據,應當能夠清晰的說明這個事件。那么小學語文老師就告訴過我們,記錄一件事情需要有:
時間
地點
人物
起因
經過
結果
如果我們將這些信息歸一歸類,我們就可以得到這些信息:
物理世界的環境參數:時間
判斷事件嚴重程度的依據:結果
事件過程的上下文參數:地點(例如 URI 或代表某種操作的入口)、人物(例如誰進行的操作)、起因(例如方法調用參數)、經過(例如調用的那個方法)
而要記錄這些信息,則可以對應到程序中的以下幾種形式的數據:
物理世界的環境參數:例如 DateTime、DateTimeOffset
判斷事件嚴重程度的依據:例如 LogLevel
事件過程上下文的參數:例如事件的類別 CategoryName;一個包含各種各樣上下文參數的 object[] 對象;以及對人類友好,能夠將這些參數串起來的消息模板。
至此,你能夠看到這些參數正是 Microsoft.Extension.Logging 中門面擴展方法中需要你來提供的參數。它本來也沒有希望你調用 ILogger.Log 方法。而是希望你調用擴展方法用最舒服的方式達成日志記錄的目的。
而作為 ILoggerProvider 開發者,你并不一定必須得接受 formatter 生成的格式化后的日志消息。你可以選擇處理每一個傳入參數。具體的請參見 FormattedLogValues 類型的源代碼。
階段性總結
日志記錄和記敘文一樣,只要滿足了六要素就可以說清楚一件事情。而記錄這六要素的形式正式 Extension.Logging 提供給我們的擴展方法的參數形式。
分析問題從端到端分析是一種非常靠譜的分析方法。可以避免走彎路。
ILogger 的擴展方法負責生成日志消息,ILoggerProvider 和負責記錄工作的 ILogger 實現負責格式化日志消息并將日志記錄到特定的目標上去。
利用 SeriLog 實現靈活的日志記錄形式
通過上述分析我們應該能夠看到這種設計的合理性。但是不爭的事實是 ILoggerProvider 一系包攬兩種職能,并沒有進一步抽象,有沒有人來對日志記錄的目標和日志記錄的整體格式進行抽象呢?有!那就是被千萬人喜愛的 SeriLog。它在 ILoggerProvider 一級抽象了 ITextFormatter 解決了這個問題:
我不會在這里介紹 SeriLog 的具體使用方法。網上教程一大堆大家去搜搜好了。我建議直接去官網。
例如,我可以將日志記錄到 Console 中,默認情況下,這種日志的格式是給人看的:
//?normal?initialization?logicvar?serviceCollection?=?new?ServiceCollection();
serviceCollection.AddLogging(b?=>
{
????Logger?seriLogger?=?new?LoggerConfiguration()
????????.WriteTo.Console()
????????.MinimumLevel.Debug()
????????.CreateLogger();
????b.AddSerilog(seriLogger);
});
ServiceProvider?provider?=?serviceCollection.BuildServiceProvider();
//?create?logger
var?loggerFactory?=?provider.GetService<ILoggerFactory>();
var?logger?=?loggerFactory.CreateLogger("category");
//?write?log
logger.LogInformation("Hello?{name}",?"world");
此時屏幕上會輸出高亮版的,適于閱讀的日志,類似這樣:
[18:41:27 INF] Hello world
但是如果希望使用其他的格式,則可以通過 ITextFormatter 快速的轉換格式:
var?serviceCollection?=?new?ServiceCollection();serviceCollection.AddLogging(b?=>
{
????Logger?seriLogger?=?new?LoggerConfiguration()
????????//?PLEASE?NOTE?that?we?use?JsonFormatter?as?input?paramter
????????.WriteTo.Console(new?JsonFormatter())
????????.MinimumLevel.Debug()
????????.CreateLogger();
????b.AddSerilog(seriLogger);
});
ServiceProvider?provider?=?serviceCollection.BuildServiceProvider();
var?loggerFactory?=?provider.GetService<ILoggerFactory>();
var?logger?=?loggerFactory.CreateLogger("category");
logger.LogInformation("Hello?{name}",?"world");
這樣就會得到以下的日志:
{????"Timestamp":"2019-03-24T19:38:54.4833240+08:00",
????"Level":"Information",
????"MessageTemplate":"Hello?{name}",
????"Properties":{"name":"world","SourceContext":"category"}
}
如果還需要 formatter 格式化之后的完整消息,可以在創建 JsonFormatter 時指定 new JsonFormatter(renderMessage: true) 這樣就會得到包含完整可讀消息的結果:
{????"Timestamp":"2019-03-24T19:44:51.0430260+08:00",
????"Level":"Information",
????"MessageTemplate":"Hello?{name}",
????"RenderedMessage":"Hello?\"world\"",
????"Properties":{"name":"world","SourceContext":"category"}
}
這樣,在實際的操作中。我們可以直接使用 Microsoft.Extension.Logging 默認體系對 ILoggerProvider 進行擴展達到對記錄目標和記錄格式的控制;也可以將其與 SeriLog 集成。通過 Sink 和 ITextFormatter 組合的方式分別對記錄目標和記錄格式進行控制。
總結
一圖勝千言:
圖-1 默認 Microsoft.Extension.Logging 類型及信息傳遞路徑
圖-2 集成 SeriLog 后類型及信息傳遞路徑
如果您覺得本文對您有幫助,也歡迎分享給其他的人。我們一起進步。歡迎關注我的博客(https://clrdaily.com)和微信公眾號:
總結
以上是生活随笔為你收集整理的ASP.NET Core 沉思录 - 结构化日志的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 《从零开始学ASP.NET CORE M
- 下一篇: 为什么我们要做单元测试?(二)