人天生有一种能力------信息过滤。只要不是废物都具备这种能力,不需要算法和数据。生物许多行为是本能反应,深深刻进潜意识里。这就是人与机器的本质区别。机器在某些方面一旦没有相关的程序和算法支持,它就是废铁。
日志消息是给人看的,所以,过滤功能必不可少。即应用程序可以根据配置记录或不记录某些日志。
日志级别
日志级别由 LogLevel 枚举类型定义,从小到大依次为:T D I W E C。看不懂?没事,来一张图。

T:Trace------此级别一般用来记录跟踪信息,级别最低,最详细,输出量最多,最影响性能,看得人最烦,江湖外号"五最散人"。此级别推荐在测试阶段用,可以跟踪应用程序的各个细节,以验证程序是否按预期运行。
D:Debug------这个表示调试阶段所用,比 Trace 要稍精简一点,一般可用在你在开发时不太确定的地方可以输出调试日志来确认。
I:Information------从这个级别起,都是默认会输出的日志。Infor 表示常规消息,表示不太重要的日志,感情色彩不浓。
W:Warning------警告。虽然不会造成程序躺平和数据丢失。但不能疏忽,存在风险。比如,SSL证书过期了。
E:Error------明确应用程序在某处的确发生错误了,应用程序躺平了,不能再运行了。
C:Critica------意为关键错误,表示很严重的错误。但有时候它也表示一些很敏感的操作,比如管理员帐号被人 Kill 了。
LogLevel 枚举还有一个最大值叫 None,这个值比较特殊,它表示忽略日志(不记录),所以它要单独放出来说。
基于日志级别的过滤
以日志级别为条件的过滤比较简单粗暴,指定一个级别,大于 / 等于这个级别的日志均会记录。这就是上文在介绍日志级别时,老周强调是从小到大排序的原因。不废话,咱们看个示例就明白。
ILoggerFactory fac = LoggerFactory.Create(lb =>
{
lb.AddConsole();
// 第一个参数:日志类别
// 第二个参数:日志级别
lb.AddFilter("*", LogLevel.Warning);
});
ILogger logger = fac.CreateLogger("Demo Log");
logger.LogTrace("Trace 级别");
logger.LogDebug("Debug 级别");
logger.LogInformation("Information 级别");
logger.LogWarning("Warning 级别");
logger.LogError("Error 级别");
logger.LogCritical("Critical 级别");
这个示例运行之后,只有 Warning、Error、Critical 级别的日志会打印。
咱们按照枚举值的排序,只要看哪些值 >= Warning 就知道了。
public enum LogLevel
{
Trace = 0,
Debug = 1,
Information = 2,
Warning = 3,
Error = 4,
Critical = 5,
// 这个不算
None = 6,
}
前面调用的 AddFilter 方法就是用来给日志加过滤用的,咱们用到的是以下重载:
AddFilter(
this ILoggingBuilder builder,
string? category,
LogLevel level);
从定义上看它是扩展方法,category 参数表示日志类别,用于告诉人们这个日志的来源。任意字符串,也可以是某个类名。比如叫 HTTP,当你看到这个类别的日志,你就知道它来自与 HTTP 通信有关的代码。这样如果出现错误,查找范围就缩小了。
上面例子中,老周用了通配符"*"(不是什么神秘符号,就是星号),它表示任何类别的日志都要应用这条过滤规则。当然,通配符也可以这样用:
my.app.data.query.*:匹配 query 后的任何内容,如 my.app.data.query.top、my.app.data.query.baby 等。
my.app.data.*:匹配以 my.app.data 开头的内容,如 my.app.data.query、my.app.data.delete 等。
my.app.*:匹配以 my.app 开头的任何内容,如 my.app.nobody、my.app.what_the_fk、my.app.lucky 等。
*:匹配任何内容,如 abc、abc.def 等。
匹配原理和文件名通配符差不多。
level 参数当然就是指定日志级别了,指定后,只要 >= 这个级别的日志都会输出。
[my:logging:LogLevel]
Default=Information
Database=Error
在配置文件中过滤
上面的过滤是写死在代码中的,对于不用改来改去的应用是没问题的;对于经常变动的应用,放到配置文件中较好,要改的时候就改配置文件就行了,不用重新编译。
接下来,老周以 ini 文件的配置为例(JSON 文件见多了,ini文件是不是很少见?)演示一下。
在项目中添加一个名为 config.ini 的文本文件。然后输入以下内容:
[my:logging:LogLevel]
Default=Information
Database=Error
有大伙伴可能会疑惑,ini 文件只有"节"和 name=value 组成,如何配置多层次的节点?可以在节标题上使用冒号分隔。即只有最内层的配置使用 name=value 的方式。保存文件后,将文件设置为复制到输出目录,至于是总是复制还有最新版本再复制,自己看着办。
上述配置文件中,指定 Database 类别的日志只输出错误以上的消息,即 Error 和 Critical。Default 相当于"*",即未指定类别的,默认输出 Information 以上的级别,即 Info、Warning、Err、Critical。
相当于 JSON 文件:
{
"my": {
"logging": {
"LogLevel": {
"Default": "Information",
"Database": "Error"
}
}
}
}
使用 ini 配置文件,需要引用 Microsoft.Extensions.Configuration.Ini 包,操作方法已超纲,本文不予介绍。
咱们配置 Logging,并加载配置文件的内容。
// 准备配置内容
IConfigurationBuilder cfgBuilder = new ConfigurationBuilder();
cfgBuilder.AddIniFile("config.ini");
IConfigurationRoot config = cfgBuilder.Build();
ILoggerFactory fac = LoggerFactory.Create(lb =>
{
lb.AddConsole();
// 从配置文件中加载过滤,注意设置正确的节点
lb.AddConfiguration(config.GetSection("my:logging"));
});
这里要注意一点:在指定让 Logging 加载的配置节点时,不能包含 LogLevel 节点,只指定 my:logging 即可。LogLevel 节点是由 Logging 内部处理的固定命名。源代码在 LoggerFilterConfigureOptions 类,它实现了配置接口 IConfigureOptions<T>,其中,T 是 LoggerFilterOptions 选项类。
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)*;
}
......
}
注意到没有?有两个特别的常量,LogLevelKey 的值是 "LogLevel",DefaultCategory 的值是 "Default"。所以前面老周说,在添加日志的配置文件节点时不要包含 LogLevel 节点,因为这个类会自己找 LogLevel 节点。很明显,该类的核心实现就在 LoadDefaultConfigValues 方法中了,咱们看看源码。
private void LoadDefaultConfigValues(LoggerFilterOptions options)
{
if (_configuration == null)
{
return;
}
options.CaptureScopes = _configuration.GetValue(nameof(options.CaptureScopes), options.CaptureScopes);
// 枚举当前节点的子级
foreach (IConfigurationSection configurationSection in _configuration.GetChildren())
{
// 如果子节点就是 LogLevel
if (configurationSection.Key.Equals(LogLevelKey, StringComparison.OrdinalIgnoreCase))
{
// 直接加载
// Load global category defaults
LoadRules(options, configurationSection, null);
}
else
{
// 如果这个子节点不是 LogLevel
// 那么它的下一级就必须是 LogLevel 节点
IConfigurationSection logLevelSection = configurationSection.GetSection(LogLevelKey);
if (logLevelSection != null)
{
// 那么当前节点必须是 Logging Provider 的名字
// Load logger specific rules
string logger = configurationSection.Key;
LoadRules(options, logLevelSection, logger);
}
}
}
}
private static void LoadRules(LoggerFilterOptions options, IConfigurationSection configurationSection, string? logger)
{
foreach (System.Collections.Generic.KeyValuePair<string, string?> section in configurationSection.AsEnumerable(true))
{
// key 就是 log category,value 就是 log level
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);
}
}
}
有大伙伴可能看不懂,老周举个例子就你明白了。
回到咱们的示例,前文已设置了日志配置使用 my:logging 节点,即,当前节点就是 my:logging。请记住它。
现在,它要找 my:logging 的下一级节点,于是找到了 LogLevel。这个节点是符合要求的,它的子级中就是一系列的 name: value 了,其中,name 是日志类别,value 是级别,所以咱们这个例子就是:
类别:*,级别:Information
类别:Database,级别:Error
如果当前节点不是 LogLevel 呢。那么就要求这个节点的名称必须是 Logging 的 Provider 名称。比如,咱们使用了控制台日志,那么,这个节点就是 Console。即配置文件会变成:
[my:logging:Console:LogLevel]
Default=Information
Database=Error
相当于 JSON 文件:
{
"my": {
"logging": {
"Console": {
"LogLevel": {
"Default": "Information",
"Database": "Error"
}
}
}
}
}
这样配置后,选项只对控制台日志起作用,不影响其他日志 Provider。假设你同时用了 Debug,EventLogs 等。
可以自己安排配置节点吗
由于项目可能会有各种神奇需求,日志的过滤配置可能与内置的规则不同。那能按我们自己定义的来吗? 那肯定可以的,不管官方内置的配置如何搞怪,其本质就是从配置文件加载内容,对应地产生 IConfigurationRoot 根节点,咱们只要按照自定义的逻辑找出需要的节点。
然后,过滤配置有个选项类,叫 LoggerFilterOptions。但不能用配置节点直接 Bind 到这个选项类上,因为这个选项类不是只包含属性的简单类。它有一个集合类型的属性叫 Rules,过滤规则实际上是一个 LoggerFilterRule 类。但,Logging 库为 LoggerFilterOptions 类提供了一个名为 AddFilter 的扩展方法,使得其操作与调用 ILoggerBuilder.AddFilter 扩展方法一样。
所以,咱们只要多写几行代码就能达成愿望。先解析出配置节点,再调用 AddFilter 扩展方法进行配置。
理论过于抽象,咱们干点实的。假设在项目中添加了 JSON 配置文件,内容如下:
{
"mylogs": {
"filters": [
{
"category": "abc",
"level": "Warning"
},
{
"category": "xyz",
"level": "Debug"
}
]
}
}
咱们看到,日志过滤规则是以 JSON 数组方式配置的,数据组中每个项都有 category 和 level 字段(日志类别和级别)。咱们在代码中实际真正要用到的是数组内的 JSON 对象,即 { "category": ..., "level": ... } 列表。
下面开始处理。先把JSON文件的配置树加载。
IConfigurationRoot config = new ConfigurationBuilder()
.AddJsonFile("config.json")
.Build();
现在,变量 config 就包含整棵配置树了。随后,咱们找节点。
ILoggerFactory loggerfac = LoggerFactory.Create(lb =>
{
lb.AddConsole(); // 添加控制台日志
// 加载配置
IConfigurationRoot config = new ConfigurationBuilder()
.AddJsonFile("config.json")
.Build();
// 下面正式处理配置
lb.Services.AddOptions<LoggerFilterOptions>().Configure(ofo =>
{
// 列出JSON配置中的数组
var filterlist = config.GetSection("mylogs:filters").GetChildren().AsEnumerable().ToArray();
......
});
});
关键点是获取 mylogs:filters 节点下面数组内部的对象列表。GetSection("mylogs:filters") 已经到达了 JSON 数组处,然后咱们只要往后再获取一层,就得到数组中的对象列表了,所以, 要跟着调用 GetChildren 方法。AsEnumerable().ToArray() 是转换为 .NET 中的数组------ IConfigurationSection 数组。
接着,来个 foreach 循环就可以读出日志类别和日志级别了。
foreach(var f in filterlist)
{
string? cate = f.GetSection("category").Value;
string? lev = f.GetSection("level").Value;
if(!string.IsNullOrEmpty(cate) && Enum.TryParse<LogLevel>(lev, out LogLevel loglev))
{
ofo.AddFilter(cate, loglev);
}
}
有没有效呢,试试看。
var logger = loggerfac.CreateLogger("abc");
logger.LogDebug("Dbg");
logger.LogInformation("Info");
logger.LogWarning("Warn");
logger.LogError("Err");
logger = loggerfac.CreateLogger("xyz");
logger.LogDebug("Dbg");
logger.LogInformation("Info");
logger.LogWarning("Warn");
logger.LogError("Err");
输出结果如下图所示:

类别 abc 只输出警告和错误;而 xyz 类别则输出了四种日志。这表明:有效的。
你要是嫌不够简洁,还可以把配置项封装一下的。
public class FilterConfigEntry
{
public string? Category { get; set; }
public LogLevel Level { get; set; } = LogLevel.None;
}
这样一来,直接从 mylogs:filters 节点提取 FilterConfigEntry 数组就完事了。
lb.Services.AddOptions<LoggerFilterOptions>().Configure(ofo =>
{
FilterConfigEntry[]? items = config.GetSection("mylogs:filters").Get<FilterConfigEntry[]?>();
if(items is not (null or { Length: 0 }))
{
foreach(var c initems)
{
ofo.AddFilter(c.Category, c.Level);
}
}
});
这么一搞,只需一行代码就能把 filters 节点下的数组提取出来了。
如果你的项目中,配置文件的结构设计好了,将来不会变来变去的话,你甚至可以学官方的做法,直接注册一个 IConfigureOptions 服务。
public class MyLogFilterOptionsConfig : IConfigureOptions<LoggerFilterOptions>
{
private IConfiguration _m_Config;
public MyLogFilterOptionsConfig(IConfiguration config)
{
// 配置树通过构造函数传进来
_m_Config = config;
}
public void Configure(LoggerFilterOptions options)
{
// 把刚才的配置代码搬到这里
FilterConfigEntry[]? items = _m_Config.GetSection("mylogs:filters").Get<FilterConfigEntry[]?>();
if (items is not (null or { Length: 0 }))
{
foreach (var c in items)
{
options.AddFilter(c.Category, c.Level);
}
}
}
}
然后,把这货注册为单例服务就好了。要单实例,毕竟这东西不需要经常实例化,仅在应用程序启动时初始化就好了。
ILoggerFactory loggerfac = LoggerFactory.Create(lb =>
{
lb.AddConsole(); // 添加控制台日志
// 加载配置
IConfigurationRoot config = new ConfigurationBuilder()
.AddJsonFile("config.json")
.Build();
lb.Services.AddSingleton<IConfigureOptions<LoggerFilterOptions>>(new MyLogFilterOptionsConfig(config));
});
你瞧,多简洁。
好了,今天就水到这里了,各位,See you La La。