【.NET】Logging 库探索(二):日志过滤

人天生有一种能力------信息过滤。只要不是废物都具备这种能力,不需要算法和数据。生物许多行为是本能反应,深深刻进潜意识里。这就是人与机器的本质区别。机器在某些方面一旦没有相关的程序和算法支持,它就是废铁。

日志消息是给人看的,所以,过滤功能必不可少。即应用程序可以根据配置记录或不记录某些日志。

日志级别

日志级别由 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。