ASP.NET Core 6框架揭秘實例演示[13]:日誌的基本編程模式[上篇]

診斷跟蹤的幾種基本編程方式》介紹了四種常用的診斷日誌框架。其實除了微軟提供的這些日誌框架,還有很多第三方日誌框架可供我們選擇,比如Log4Net、NLog和Serilog 等。雖然這些框架大都採用類似的設計,但是它們採用的編程模式具有很大的差異。為了對這些日誌框架進行整合,微軟創建了一個用來提供統一的日誌編程模式的日誌框架。(本篇提供的實例已經匯總到《ASP.NET Core 6框架揭秘-實例演示版》)

[S801]將日誌輸出到控制台和調試窗口(源程式碼
[S802]利用ILoggerFactory工廠創建Ilogger<T>對象(源程式碼
[S803]注入Ilogger<T>對象(源程式碼
[S804]TraceSource和EventSource的日誌輸出(源程式碼
[S805]針對等級的日誌過濾(源程式碼
[S806]針對等級和類別的日誌過濾(源程式碼
[S807]針對等級、類別和ILoggerProvider類型的日誌過濾(源程式碼

[S801]將日誌輸出到控制台和調試窗口

我們通過一個簡單的實例來演示如何將具有不同等級的日誌消息輸出到當前控制台和Visual Studio的調試窗口。如下所示的兩個NuGet包提供了針對這兩種日誌輸出渠道的支援,所以演示程式需要添加針對它們的引用。

  • Microsoft.Extensions.Logging.Console
  • Microsoft.Extensions.Logging.Debug

應用程式一般使用ILoggerFacotry工廠創建的ILogger對象來記錄日誌,下面的演示實例利用依賴注入容器來提供ILoggerFactory對象。如程式碼片段所示,我們創建了一個ServiceCollection對象,並調用AddLogging擴展方法註冊了與日誌相關的核心服務,作為依賴注入容器的IServiceProvider對象被構建出來後,我們從中提取出ILoggerFactory對象。

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

var logger = new ServiceCollection()
    .AddLogging(builder => builder
        .AddConsole()
        .AddDebug())
    .BuildServiceProvider()
    .GetRequiredService<ILoggerFactory>()
    .CreateLogger("Program");

var levels = (LogLevel[])Enum.GetValues(typeof(LogLevel));
levels = levels.Where(it => it != LogLevel.None).ToArray();
var eventId = 1;
Array.ForEach(levels, level => logger.Log(level, eventId++, "This is a/an {0} log message.", level));
Console.Read();

在調用AddLogging擴展方法時,我們利用提供的Action<ILoggingBuilder>委託完成了針對ConsoleLoggerProvider和DebugLoggerProvider的註冊。具體來說,前者由ILoggingBuilder介面的AddConsole擴展方法註冊,後者則由AddDebug擴展方法進行註冊。我們通過指定日誌類別(「Program」)調用ILoggerFactory介面的CreateLogger方法將對應的ILogger對象創建出來。每個ILogger對象都對應一個確定的類別,我們傾向於將當前寫入日誌的組件、服務或者類型名稱作為日誌類別,所以需要指定的是當前類型的名稱「Program」。

我們通過調用ILogger的Log方法針對每個有效的日誌等級分發了六個日誌事件,事件的ID分別被設置成1~6的整數。我們在調用Log方法時通過指定一個包含佔位符({0})的消息模板和對應參數的方式來格式化最終輸出的消息內容。程式啟動後,相應的日誌會以圖1示的形式同時輸出到控制台和Visual Studio的調試窗口。

image
圖1 針對控制台和Debugger的日誌輸出

[S802]利用ILoggerFactory工廠創建Ilogger<T>對象

在前面演示的實例中,我們將字元串形式表示的日誌類別「Program」作為參數調用ILoggerFactory工廠的CreateLogger方法來創建對應的ILogger對象,實際上我們還可以調用泛型的CreateLogger<T>方法創建一個ILogger<T>對象來完成相同的工作。如果調用這個方法,我們就不需要額外提供日誌類別,因為日誌類別會根據泛型參數類型T自動解析出來。在如下的程式碼片段中,我們調用了ILoggerFactory工廠的CreateLogger<Program>方法將對應的 ILogger<Program>對象創建出來。作為日誌負載內容的消息模板除了可以採用{0},{1},…,{n}這樣的佔位符,還可以使用任意字元串(「{level}」)來表示。啟動改寫的程式之後,輸出到控制台和調試輸出窗口的內容與圖1完全一致的。

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

var logger = new ServiceCollection()
    .AddLogging(builder => builder
        .AddConsole()
        .AddDebug())
    .BuildServiceProvider()
    .GetRequiredService<ILoggerFactory>()
    .CreateLogger<Program>();
var levels = (LogLevel[])Enum.GetValues(typeof(LogLevel));
levels = levels.Where(it => it != LogLevel.None).ToArray();
var eventId = 1;
Array.ForEach(levels, level => logger.Log(level, eventId++, "This is a/an {level} log message.", level));
Console.Read();

[S803]注入Ilogger<T>對象

除了利用ILoggerFactory工廠來創建泛型的ILogger<Program>對象之外,我們還具有更簡潔的方式,那就是按照如下的方式直接利用IServiceProvider對象來提供這個ILogger<Program>對象。換句話說,ILogger<T>實際上是可以作為依賴服務注入到消費它的類型中。

...
var logger = new ServiceCollection()
    .AddLogging(builder => builder
        .AddConsole()
        .AddDebug())
.BuildServiceProvider()
.GetRequiredService<ILogger<Program>>();
...

[S804]TraceSource和EventSource的日誌輸出

除了控制台和調試器這兩種輸出渠道,日誌框架還提供針對其他輸出渠道的支援。第7章重點介紹了針對TraceSource和EventSource的日誌框架也是默認支援的兩種輸出渠道。針對這兩種輸出渠道的整合式由如下兩個NuGet包提供的。

  • Microsoft.Extensions.Logging.TraceSource
  • Microsoft.Extensions.Logging.EventSource

在添加了上述兩個NuGet包的引用之後,我們對演示實例作了如下的修改。為了捕捉由EventSource分發的日誌事件,我們自定義了一個FoobarEventListener類型。我們在應用啟動的時候創建了這個FoobarEventListener對象並分別註冊了它的EventSourceCreated和EventWritten事件。一個名為「Microsoft-Extensions-Logging」的EventSource會幫助我們完成日誌的輸出,所以EventSourceCreated事件的處理程式專門訂閱了這個EventSource。

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using System.Diagnostics;
using System.Diagnostics.Tracing;

var listener = new FoobarEventListener();
listener.EventSourceCreated += (sender, args) =>
{
    if (args.EventSource?.Name == "Microsoft-Extensions-Logging")
    {
        listener.EnableEvents(args.EventSource, EventLevel.LogAlways);
    }
};

listener.EventWritten += (sender, args) =>
{
    var payload 	= args.Payload;
    var payloadNames 	= args.PayloadNames;
    if (args.EventName == "FormattedMessage" && payload != null && payloadNames !=null)
    {
        var indexOfLevel = payloadNames.IndexOf("Level");
        var indexOfCategory = payloadNames.IndexOf("LoggerName");
        var indexOfEventId = payloadNames.IndexOf("EventId");
        var indexOfMessage = payloadNames.IndexOf("FormattedMessage");
        Console.WriteLine(@$"{(LogLevel)payload[indexOfLevel],-11}: { payload[indexOfCategory]}[{ payload[indexOfEventId]}]");
        Console.WriteLine($"{"",-13}{payload[indexOfMessage]}");
    }
};

var logger = new ServiceCollection()
    .AddLogging(builder => builder
        .AddTraceSource(new SourceSwitch("default", "All"), new DefaultTraceListener { LogFileName = "trace.log" })
        .AddEventSourceLogger())
    .BuildServiceProvider()
    .GetRequiredService<ILogger<Program>();

var levels = (LogLevel[])Enum.GetValues(typeof(LogLevel));
levels = levels.Where(it => it != LogLevel.None).ToArray();
var eventId = 1;
Array.ForEach(levels, level => logger.Log(level, eventId++, "This is a/an {level} log message.", level));

internal class FoobarEventListener : EventListener
{ }

上述的EventSource對象在進行日誌分發的時候,它會採用不同的方式對將日誌消息進行格式化,最終將格式化後的內容作為荷載內容的一部分通過多個事件分發出去,EventWritten事件處理程式選擇的是一個名為FormattedMessage的事件,它會將包括格式化日誌消息在內的內容荷載資訊輸出到控制台上。

基於TraceSource和EventSource日誌框架的輸出渠道是調用ILoggingBuilder的AddTraceSource和AddEventSourceLogger擴展方法進行註冊的。針對AddTraceSource擴展方法的調用提供了兩個參數,前者是作為全局過濾器的SourceSwitch對象,後者則是註冊的DefaultTraceListener對象。由於我們為註冊的DefaultTraceListener指定了日誌文件的路徑,所以輸出的日誌消息最終會被寫入指定的文件中。程式運行後,日誌消息會以如圖2示的形式同時輸出到控制台和指定的日誌文件中(trace.log)。
image
圖2 對TraceSource和EventSource的日誌輸出

[S805]針對等級的日誌過濾

對於使用ILogger或者ILogger<T>對象分發的日誌事件,並不能保證都會進入最終的輸出渠道,因為註冊的ILoggerProvider對象會對日誌進行過濾,只有符合過濾條件的日誌消息才會被真正地輸出到對應的渠道。每一個分發的日誌事件都具有一個確定的等級。一般來說,日誌消息的等級越高,表明對應的日誌事件越重要或者反映的問題越嚴重,自然就越應該被記錄下來,所以在很多情況下我們指定的過濾條件只需要一個最低等級,所有不低於(等於或者高於)該等級的日誌都會被記錄下來。最低日誌等級在默認情況下被設置為Information,這就是前面演示實例中等級為Trace和Debug的兩條日誌沒有被真正輸出的原因。如果需要將這個作為輸出「門檻」的日誌等級設置得更高或者更低,我們只需要將指定的等級作為參數調用ILoggingBuilder介面的SetMinimumLevel方法即可。

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

var logger = new ServiceCollection().AddLogging(builder => builder
    .SetMinimumLevel(LogLevel.Trace)
    .AddConsole())
    .BuildServiceProvider()
.GetRequiredService<ILogger<Program>>();

var levels = (LogLevel[])Enum.GetValues(typeof(LogLevel));
levels = levels.Where(it => it != LogLevel.None).ToArray();
var eventId = 1;
Array.ForEach(levels, level => logger.Log(level, eventId++, "This is a/an {level} log message.", level));
Console.Read();

如上面的程式碼片段所示,在調用AddLogging擴展方法時,我們調用ILoggingBuilder介面的SetMinimumLevel方法將最低日誌等級設置為Trace。由於設置的是最低等級,所以所有的日誌消息都會以圖3示的形式輸出到控制台上。

image
圖3 過設置最低等級控制輸出的日誌

[S806]針對等級和類別的日誌過濾

雖然「過濾不低於指定等級的日誌消息」是常用的日誌過濾規則,但過濾規則的靈活度並不限於此,很多時候還會同時考慮日誌的類別。在創建對應ILogger時,由於一般將當前組件、服務或者類型的名稱作為日誌類別,所以日誌類別基本上體現了日誌消息來源。如果我們只希望輸出由某個組件或者服務發出的日誌事件,就需要針對類別對日誌事件實施過濾。綜上可知,日誌過濾條件其實可以通過一個類型為Func<string, LogLevel, bool>的委託對象來表示,它的兩個輸入參數分別代表日誌事件的類別和等級。下面通過提供這樣一個委託對象對日誌消息做更細粒度的過濾,所以需要對演示程式做如下修改。

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

var loggerFactory = new ServiceCollection()
    .AddLogging(builder => builder
        .AddFilter(Filter)
        .AddConsole())
    .BuildServiceProvider()
    .GetRequiredService<ILoggerFactory>();

Log(loggerFactory, "Foo");
Log(loggerFactory, "Bar");
Log(loggerFactory, "Baz");

Console.Read();

static void Log(ILoggerFactory loggerFactory, string category)
{
    var logger = loggerFactory.CreateLogger(category);
    var levels = (LogLevel[])Enum.GetValues(typeof(LogLevel));
    levels = levels.Where(it => it != LogLevel.None).ToArray();
    var eventId = 1;
Array.ForEach(levels, level => logger.Log(level, eventId++, "This is a/an {0} log message.", level));
}

static bool Filter(string category, LogLevel level)
{
    return category switch
    {
        "Foo" => level >= LogLevel.Debug,
        "Bar" => level >= LogLevel.Warning,
        "Baz" => level >= LogLevel.None,
        _ => level >= LogLevel.Information,
    };
}

如上面的程式碼片段所示,作為日誌過濾器的Func<string, LogLevel, bool>對象定義的過濾規則如下:對於日誌類別Foo和Bar,我們只會選擇輸出等級不低於Debug和Warning的日誌;對於日誌類別Baz,任何等級的日誌事件都不會被選擇;至於其他日誌類別,我們採用默認的最低等級Information。在執行AddLogging擴展方法時,我們調用ILoggerBuilder介面的AddFilter方法將Func<string, LogLevel, bool>對象註冊為全局過濾器。我們利用依賴注入容器提供的ILoggerFactory工廠創建了三個ILogger對象,它們採用的類別分別為「Foo」、「Bar」和「Baz」。我們最後利用這三個ILogger對象分髮針對不同等級的六次日誌事件,滿足過濾條件的日誌消息會以圖4所示的形式輸出到控制台上。

image
圖4 針對類別和等級的日誌過濾

[S807]針對等級、類別和ILoggerProvider類型的日誌過濾

不論是通過調用ILoggerBuilder介面的SetMinimumLevel方法設置的最低日誌等級,還是通過調用AddFilter擴展方法提供的過濾器,設置的日誌過濾規則針對的都是所有註冊的ILoggerProvider對象,但是有時需要將過濾規則應用到某個具體的ILoggerProvider對象上。如果將ILoggerProvider對象引入日誌過濾規則中,那麼日誌過濾器就應該表示成一個類型為Func<string, string, LogLevel, bool>的委託對象,該委託的三個輸入參數分別表示ILoggerProvider類型的全名、日誌類別和等級。為了演示針對LoggerProvider的日誌過濾,可以將演示程式做如下改動。

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Console;
using Microsoft.Extensions.Logging.Debug;

var logger = new ServiceCollection()
    .AddLogging(builder => builder
        .AddFilter(Filter)
        .AddConsole()
        .AddDebug())
    .BuildServiceProvider()
    .GetRequiredService<ILoggerFactory>()
    .CreateLogger("App.Program");

var levels = (LogLevel[])Enum.GetValues(typeof(LogLevel));
levels = levels.Where(it => it != LogLevel.None).ToArray();
var eventId = 1;
Array.ForEach(levels, level => logger.Log(level, eventId++,"This is a/an {0} log message.", level));
Console.Read();

static bool Filter(string provider, string category, LogLevel level) => provider switch
{
    var p when p == typeof(ConsoleLoggerProvider).FullName => level >= LogLevel.Debug,
    var p when p == typeof(DebugLoggerProvider).FullName => level >= LogLevel.Warning,
    _ => true,
};

如上面的程式碼片段所示,我們註冊的過濾器體現的過濾規則如下:ConsoleLoggerProvider,和DebugLoggerProvider的最低日誌等級分別設置為Debug和Warning,至於其他的ILoggerProvider類型則不做任何的過濾。我們演示程式同時註冊了ConsoleLoggerProvider和DebugLoggerProvider,對於分發的12條日誌消息,5條會在控制台上輸出,3條會出現在Visual Studio的調試輸出窗口中。

image
圖5 對ILoggerProvider類型的日誌過濾