# 一、日誌記錄 日誌記錄是什麼?簡單而言,就是通過一些方式記錄應用程式運行中的某一時刻的狀態,保留應用程式當時的信息。這對於我們進行應用程式的分析、審計以及維護有很大的作用。 作為程式員,我們恐怕誰也不敢保證我們開發的軟體應用一定不存在BUG,一定不會出現故障,而當故障出現的時候,日誌就是我們排查 ...
一、日誌記錄
日誌記錄是什麼?簡單而言,就是通過一些方式記錄應用程式運行中的某一時刻的狀態,保留應用程式當時的信息。這對於我們進行應用程式的分析、審計以及維護有很大的作用。
作為程式員,我們恐怕誰也不敢保證我們開發的軟體應用一定不存在BUG,一定不會出現故障,而當故障出現的時候,日誌就是我們排查故障的首要依據,排查故障的第一步一定是查看故障發生時的日誌信息。
當然,日誌也不僅僅只是在排查故障的時候有用,這類稱為錯誤日誌,比較常談的還有安全日誌、審計日誌等等,它根據應用場景、企業團隊對其認知和需要有不同的應用。日誌記錄在軟體工程中更是一種思想,而不止是一種開發技術實現,它被認為是產品團隊對其產品需求沒有特別要求的非功能性使用場景,在軟體框架、開發實現中基本是一種必備的橫切功能點,現在的各種開發語言、框架中基本都具備日誌記錄的實現。
二、ASP.Net Core 的日誌記錄
.NET Core 框架中內置了日誌記錄系統,支持通過統一的 API 進入日誌的記錄,並且支持通過配置各種日誌提供程式以各種不同的方式保存日誌信息,不僅有多種內置的日誌提供程式,也相容各種按照標準規範實現的第三方框架。以下演示代碼基於 .NET 7 。
2.1. 日誌記錄系統的接入
當我們通過 VS 應用模板創建一個 ASP.NET Core 的應用時,預設將日誌記錄系統添加到應用中,內部實際上時在創建 HostApplicationBuilder
的過程中,通過 AddLogging()
註冊了日誌相關的服務,並配置了 Console
、Debug
、EventSource
和 EventLog
(僅Windows)共四種日誌記錄提供程式。
var builder = WebApplication.CreateBuilder(args);
除此之外,我們也可以引入 Microsoft.Extensions.Hosting
包,自行通過通用主機創建應用,以下代碼中也預設添加了日誌記錄系統:
var host = Host.CreateDefaultBuilder().Build();
通過查看源碼,可以看到:
這兩種方式最終都是通過 HostingHostBuilderExtensions
中的 AddDefaultServices()
方法向容器中註入日誌相關的服務,並根據不同平臺配置了不同的日誌提供程式。
除了預設的幾種提供程式,我們也可以通過以下兩種方式根據自己的實際需要,添加其他的日誌提供程式,如比較常用的文件記錄提供程式將日誌輸出到文本文件中,或者清除預設的提供程式進行自定義。在.NET 6、.NET 7 中,微軟推進用第二種方式替代第一種方式。
builder.Host.ConfigureLogging(logging =>
{
// 清除已經註入的日誌提供程式
logging.ClearProviders();
logging.AddConsole();
});
// 清除已經註入的日誌提供程式
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Logging.AddJsonConsole();
關於日誌記錄提供程式,下麵再進行詳細的介紹。
2.2 記錄日誌
將日誌記錄系統集成到應用之後,使用方式就非常簡單了。.NET Core 日誌記錄系統提供統一的 API 進行日誌的記錄,無論底層使用的是什麼日誌提供程式,無論最終是將日誌記錄在哪裡。
我們可以通過依賴註入,在需要記錄日誌的類中從容器中解析出 ILogger<TCategoryName>
這樣一個日誌記錄器實例。日誌記錄器在創建的時候需要指定日誌類別,它會與該記錄器的記錄的每一條日誌關聯,方便我們在眾多的日誌信息中查找特定的日誌。ILogger<TCategoryName>
中的泛型會作為該記錄器的日誌類別,按照 .NET 體系下不成文的約定,一般情況下使用註入記錄器的類作為泛型類型,最終在日誌信息中會以該類的全類名作為日誌類別。
[ApiController]
[Route("[controller]/[action]")]
public class WeatherForecastController : ControllerBase
{
private readonly ILogger<WeatherForecastController> _logger;
public WeatherForecastController(ILogger<WeatherForecastController> logger)
{
_logger = logger;
}
}
我們也可以顯式指定日誌類別,日誌類別實質就是一個字元串,這時我們可以註入 ILoggerFactory
實例,之後通過 ILoggerFactory.CreateLogger
方法自行創建記錄器。
[ApiController]
[Route("[controller]/[action]")]
public class WeatherForecastController : ControllerBase
{
private ILogger _logger;
public WeatherForecastController(ILoggerFactory loggerFactory)
{
_logger = loggerFactory.CreateLogger("MyLogger");
}
}
之後,就是使用記錄器在我們需要的位置記錄日誌信息,記錄器提供了豐富的 API 方便我們記錄各種日誌:
[ApiController]
[Route("[controller]/[action]")]
public class WeatherForecastController : ControllerBase
{
private readonly ILogger<WeatherForecastController> _logger;
public WeatherForecastController(ILogger<WeatherForecastController> logger)
{
_logger = logger;
}
[HttpGet]
public Task Get()
{
// 各種日誌API對應各種日誌級別
// 斷點
_logger.LogTrace("這是一個斷點日誌");
//調試
_logger.LogDebug("this is a debug.");
//信息
_logger.LogInformation("this is an info.");
//警告
_logger.LogWarning("this is a warning.");
//錯誤
_logger.LogError("this is an error.");
//當機
_logger.LogCritical("this is Critical");
// 多個重載方法,支持字元串占位符
_logger.LogInformation("this is an info {date} {level}.", DateTime.Now, 1);
// 支持傳入異常,記錄異常信息
_logger.LogError(new Exception(), "this is an error.");
// 指定日誌ID,方便同類異常的篩選, 常用的日誌id可以定義為常量
_logger.LogError(1001, new Exception(), "this is an error with eventId.");
// 自行指定日誌級別
_logger.Log(LogLevel.Information, "loging an info.");
return Task.CompletedTask;
}
}
當調用該介面的時候,可以看到控制臺中輸出了我們記錄的日誌內容:
這裡沒有輸出 Trace
和 Debug
日誌,是因為預設配置中輸出的最低日誌級別是 Information
,要使 Trace
和 Debug
這兩類日誌可以正常輸出,需要我們進行配置。
2.3 基本配置
2.3.1 日誌級別
上面的代碼中講到,記錄日誌時我們可以指定當前日誌信息的級別,日誌級別表示日誌的嚴重程度,.NET Core 框架日誌系統中日誌級別如下,一共分為7個等級,從輕到重為(最後的None較為特殊):
日誌級別 | 值 | 描述 |
---|---|---|
Trace | 0 | 追蹤級別,包含最詳細的信息。這些信息可能包含敏感數據,預設情況下是禁用的,並且絕不能出現在生產環境中。 |
Debug | 1 | 調試級別,用於開發人員開發和調試。信息量一般比較大,在生產環境中一定要慎用。 |
Information | 2 | 信息級別,該級別平時使用較多。 |
Warning | 3 | 警告級別,一些意外的事件,但這些事件並不對導致程式出錯。 |
Error | 4 | 錯誤級別,一些無法處理的錯誤或異常,這些事件會導致當前操作或請求失敗,但不會導致整個應用出錯。 |
Critical | 5 | 致命錯誤級別,這些錯誤會導致整個應用出錯。例如記憶體不足等。 |
None | 6 | 指示不記錄任何日誌 |
2.3.2 全局輸出配置
我們可以在記錄日誌的時候指定日誌的級別,但是並不是我們記錄的任何一個級別的日誌都會輸出保存,還得配合日誌記錄系統的配置,就像上面的例子中,最開始 Debug
和 Trace
級別的日誌是不輸出的。
日誌記錄配置通常通過配置文件進行設置,在 appsettings.json
文件有關於日誌配置的相關節點 Logging,在我們通過 ASP.NET Core 應用模板創建項目時,就會自動生成:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
在配置文件中,我們可以通過 LogLevel
節點對日誌記錄系統全局輸出的日誌最低級別進行設置,日誌記錄系統最終會輸出大於等於我們設置的級別的日誌信息,而其他信息則不會輸出。
可以看到,在 LogLevel
節點下還有一些欄位,通過這些欄位我們還可以進行更具體的配置。其中“Default”
欄位顧名思義就是預設配置,如上面配置中設置了日誌系統預設輸出的最低級別日誌為 Information
,沒有進行過特殊配置的日誌記錄器全部按照這一個配置進行輸出。
我們還可以針對某一特定的日誌記錄器進行專門的設置,通過日誌記錄器創建時傳入的名稱進行篩選,支持模糊匹配(字元串 StartWith 判斷),如上面配置中的 “Microsoft.AspNetCore”
,這個欄位一看就是命名空間書寫方式,也就是說全類名以該欄位開始的日誌記錄器記錄的日誌按照這個配置設置的最低日誌記錄進行記錄。如果還有更加具體的配置,如“Microsoft.AspNetCore.Mvc”
,一個日誌記錄器名稱同時匹配 “Microsoft.AspNetCore”
和“Microsoft.AspNetCore.Mvc”
,則以 “Microsoft.AspNetCore.Mvc”
的配置為準,因為 “Microsoft.AspNetCore.Mvc”
更具體。
這也是為什麼約定使用 ILogger<TCategoryName>
介面註入日誌記錄器的原因,這種方式下我們可以通過有規律的命名空間快速設置篩選最終需要輸出保存的日誌信息。當然,如果自定義的日誌記錄器名稱字元串比較有規律,那也沒有問題。
2.3.3 針對特定日誌提供程式的配置
在日常的應用開發中,往往我們都會使用不止一種方式記錄日誌,通常會同時集成多個日誌記錄提供程式,LogLevel
節點是針對所有日誌記錄提供程式的統一配置,它適用於所有沒有進行單獨配置的日誌記錄提供程式(Windows EventLog 除外。EventLog 必須顯式地進行配置,否則會使用其預設的 LogLevel.Warning)。當然,我們也可以針對不同的日誌提供程式進行單獨的配置。如:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
},
"Console": {
"LogLevel": {
"Default": "Error"
}
},
"Debug": {
"LogLevel": {
"Microsoft": "None"
}
},
"EventSource": {
"LogLevel": {
"Default": "Trace",
"Microsoft": "Trace",
"Microsoft.Hosting.Lifetime": "Trace"
}
}
}
}
就像 appsettings.{Environment}.json
和 appsettings.json
之間的關係一樣,Logging.{Provider}.LogLevel
中的配置將會覆蓋 Logging.LogLevel
中的配置。例如 Logging.Console.LogLevel.Default
將會覆蓋 Logging.LogLevel.Default
,Console
日誌記錄器將預設記錄 Error
及其以上級別的日誌。
剛纔提到了,Windows EventLog
比較特殊,它不會繼承 Logging.LogLevel
的配置。EventLog 預設日誌級別為 LogLevel.Warning
,如果想要修改,則必須顯式進行指定。
以上講到的日誌配置方式,都是通過 appsettings.json
設置的,實際上 .NET Core 框架下配置來源不僅僅是 appsettings.json
文件,只不過它是最常用的,這一塊的內容在之前的配置系統的文章中已經詳細講過了。我們也可以通過其他的配置來源進行日誌相關的配置,例如命令行、環境變數等。
2.3.6 顯式設置
除了通過配置進行日誌記錄系統的設置之外,我們還可以在代碼中通過 AddFilter
方法顯式地設置日誌系統的相關行為配置,該方法有多個重載,如:
var builder = WebApplication.CreateBuilder(args);
// 相當於 Logging:LogLevel:Default:Information
builder.Logging.AddFilter(logging => logging >= LogLevel.Information);
// 相當於 Logging:LogLevel:Microsoft:Warning
builder.Logging.AddFilter("Microsoft", LogLevel.Warning);
// 相當於 Logging:Console:LogLevel:Microsoft:Information
builder.Logging.AddFilter<ConsoleLoggerProvider>("Microsoft", LogLevel.Information);
// 也可以更加靈活地通過篩選器設置日誌記錄規則
builder.Logging.AddFilter((provider, category, logLevel) =>
{
if (provider.Contains("ConsoleLoggerProvider")
&& category.Contains("Controller")
&& logLevel >= LogLevel.Information)
{
return true;
}
else if (provider.Contains("ConsoleLoggerProvider")
&& category.Contains("Microsoft")
&& logLevel >= LogLevel.Information)
{
return true;
}
else
{
return false;
}
});
// 設置全局的日誌輸出最小級別,日誌記錄系統預設最低級別是 Information
builder.Logging.SetMinimumLevel(LogLevel.Debug);
這種方式相對於配置會比較固化,不利於動態調整,一般來說,日誌記錄的相關配置還是配置文件中設置,所以這裡就簡單地講一下,大家知道有這種方式就行了。
2.3.4 配置篩選原理
當創建 ILogger<TCategoryName>
的對象實例時,ILoggerFactory
根據不同的日誌記錄提供程式,將會:
- 查找匹配該日誌記錄提供程式的配置。如果找不到,則使用通用配置。
- 然後匹配擁有最長首碼的配置類別。如果找不到,則使用Default配置。
- 如果匹配到了多條配置,則採用最後一條。
- 如果沒有匹配到任何配置,則使用 MinimumLevel,這是個配置項,預設是LogLevel.Information。
如上面講到的,我們可以使用 SetMinimumLevel
方法設置 MinimumLevel
。
對於到的 .NET Core 中的源碼是這一段:
在創建 ILoggerFactory
實例、創建 ILogger
實例和配置刷新的時候,都會對每一個提供程式的配置規則根據優先順序進行篩選,只有最小級別不為 None
,才會創建最終的日誌記錄書寫器,否則甚至不會有書寫器。
而在具體的規則過濾邏輯中,可以看到微軟的註釋:
日誌規則過濾 LoggerRuleSelector
internal static class LoggerRuleSelector
{
public static void Select(LoggerFilterOptions options, Type providerType, string category, out LogLevel? minLevel, out Func<string?, string?, LogLevel, bool>? filter)
{
filter = null;
minLevel = options.MinLevel;
// Filter rule selection:
// 1. Select rules for current logger type, if there is none, select ones without logger type specified
// 2. Select rules with longest matching categories
// 3. If there nothing matched by category take all rules without category
// 3. If there is only one rule use it's level and filter
// 4. If there are multiple rules use last
// 5. If there are no applicable rules use global minimal level
string? providerAlias = ProviderAliasUtilities.GetAlias(providerType);
LoggerFilterRule? current = null;
foreach (LoggerFilterRule rule in options.RulesInternal)
{
if (IsBetter(rule, current, providerType.FullName, category)
|| (!string.IsNullOrEmpty(providerAlias) && IsBetter(rule, current, providerAlias, category)))
{
current = rule;
}
}
if (current != null)
{
filter = current.Filter;
minLevel = current.LogLevel;
}
}
private static bool IsBetter(LoggerFilterRule rule, LoggerFilterRule? current, string? logger, string category)
{
// Skip rules with inapplicable type or category
// 別名或者全類名與當前日誌提供程式對不上的則跳過
if (rule.ProviderName != null && rule.ProviderName != logger)
{
return false;
}
// 對日誌類別進行判斷,這裡會同時判斷通用的配置和針對特定日誌提供程式的配置
// 也就是說某個類別,如果通用的LogLevel中配置了,如果特定的日誌特工程式中沒有重新配置覆蓋,則會使用通用配置
// 支持通配符 * ,但 * 只能有一個
string? categoryName = rule.CategoryName;
if (categoryName != null)
{
const char WildcardChar = '*';
int wildcardIndex = categoryName.IndexOf(WildcardChar);
if (wildcardIndex != -1 &&
categoryName.IndexOf(WildcardChar, wildcardIndex + 1) != -1)
{
throw new InvalidOperationException(SR.MoreThanOneWildcard);
}
ReadOnlySpan<char> prefix, suffix;
if (wildcardIndex == -1)
{
prefix = categoryName.AsSpan();
suffix = default;
}
else
{
prefix = categoryName.AsSpan(0, wildcardIndex);
suffix = categoryName.AsSpan(wildcardIndex + 1);
}
if (!category.AsSpan().StartsWith(prefix, StringComparison.OrdinalIgnoreCase) ||
!category.AsSpan().EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
{
return false;
}
}
// 如果相同的類別,則以特定提供程式的配置優先
if (current?.ProviderName != null)
{
if (rule.ProviderName == null)
{
return false;
}
}
else
{
// We want to skip category check when going from no provider to having provider
if (rule.ProviderName != null)
{
return true;
}
}
// 特定的類別優先於預設的 Default 類別
if (current?.CategoryName != null)
{
if (rule.CategoryName == null)
{
return false;
}
// 類別名稱更詳細的優先
if (current.CategoryName.Length > rule.CategoryName.Length)
{
return false;
}
}
return true;
}
}
而 LoggerFilterOptions
中的規則是怎麼來的呢?在通過主機構建應用的時候會通過配置文件載入相關的配置,並將配置轉化為規則
最終,配置文件中的每一個日誌類別的配置都會結合日誌提供程式轉化為一項規則,預設的 LogLevel
中的配置轉換成的規則中 ProviderName
為 null
,預設的 Default
類別對於的規則 CategoryName
為 null
。
日誌規則配置 LoggerFilterConfigureOptions
internal sealed class LoggerFilterConfigureOptions : IConfigureOptions<LoggerFilterOptions>
{
private const string LogLevelKey = "LogLevel";
private const string DefaultCategory = "Default";
private readonly IConfiguration _configuration;
public LoggerFilterConfigureOptions(IConfiguration configuration)
{
_configuration = configuration;
}
public void Configure(LoggerFilterOptions options)
{
LoadDefaultConfigValues(options);
}
private void LoadDefaultConfigValues(LoggerFilterOptions options)
{
if (_configuration == null)
{
return;
}
options.CaptureScopes = GetCaptureScopesValue(options);
foreach (IConfigurationSection configurationSection in _configuration.GetChildren())
{
if (configurationSection.Key.Equals(LogLevelKey, StringComparison.OrdinalIgnoreCase))
{
// Load global category defaults
LoadRules(options, configurationSection, null);
}
else
{
IConfigurationSection logLevelSection = configurationSection.GetSection(LogLevelKey);
if (logLevelSection != null)
{
// Load logger specific rules
string logger = configurationSection.Key;
LoadRules(options, logLevelSection, logger);
}
}
}
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026:RequiresUnreferencedCode",
Justification = "IConfiguration.GetValue is safe when T is a bool.")]
bool GetCaptureScopesValue(LoggerFilterOptions options) => _configuration.GetValue(nameof(options.CaptureScopes), options.CaptureScopes);
}
private static void LoadRules(LoggerFilterOptions options, IConfigurationSection configurationSection, string? logger)
{
foreach (System.Collections.Generic.KeyValuePair<string, string?> section in configurationSection.AsEnumerable(true))
{
if (TryGetSwitch(section.Value, out LogLevel level))
{
string? category = section.Key;
if (category.Equals(DefaultCategory, StringComparison.OrdinalIgnoreCase))
{
category = null;
}
var newRule = new LoggerFilterRule(logger, category, level, null);
options.Rules.Add(newRule);
}
}
}
private static bool TryGetSwitch(string? value, out LogLevel level)
{
if (string.IsNullOrEmpty(value))
{
level = LogLevel.None;
return false;
}
else if (Enum.TryParse(value, true, out level))
{
return true;
}
else
{
throw new InvalidOperationException(SR.Format(SR.ValueNotSupported, value));
}
}
}
而這些是對配置中的規則的處理,最終得到的是 miniLevel
,每次寫日誌的時候會先將當前日誌信息的級別和配置的最低級別進行比較,如果我們還有在代碼中通過 AddFilter
擴展方法增加的額外的規則的化,會在配置規則過濾完成之後再過濾(也就是說,Filter 中是不會有低於配置的級別的日誌的),如果都不通過,則不會轉到最終的記錄器。
2.3.5 日誌作用域
有些時候,我們可能希望某一些日誌集中在一起顯示,或者在進行一些強關聯的邏輯操作時,希望記錄的日誌中保留有關聯信息,這時候就可以使用日誌作用域。日誌作用域依賴於特定的日誌記錄提供程式的支持,並不是所有的提供程式都支持,內置的提供程式中 Console、AzureAppServicesFile 和 AzureAppServicesBlob 提供了相應的支持。可以通過以下的方式啟用日誌作用域:
(1) 通過日誌記錄器的 BeginScope 創建作用域,並使用 using 塊包裝。
[ApiController]
[Route("[controller]/[action]")]
public class WeatherForecastController : ControllerBase
{
private readonly ILogger<WeatherForecastController> _logger;
public WeatherForecastController(ILogger<WeatherForecastController> logger)
{
_logger = logger;
}
[HttpGet]
public Task Get()
{
// 創建一個日誌域,以下日誌會被當作一個整體
using (_logger.BeginScope("this is a log scope"))
{
// 除了使用特定級別的API,也可以使用Log方法,動態指定級別
_logger.Log(LogLevel.Information, "logging a scope info.");
_logger.Log(LogLevel.Warning, "logging a scope warning.");
}
return Task.CompletedTask;
}
}
(2) 在配置中針對日誌提供程式添加 "IncludeScopes: true" 配置
{
"Logging": {
"LogLevel": {
"Default": "Trace",
"Microsoft.AspNetCore": "Warning"
},
"Console": {
"IncludeScopes": true,
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
}
從最終的輸出中可以看到,同一個作用域中記錄的日誌都帶上了創建作用域時設置的標記。同時也可以看到,記錄的日誌中多了 SpanId、TraceId、ParentId 這些內容,這是日誌記錄系統隱式創建範圍對象,這些信息源自於每一次的Http 請求,方便對一次 Http 請求中各個步驟的跟蹤。對於這些信息的配置,可以通過 ActivityTrackingOptions
設置。
var builder = WebApplication.CreateBuilder(args);
builder.Logging.Configure(option =>
{
option.ActivityTrackingOptions = ActivityTrackingOptions.SpanId | ActivityTrackingOptions.TraceId;
});
以下是一些註意點:
- 若要在
Startup.Configure
方法中記錄日誌,直接在參數上註入ILogger<Startup>
即可。 - 不支持在
Startup.ConfigureServices
方法中使用ILogger
,因為此時 DI 容器還未配置完成。 - 沒有非同步的日誌記錄方法。日誌記錄動作執行應該很快,不值的犧牲性能使用非同步方法。如果日誌記錄動作比較耗時,如記錄到 MSSQL 中,那麼請不要直接寫入 MSSQL。你應該考慮先將日誌寫入到快速存儲介質,如記憶體隊列,然後通過後臺工作線程將其從記憶體轉儲到 MSSQL 中。
- 無法使用日誌記錄 API 在應用運行時更改日誌記錄配置。不過,一些配置提供程式(如文件配置提供程式)可重新載入配置,這可以立即更新日誌記錄配置。
參考文章:
.NET Core 和 ASP.NET Core 中的日誌記錄 | Microsoft Learn
理解ASP.NET Core - 日誌(Logging) - xiaoxiaotank - 博客園 (cnblogs.com)
ASP.NET Core 系列:
目錄:ASP.NET Core 系列總結
上一篇:ASP.NET Core - 緩存之分散式緩存
下一篇:[ASP.NET Core - 日誌記錄系統(二)]