1. 程式人生 > 實用技巧 >.NET Core下的日誌(3):如何將日誌訊息輸出到控制檯上

.NET Core下的日誌(3):如何將日誌訊息輸出到控制檯上

當我們利用LoggerFactory建立一個Logger物件並利用它來實現日誌記錄,這個過程會產生一個日誌訊息,日誌訊息的流向取決於註冊到LoggerFactory之上的LoggerProvider。說的更加具體一點,日誌訊息的歸宿取決於註冊到LoggerFactory的LoggerProvider究竟會提供怎樣的Logger。微軟提供了一系列原生的LoggerProvider,我們先來認識一下將控制檯作為日誌輸出目的地的ConsoleLoggerProvider。ConsoleLoggerProvider會提供一個名為ConsoleLogger的Logger物件,讓後者在進行日誌寫入的時候會將格式化的日誌訊息輸出到當前控制檯上,這兩個型別(ConsoleLoggerProvider和ConsoleLogger)均定義在NuGet包“Microsoft.Extensions.Logging.Console”之中。

目錄
一、ConsoleLogger
二、ConsoleLogScope
三、ConsoleLoggerProvider
四、擴充套件方法AddConsole

一、ConsoleLogger

如下所示的程式碼片段展示了由ConsoleLoggerProvider提供的這個ConsoleLogger型別的定義。ConsoleLogger具有四個屬性,代表Logger名稱的Name屬性最初由ConsoleLoggerProvider提供,實際上就是LoggerFactory在建立Logger時指定的日誌型別。出於對跨平臺的支援,ConsoleLogger對不同平臺下控制檯進行了抽象並使用介面IConsole來表示,所示程式碼當前控制檯的Console屬性的型別為IConsole。Func<string, LogLevel, bool>型別的Filter屬性提供了一個針對日誌型別與等級的過濾條件,是否真正需要將提供的日誌訊息輸出到控制檯就由這個過濾條件來決定。最後一個屬性IncludeScopes與上面提到的關聯多次日誌記錄的上下文範圍有關,我們後續內容中對此進行單獨介紹。

   1: public class ConsoleLogger : ILogger
   2: {
   3:     public string                           Name { get; }
   4:     public IConsole                         Console { get; set; }
   5:     public Func<string, LogLevel, bool>     Filter { get; set; }
   6:     public bool                             IncludeScopes { get; set; }    
   7: 
   8:     public ConsoleLogger(string name, Func<string, LogLevel, bool> filter, bool includeScopes);public IDisposable BeginScope<TState>(TState state);
   9:   
  10:     public bool IsEnabled(LogLevel logLevel);
  11:     public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter);  
  12:     public virtual void WriteMessage(LogLevel logLevel, string logName, int eventId, string message);
  13: }

對於ConsoleLogger的這四個屬性,除了表示當前控制檯的Console屬性,其餘三個均可以在建立它的時候通過建構函式的相應引數來指定。接下來我們來了解一下用於抽象不同平臺控制檯的IConsole介面,如下面的程式碼片段所示,IConsole介面具有如下三個方法。在呼叫Write和WriteLine方法寫入日誌的時候,我們除了指定寫入的訊息文字之外,還可以控制訊息在控制檯上的背景和前景顏色。Flush方法與資料輸出緩衝機制有關,如果採用緩衝機制,通過Write或者WriteLine方法寫入的訊息並不會立即輸出到控制檯,而是先被儲存到緩衝區,Flush方法被執行的時候會將緩衝區的所有日誌訊息批量輸出到控制檯上。

   1: public interface IConsole
   2: {
   3:     void Write(string message, ConsoleColor? background, ConsoleColor? foreground);
   4:     void WriteLine(string message, ConsoleColor? background, ConsoleColor? foreground);    
   5:     void Flush();
   6: }

微軟預設提供了兩種型別的Console型別,一種是基於Windows平臺的WindowsLogConsole,非Windows平臺的控制檯則通過AnsiLogConsole來表示。它們之間的不同之處在於對日誌訊息在控制檯上顯示顏色(前景色和背景色)的控制。對於Windows平臺來說,訊息顯示在控制檯顏色是通過顯式設定System.Console的靜態屬性ForegroundColor和BackgroundColor來實現的,但是對於非Windows平臺來說,顏色資訊會直接以基於ASNI標準的轉意字元序列(ANSI Esacpe Sequences)的形式內嵌在訊息文字之中)。

ConsoleLogger的IsEnabled方法最終決定了是否需要真正完成對提供日誌的寫入操作,這方法是由Filter屬性返回的委託物件的執行結果。當Log方法執行的時候,它會先呼叫IsEnabled方法,如果這個方法返回True,它呼叫另一個WriteMessage方法將提供的日誌訊息輸出到由Console屬性表示的控制檯上。WriteMessage方法是一個虛方法,如果它輸出的訊息格式和樣式不滿足我們的要求,我們可以定義ConsoleLogger的子類,並通過重寫這個方法按照我們希望的方式輸出日誌訊息。

   1: {LogLevel} : {Category}[{EventId}]
   2: {Message}

在預設情況下,被ConsoleLogger輸出到控制檯上的日誌訊息會採用上面的格式,這也可以通過我們在上面演示的例項來印證。對於輸出到控制檯表示日誌等級的部分,輸出的文字與對應的日誌等級具有如表1所示的對映關係,可以看出日誌等級在控制檯上均會顯示為僅包含四個字母的簡寫形式。日誌等級也同時決定了改部分內容在控制檯上顯示的前景色。

二、ConsoleLogScope

在預設情況下針對Log方法的每次呼叫都是一次獨立的日誌記錄行為,但是在很多情況下多次相關的日誌記錄需要在同一個上下文範圍中進行,我們可以通過呼叫Logger的BeginScope方法來建立這個上下文範圍。對於ConsoleLogger來說,它的BeginScope方法建立的上下文範圍與一個具有如下定義的ConsoleLogScope類有關。

   1: public class ConsoleLogScope
   2: {   
   3:     internal ConsoleLogScope(string name, object state);
   4:     public static IDisposable Push(string name, object state);
   5:     public override string ToString();
   6: 
   7:     public static ConsoleLogScope     Current { get; set; }
   8:     public ConsoleLogScope            Parent { get; set; }
   9: }

我們說ConsoleLogger的BeginScope方法返回的日誌上下文範圍與ConsoleLogScope有關,但並沒有說該方法返回的是一個ConsoleLogScope物件,關於這一點從上面給出的ConsoleLogScope型別定義也可以看出來,BeginScope方法返回型別為IDisposable介面,但是ConsoleLogScope並未實現該介面。如上面的程式碼片段所示,ConsoleLogScope只定義了一個內部建構函式,所以我們不可以直接呼叫建構函式建立一個ConsoleLogScope物件,ConsoleLogScope的建立實現在它的靜態方法Push中,ConsoleLogger的BeginScope方法的返回值其實就是針對這方法的呼叫結果。

要了解實現在Push方法中針對ConsoleLogScope的建立邏輯,需要先來了解一下ConsoleLogScope的巢狀層次結構。一個ConsoleLogScope可以內嵌於另一個ConsoleLogScope之中,後者被稱為前者的“父親”,它的Parent屬性返回的就是這麼一個物件。ConsoleLogScope的靜態屬性Current表示當前的ConsoleLogScope,當我們通過指定name和state這兩個引數呼叫靜態方法Push時,該方法實際上會呼叫靜態建構函式建立一個新的ConsoleLogScope物件並將其作為當前ConsoleLogScope的“兒子”。於此同時,當前ConsoleLogScope被切換成這個新建立的ConsoleLogScope。

ConsoleLogScope的Push方法最終返回的是一個DisposableScope物件。如下面的程式碼片段所示,DisposableScope僅僅是內嵌於ConsoleLogScope的一個私有型別。當它的Dispose方法執行的時候,它僅僅是獲取當前ConsoleLogScope的“父親”,並將後者作為當前ConsoleLogScope。

   1: public class ConsoleLogScope
   2: {
   3:     public static IDisposable Push(string name, object state)
   4:     {
   5:         ConsoleLogScope current = Current;
   6:         Current = new ConsoleLogScope(name, state);
   7:         Current.Parent = current;
   8:         return new DisposableScope();
   9:     }
  10: 
  11:     private class DisposableScope : IDisposable
  12:     {
  13:         public void Dispose()
  14:         {
  15:             ConsoleLogScope.Current = ConsoleLogScope.Current.Parent;
  16:         }
  17:     }
  18: }

簡單地說,我們呼叫ConsoleLogScope的Push方法建立當前日誌上下文範圍並返回一個DisposableScope物件,後者的Dispose方法的呼叫意味著這個上下文範圍的終結。與此同時,原來的ConsoleLogScope從新成為當前的上下文範圍。下面的程式碼片段體現了基於ConsoleLogScope的作用域控制方法,這段程式碼來體現另一個細節,那就是ConsoleLogScope的ToString方法被重寫,它返回的是ConsoleLogScope物件被建立時指定的State物件(state引數)的字串形式(呼叫ToString方法的返回值)。

   1: using (ConsoleLogScope.Push("App", "Scope1"))
   2: {
   3:     Debug.Assert("Scope1" == ConsoleLogScope.Current.ToString());
   4:     using (ConsoleLogScope.Push("App", "Scope1"))
   5:     {
   6:         Debug.Assert("Scope2" == ConsoleLogScope.Current.ToString());
   7:     }
   8:     Debug.Assert("Scope1" == ConsoleLogScope.Current.ToString());
   9: }

當ConsoleLogger的BeginScope方法被執行的時候,它會將自己的名稱(Name屬性)和指定的State物件作為引數呼叫ConsoleLogScope的靜態方法Push。只要我們沒有呼叫返回物件的Dispose方法,就可以表示當前日誌上下文範圍的ConsoleLogScope物件,這個物件和我們指定的State物件的ToString方法返回相同的字串。

   1: public class ConsoleLogger : ILogger
   2: {
   3:    public IDisposable BeginScope<TState>(TState state)
   4:    {  
   5:       return ConsoleLogScope.Push(this.Name, state);
   6:    }
   7: }

如果一個ConsoleLogger物件的IncludeScopes屬性返回True,意味著我們希望針對它的日誌記錄會在一個預先建立的日誌上下文範圍中執行執行,輸出到控制檯的日誌訊息會包含當前上下文範圍的資訊。在次情況下,ConsoleLogger會採用如下的格式呈現輸出在控制檯上的日誌訊息,其中{State}表示呼叫BeginScope方法傳入的State物件。

   1: {LogLevel} : {Category}[{EventId}]
   2:              =>{State}
   3:           {Message}

比如在一個處理訂購訂單的應用場景中,需要將針對同一筆訂單的多條日誌訊息關聯在一起,我們就可以針對訂單的ID建立一個日誌上下文範圍,並在此上下文範圍內呼叫Logger物件的Log方法進行日誌記錄,那麼訂單ID將會包含在每條寫入的日誌訊息中。

   1: ILogger logger = new ServiceCollection()
   2:     .AddLogging()
   3:     .BuildServiceProvider()
   4:     .GetService<ILoggerFactory>()
   5:     .AddConsole(true)
   6:     .CreateLogger("App");
   7: 
   8: using (logger.BeginScope("訂單: {ID}", "20160520001"))
   9: {
  10:     logger.LogWarning("商品庫存不足(商品ID: {0}, 當前庫存:{1}, 訂購數量:{2})", "9787121237812",20, 50);
  11:     logger.LogError("商品ID錄入錯誤(商品ID: {0})","9787121235368");
  12: }

如上面的程式碼片段所示,我們按照依賴注入的方式建立了一個註冊有ConsoleLoggerProvider的LoggerFactory,並利用建立了一個Logger物件。在呼叫註冊ConsoleLoggerProvider的AddConsole方法時,我們傳入True作為引數,意味著提供的ConsoleLogger會在當前的日誌上下文範圍中進行日誌記錄(它 的IncludeScope屬性被設定為True)。我們通過Logger物件記錄了兩條針對同一筆訂單的日誌,兩次日誌記錄所在的上下文範圍是呼叫BeginScope方法根據指定 的訂單ID建立的。這段程式執行之後會在控制檯上輸出如下所示的兩條日誌訊息。

三、ConsoleLoggerProvider

ConsoleLogger最終通過註冊到LoggerFactory上的ConsoleLoggerProvider來提供。當我們在建立一個ConsoleLogger的時候,除了需要指定它的名稱之外,還需要指定一個進行日誌過濾的Func<string, LogLevel, bool>型別的委託物件和確定是否將日誌寫入操作納入當前上下文範圍的布林值。由於這兩個物件最終都需要通過ConsoleLoggerProvider來提供,所以它具有對應的建構函式。

   1: public class ConsoleLoggerProvider : ILoggerProvider, IDisposable
   2: {    
   3:     public ConsoleLoggerProvider(Func<string, LogLevel, bool> filter,bool includeScopes);
   4:     public ConsoleLoggerProvider(IConsoleLoggerSettings settings);
   5: 
   6:     public ILogger CreateLogger(string name);
   7:     public void Dispose();
   8: }

ConsoleLoggerProvider還具有另一個建構函式過載,它接受一個IConsoleLoggerSettings介面的引數,該介面表示為建立的ConsoleLogger而指定的配置。配置的目的是為了指導ConsoleLoggerProvider建立正確的ConsoleLogger,所以它最終還是為了提供日誌寫入過濾條件和是否將日誌寫入操作納入當前上下文範圍的布林值,前者體現為IConsoleLoggerSettings介面的TryGetSwitch方法,後者自然對應其IncludeScopes屬性。

   1: public interface IConsoleLoggerSettings
   2: {    
   3:     bool IncludeScopes { get; }
   4:     IChangeToken ChangeToken { get; }
   5: 
   6:     IConsoleLoggerSettings Reload();
   7:     bool TryGetSwitch(string name, out LogLevel level);    
   8: }

由於配置資料具有不同的載體,或者具有不同來源,比如檔案、資料庫和環境變數等,所以需要考慮應用於配置源的同步問題。IConsoleLoggerSettings的ChangeToken提供了一個嚮應用通知配置源發生改變的令牌,另一個Reload則在配置源發生改變時從新載入配置。

在NuGet包“Microsoft.Extensions.Logging.Console”中提供了兩個實現了IConsoleLoggerSettings介面的型別,其中一個是具有如下定義的ConsoleLoggerSettings。ConsoleLoggerSettings的實現方式非常簡單,它通過一個字典物件來儲存日誌型別與最低等級(低於該等級的日誌將被ConsoleLogger忽略)之間的對映,並利用它來實現TryGetSwitch方法。由於配置原資料體現為一個記憶體變數,所以無需考慮配置的同步問題,所以ConsoleLoggerSettings的Reload方法的返回值是它自己,ChangeToken被定義成簡單的可讀寫的屬性。

   1: public class ConsoleLoggerSettings : IConsoleLoggerSettings
   2: {
   3:     public bool                              IncludeScopes { get; set; }
   4:     public IChangeToken                      ChangeToken { get; set; } 
   5:     public IDictionary<string, LogLevel>     Switches { get; set; } = new Dictionary<string, LogLevel>();
   6: 
   7:     public IConsoleLoggerSettings Reload() => this;
   8:     public bool TryGetSwitch(string name, out LogLevel level)=> Switches.TryGetValue(name, out level);
   9: }

IConsoleLoggerSettings介面的另一個實現者ConfigurationConsoleLoggerSettings則直接採用真正的配置來提供建立ConsoleLogger使用的設定。如下面的程式碼片段所示,ConfigurationConsoleLoggerSettings的建構函式的唯一引數型別為IConfiguration介面,它的IncludeScopes屬性和TryGetSwitch方法的返回值都是利用這個Configuration物件承載的配置計算出來的。至於資料的同步,則直接藉助配置模型自身的同步機制來實現。

   1: public class ConfigurationConsoleLoggerSettings : IConsoleLoggerSettings
   2: {    
   3:     public bool             IncludeScopes { get; }
   4:     public IChangeToken     ChangeToken { get; }
   5:    
   6:     public ConfigurationConsoleLoggerSettings(IConfiguration configuration);
   7: 
   8:     public IConsoleLoggerSettings Reload();
   9:     public bool TryGetSwitch(string name, out LogLevel level);
  10: }

如下所示的程式碼片段以JSON格式定義了ConfigurationConsoleLoggerSettings期望的配置結構。我們可以看到這個配置和ConsoleLoggerSettings一樣,除了直接提供與日誌上下文範圍的IncludeScopes屬性之外,還定義一組日誌型別與最低等級直接的對映關係。對於這組對映關係中指定的某種型別的日誌,只有在不低於設定的等級才會被ConsoleLogger輸出到控制檯。

   1: {
   2:   "includeScopes": true|false,
   3:   "logLevel":{
   4:     "Category1": "Debug",
   5:     "Category2": "Error",
   6:     …
   7: }
   8: 

關於ConsoleLoggerProvider針對ConsoleLogger的建立,有一個細節值得一提。當我們呼叫它的CreateLogger方法的時候,ConsoleLoggerProvider並不總是直接建立一個新的ConsoleLogger物件。實際上它會對建立的ConsoleLogger根據其名稱進行快取,如果後續呼叫CreateLogger方法時指定相同的名稱,快取的ConsoleLogger物件會直接作為返回值。ConsoleLoggerProvider針對ConsoleLogger的快取體現在如下所示的程式碼片段中。

   1: ConsoleLoggerProvider loggerProvider = new ConsoleLoggerProvider(new ConsoleLoggerSettings());
   2: Debug.Assert(ReferenceEquals(loggerProvider.CreateLogger("App"), loggerProvider.CreateLogger("App")));


四、擴充套件方法AddConsole

針對ILoggerFactory介面的擴充套件方法AddConsole幫助我們根據提供的引數建立一個ConsoleLoggerProvider物件並將其註冊到指定的LoggerFactory之上。我們在前面的使用了少數幾個AddConsole方法過載之外,實際上AddConsole方法還存在很多其他的過載。對於如下所示的這些AddConsole方法,它提供了不同型別的引數幫助我們建立ConsoleLoggerProvider物件。經過了上面對ConsoleLoggerProvider的詳細介紹,相信大家對每個引數所代表的含義會有正確的理解。

   1: public static class ConsoleLoggerExtensions
   2: {
   3:     public static ILoggerFactory AddConsole(this ILoggerFactory factory);
   4:     public static ILoggerFactory AddConsole(this ILoggerFactory factory, IConfiguration configuration);
   5:     public static ILoggerFactory AddConsole(this ILoggerFactory factory, IConsoleLoggerSettings settings);
   6:     public static ILoggerFactory AddConsole(this ILoggerFactory factory, LogLevel minLevel);
   7:     public static ILoggerFactory AddConsole(this ILoggerFactory factory, bool includeScopes);
   8:     public static ILoggerFactory AddConsole(this ILoggerFactory factory, Func<string, LogLevel, bool> filter);
   9:     public static ILoggerFactory AddConsole(this ILoggerFactory factory, LogLevel minLevel, bool includeScopes);
  10:     public static ILoggerFactory AddConsole(this ILoggerFactory factory, Func<string, LogLevel, bool> filter, bool includeScopes);
  11: }

接下來通過一個例項來演示通過指定一個Configuration物件來呼叫擴充套件方法AddConsole來建立並註冊ConsoleLoggerProvider。我們在一個.NET Core控制檯應用的project.json檔案中添加了針對如下幾個NuGet包的依賴。

   1: {
   2:   "dependencies": {
   3:     …
   4:     "Microsoft.Extensions.DependencyInjection"     : "1.0.0-rc2-final",
   5:     "Microsoft.Extensions.Logging"                : "1.0.0-rc2-final",
   6:     "Microsoft.Extensions.Logging.Console"        : "1.0.0-rc2-final",
   7:     "Microsoft.Extensions.Configuration.Json"     : "1.0.0-rc2-final",
   8:     "System.Text.Encoding.CodePages"              : "4.0.1-rc2-24027"
   9:   }
  10: }

我們將ConsoleLogger的相關配置按照如下的形式定義在一個JSON檔案中,並將其命名為log.json。通過這個配置,我們要求建立的ConsoleLogger忽略當前的日誌上下文範圍,併為型別“App”的日誌設定的最低的等級“Warning”。

   1: {
   2:   "IncludeScopes": false,
   3:   "LogLevel": {
   4:     "App": "Warning"
   5:   }
   6: }

我們在作為入口的Main方法中編寫了下面一段程式。我們通過載入上面這個log.json檔案建立了一個Configuration物件,並將其作為引數呼叫擴充套件方法AddConsole將建立的ConsoleLoggerProvider註冊到LoggerFactory上面。我們利用LoggerFactory針對日誌型別“App”建立了一個Logger物件,並利用後者記錄三條日誌。

   1: public class Program
   2: {
   3:     public static void Main(string[] args)
   4:     {
   5:         Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
   6: 
   7:         IConfiguration settings = new ConfigurationBuilder()
   8:             .AddJsonFile("log.json")
   9:             .Build();
  10: 
  11:         ILogger logger = new ServiceCollection()
  12:             .AddLogging()
  13:             .BuildServiceProvider()
  14:             .GetService<ILoggerFactory>()
  15:             .AddConsole(settings)
  16:             .CreateLogger("App");
  17: 
  18:         int eventId = 3721;
  19:         logger.LogInformation(eventId, "升級到最新版本({version})", "1.0.0.rc2");
  20:         logger.LogWarning(eventId, "併發量接近上限({maximum}) ", 200);
  21:         logger.LogError(eventId, "資料庫連線失敗(資料庫:{Database},使用者名稱:{User})", "TestDb", "sa");
  22:     }
  23: }

根據定義在配置檔案中的日誌開關,只有等級不低於Warning的日誌才會真正被ConsoleLogger輸出到控制檯上,所以對於上面程式中記錄的三條日誌,控制檯上只會按照如下的形式呈現出等級分別為Warning和Error的兩條,等級為Information的日誌直接被忽略。