.NET Core下的日志(2):日志模型详解
NET Core的日志模型主要由三个核心对象构成,它们分别是Logger、LoggerProvider和LoggerFactory。总的来说,LoggerProvider提供一个具体的Logger对象将格式化的日志消息写入相应的目的地,但是我们在编程过程中使用的Logger对象则由LoggerFactory创建,这个Logger利用注册到LoggerFactory的LoggerProvider来提供真正具有日志写入功能的Logger,并委托后者来记录日志。
目录
一、Logger
扩展方法LogXxx与BeginScope
Logger<TCategoryName>
二、LoggerProvider
三、LoggerFactory
Logger提供的同一性
Logger类型
LoggerFactory类型
依赖注入
一、Logger
日志模型的Logger泛指所有实现了ILogger接口的所有类型以及对应对象,该接口定义在NuGet包“Microsoft.Extensions.Logging.Abstractions”中,这个NuGet包同时定义了分别代表LoggerProvider和LoggerFactory的接口ILoggerProvider和ILoggerFactory。ILogger接口中定义了如下三个方法Log、IsEnabled和BeginScope。
1: public interface ILogger
2: {
3: void Log(LogLevel logLevel, EventId eventId, object state, Exception exception, Func<object, Exception, string> formatter);
4: bool IsEnabled(LogLevel logLevel);
5: IDisposable BeginScope<TState>(TState state);
6: }
Logger对日志消息的写入实现在Log方法中。Log方法的logLevel代表写入日志消息的等级,而日志消息的原始内容通过参数state和exception这两个参数来承载承载,前者代表一个原始的日志条目(Log Entry),后者代表与之关联的异常。日志在被写入之前必须格式成一个字符串,由于日志原始信息分别由一个Object和Exception对象对象来表示,所以日志的“格式化器”自然体现为一个Func<object, Exception, string>类型的委托对象。
一条写入的日志消息会关联着一个日志记录事件,后者则通过一个EventId对象来标识,Log方法的eventId参数类型就是EventId。如下面的代码片段所示,EventId被定义成一个结构,它具有两个基本的属性Id和Name,前者代表必需的唯一标识,后者则是一个可选的名称。除此之外,整形到EventId类型之间还存在一个隐式类型转换,所以在需要使用EventId对象的地方,我们可以使用一个整数来代替。
1: public struct EventId
2: {
3: public int Id { get; }
4: public string Name{ get; }
5: public EventId(int id, string name = null);
6:
7: public static implicit operator EventId(int i);
8: }
对于任意一次日志消息写入请求,Logger并不会直接调用Log方法将日志消息写入对应的目的地,它会根据提供日志消息的等级判断是否应该执行写入操作,判断的逻辑实现在IsEnabled方法中,只有当这个方法返回True的时候它的Log方法才会被执行。
在默认的情况下,每次调用Logger的Log方法所进行的日志记录操作都是相互独立的,但是有时候我们需要将相关的多次日志记录做一个逻辑关联,或者说我们需要为多次日志记录操作创建一个共同的上下文范围。这样一个关联上下文范围可以通过BeginScope<TState>方法来创建,该方法将该上下文范围与参数state表示的对象进行关联。被创建的这个关联上下文体现为一个IDisposable对象,我们需要调用其Dispose方法将其释放回收,也就是说被创建的关联上下文的生命周期终止于Dispose方法的调用。
扩展方法LogXxx与BeginScope
当我们调用Logger的Log方法记录日志时必须指定日志消息采用的等级,出于调用便利性考虑,日志模型还为ILogger接口定义了一系列针对不同日志等级的扩展方法,比如LogDebug、LogTrace、LogInformation、LogWarning、LogError和LogCritical等。下面的代码片段列出了整个日志等级Debug三个LogDebug方法重载的定义,针对其他日志等级的扩展方法的定义与之类似。对于这些扩展方法来说,如果它们没有定义表示日志事件ID的参数eventId,默认使用的事件ID为0。
1: public static class LoggerExtensions
2: {
3: public static void LogDebug(this ILogger logger, EventId eventId, Exception exception, string message, params object[] args);
4: public static void LogDebug(this ILogger logger, EventId eventId, string message, params object[] args);
5: public static void LogDebug(this ILogger logger, string message, params object[] args);
6: }
对于定义在ILogger接口中的Log方法来说,原始日志消息的内容通过Object类型的参数state和Exception类型的参数exception来承载,并通过一个Func<object, Exception, string>类型的委托对象来将它们格式化成可以写入的字符串。上述这些扩展方法对此作了简化,它利用一个包含占位符的字符串模板(对应参数message)和用于替换占位符的参数列表(对应参数args)来承载原始的日志消息,日志消息的格式化体现在如何使用提供的参数替换模板中相应的占位符进而生成一个完整的消息。值得一提的是,定义在模板中的占位符通过花括号括起来,可以使用零基连续整数(比如“{0}”、“{1}”和“{2}”等),也可以使用任意字符串(比如“{Minimum}”和“Maximum”等)。
定义在ILogger接口的泛型方法BeginScope<TState>为多次相关的日志记录操作创建一个相同的执行上下文范围,并将其上下文范围与一个TState对象进行关联。ILogger接口还具有如下一个同名的扩展方法,它采用与上面类似的方式将创建的上下文范围与一个字符串进行关联,该字符串是指定的模板与参数列表格式化后的结果。
1: public static class LoggerExtensions
2: {
3: public static IDisposable BeginScope(this ILogger logger, string messageFormat, params object[] args);
4: }
Logger<TCategoryName>
每条日志消息都关联着一个具体的类型(Category),这个类型实际上创建这条日志消息的“源”,我们一般将日志记录所在的应用或者组件名称作为类型。除了ILogger这个基本的接口,日志模型中还定义了如下一个泛型的ILogger <TCategoryName>接口,它派生与ILogger接口并将泛型参数的类型名称作为由它写入的日志消息的类型。
1: public interface ILogger<out TCategoryName> : ILogger
2: {}
Logger<TCategoryName>实现了ILogger <TCategoryName>接口。一个Logger<TCategoryName>对象可以视为是对另一个Logger对象的封装,它使用泛型参数类型来确定写入日志的类型,而采用这个内部封装的Logger对象完成具体的日志写入操作。如下面的代码片段所示,Logger<TCategoryName>的构造函数接受一个LoggerFactory作为输入参数,上述的这个内部封装的Logger对象就是由它创建的。
1: public class Logger<TCategoryName> : ILogger<TCategoryName>
2: {
3: public Logger(ILoggerFactory factory) ;
4:
5: IDisposable ILogger.BeginScope<TState>(TState state;
6: void ILogger.Log<TState>(LogLevel logLevel, EventId eventId, TState state,
7: Exception exception, Func<TState, Exception, string> formatter) ;
8: }
在利用指定的LoggerFactory创建Logger对象时,泛型参数TCategoryName的类型被用来计算日志类型。对于具有简写形式的基元类型(比如Int32、Boolean和Decimal等)来说,类型的简写形式(比如int、bool和decimal等)直接作为日志类型名称。对于一般的类型来说,日志类型名称就是该类型的全名(命名空间+类型名)。如果该类型内嵌于另一个类型之中(比如“Foo.Bar+Baz”),表示内嵌的“+”需要替换成“.”(比如“Foo.Bar.Baz”)。如果该类型是一个泛型类型(比如Foobar<T1,T2>),泛型参数部分将不包含在日志类型名称中(日志类型为“Foobar”)。
除了调用构造函数创建一个Logger<TCategoryName>对象之外,我们还可以调用针对ILoggerFactory接口的扩展方法CreateLogger<T>来创建它。如下面的代码片段所示,除了这个CreateLogger<T>方法之外,另一个CreateLogger方法直接指定一个Type类型的参数,虽然返回类型不同,但是由此两个方法创建的Logger在日志记录行为上是等效的。
1: public static class LoggerFactoryExtensions
2: {
3: public static ILogger<T> CreateLogger<T>(this ILoggerFactory factory)
4: public static ILogger CreateLogger(this ILoggerFactory factory, Type type);
5: }
二、LoggerProvider
日志模型的LoggerProvider泛指所有实现了接口ILoggerProvider的类型和对应的对象,从其命名我们不难看出LoggerProvider的目的在于“提供”真正具有日志写入功能的Logger。如下面的代码片段所示,ILoggerProvider继承了IDisposable,如果某个具体的LoggerProvider需要释放某种资源,可以将相关的操作实现在Dispose方法中。
1: public interface ILoggerProvider : IDisposable
2: {
3: ILogger CreateLogger(string categoryName);
4: }
LoggerProvider针对Logger的提供实现在唯一的方法CreateLogger中,该方法的参数categoryName自然代表上面我们所说的日志消息的类型。这个CreateLogger方法返回类型为ILogger,代表根据指定日志类型创建的Logger对象。
三、LoggerFactory
从命名的角度来讲,LoggerProvider和LoggerFactory最终都是为了提供一个Logger对象,但是两者提供的Logger对象在本质上是不同的。一个LoggerProvider一般针对某种具体的日志目的地类型(比如控制台、文件或者Event Log等)提供对应的Logger,而LoggerFactory仅仅为我们创建日志编程所用的那个Logger对象。
日志模型中的LoggerFactory泛指所有实现了ILoggerFactory接口的所有类型及其对应的对象。如下面的代码片段所示,ILoggerFactory具有两个简单的方法,针对Logger的创建实现在CreateLogger方法中。我们通过调用AddProvider方法将某个LoggerProvider对象注册到LoggerFactory之上,CreateLogger方法创建的Logger需要利用这些注册的LoggerProvider来提供真正具有日志写入功能的Logger对象,并借助后者来完成对日志的写入操作。
1: public interface ILoggerFactory : IDisposable
2: {
3: ILogger CreateLogger(string categoryName);
4: void AddProvider(ILoggerProvider provider);
5: }
日志模型中定义了一个实现了ILoggerFactory接口的类型,这就是我们在上面演示实例中使用的LoggerFactory类,由它创建的是一个类型为Logger的对象,这两个类型均定义在NuGet包“Microsoft.Extensions.Logging”之中。到目前为止,我们认识了日志模型中的三个接口(ILogger、ILoggerProvider和ILoggerFactory)和其中两个的实现者(Logger和LoggerFactory),右图所示的UML体现了它们之间的关系。
Logger提供的同一性
上图所示的UML基本上体现了Logger和LoggerFactory这两个类型的实现逻辑,这个逻辑我们在上面已经提到过多次,现在我们通过代码实现的方式来对它做进一步地说明。在这之前,我们有必要了解LoggerFactory类型创建Logger过程中所体现出的一个重要特性,即对于CreateLogger方法的多次调用,如果我们指定的日志类型(categoryName参数)相同(不区分大小写),该方法返回的实际是同一个对象。
1: LoggerFactory loggerFactory = new LoggerFactory();
2: ILogger logger1 = loggerFactory.CreateLogger("App");
3:
4: loggerFactory.AddConsole();
5: ILogger logger2 = loggerFactory.CreateLogger("App");
6:
7: loggerFactory.AddDebug();
8: ILogger logger3 = loggerFactory.CreateLogger("App");
9:
10: Debug.Assert(ReferenceEquals(logger1, logger2) && ReferenceEquals(logger2, logger3));
如上面的代码片段所示,我们利用同一个LoggerFactory对象针对相同的日志类型(“App”)先后得到三个Logger对象,虽然这三个Logger被创建的时候LoggerFactory具有不同的状态(注册到它上面的LoggerProvider逐次增多),但是它们其实是同一个对象。换句话说,LoggerFactory和由它创建的Logger对象并不是两个孤立的对象,它们之间存在着一种动态的关联,当LoggerFactory自身的状态发生改变时(注册新的LoggerProvider),它会主动改变Logger的状态使之与自身同步。
Logger类型
我们定义了一个精简版本的同名类型来模拟真实Logger类的实现逻辑。如下面的代码片段所示,我们创建一个Logger对象的时候需要指定创建它的LoggerFactory对象和日志类型。它的字段loggers代表由它封装的一组具有真正日志写入功能的Logger对象,它们由注册到LoggerFactory的LoggerProvider(体现为LoggerFactory的LoggerProviders属性)来提供。
1: public class Logger : ILogger
2: {
3: private LoggerFactory loggerFactory;
4: private IList<ILogger> loggers;
5: private string categoryName;
6:
7: public Logger(LoggerFactory loggerFactory, string categoryName)
8: {
9: this.loggerFactory = loggerFactory;
10: this.categoryName = categoryName;
11: loggers = loggerFactory.LoggerProviders.Select(provider => provider.CreateLogger(categoryName)).ToList();
12: }
13:
14: public bool IsEnabled(LogLevel logLevel) => loggers.Any(logger => logger.IsEnabled(logLevel));
15:
16: public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter)
17: => loggers.ForEach(logger => logger.Log(logLevel, eventId, state, exception, formatter));
18:
19: internal void AddProvider(ILoggerProvider provider) => loggers.Add(provider.CreateLogger(categoryName));
20: //其他成员
21: }
IsEnabled方法实现了针对等级的日志过滤,如果指定的日志等级能够通过任一Logger的过滤条件,该方法就返回True。至于真正用于实现日志消息记录的Log方法,它只需要调用每个Logger对象的同名方法即可。除此之外,Logger类还定义了一个AddProvider方法,它利用指定的LoggerProvider来创建对应的Logger,并将后者添加到封装的Logger列表中。一旦新的LoggerProvider注册到LoggerFactory之上,LoggerFactory正是调用这个方法将新注册的LoggerProvider应用到由它创建的Logger对象之上。
一个Logger对象是对一组具有真正日志写入功能的Logger对象的封装,由它的BeginScope方法创建的日志上下文范围则是对这组Logger创建的上下文范围的封装。当这个日志上下文范围因调用Dispose方法被释放的时候,这些内部封装的上下文范围同时被释放。如下所示的代码基本体现了定义在BeginScope方法中创建日志上下文范围的逻辑。
1: public class ConsoleLogger : ILogger
2: {
3: private IList<ILogger> loggers;
4: public IDisposable BeginScope<TState>(TState state)
5: {
6: return new Scope(loggers.Select(logger => logger.BeginScope(state)));
7: }
8:
9: private class Scope : IDisposable
10: {
11: private readonly IDisposable[] scopes;
12: public Scope(IEnumerable<IDisposable> scopes)
13: {
14: this.scopes = scopes.ToArray();
15: }
16: public void Dispose() => scopes.ForEach(scope => scope.Dispose());
17: }
18: }
LoggerFactory类型
我们同样采用最精简的代码来模拟实现在LoggerFactory类型中的Logger创建逻辑。如下面的代码片段所示,处于线程安全方面的考虑,我们定义了一个ConcurrentBag<ILoggerProvider>类型的属性LoggerProviders来保存注册到LogggerFactory上的LoggerProvider。另一个ConcurrentDictionary<string, Logger>类型的字段loggers则用来保存自身创建的Logger对象,该对象的Key表示日志消息类型。
1: public class LoggerFactory : ILoggerFactory
2: {
3: internal ConcurrentBag<ILoggerProvider> LoggerProviders { get; private set; }
4: private readonly ConcurrentDictionary<string, Logger> loggers = new ConcurrentDictionary<string, Logger>(StringComparer.OrdinalIgnoreCase);
5:
6: public void AddProvider(ILoggerProvider provider)
7: {
8: this.LoggerProviders = new ConcurrentBag<ILoggerProvider>();
9: this.LoggerProviders.Add(provider);
10: loggers.ForEach(it => it.Value.AddProvider(provider));
11: }
12:
13: public ILogger CreateLogger(string categoryName)
14: {
15: Logger logger;
16: return loggers.TryGetValue(categoryName, out logger)
17: ? logger
18: : loggers[categoryName] = new Logger(this, categoryName);
19: }
20:
21: public void Dispose() => LoggerProviders.ForEach(provider => provider.Dispose());
22: }
当LoggerFactory的CreateLogger方法的时候,如果根据指定的日志类型能够在loggers字段表示的字典中找到一个Logger对象,则直接将它作为返回值。只有在根据指定的日志类型找不到 对应的Logger的情况下,LoggerFactory才会真正去创建一个新的Logger对象,并在返回之前将它添加到该字典之中。针对相同的日志类型,LoggerFactory之所以总是返回同一个Logger,根源就在于此。
对于用于注册LoggerProvider的AddProvider方法来说,LoggerFactory除了将指定的LoggerProvider添加到LoggerProviders属性表示的列表之中,它还会调用每个已经创建的Logger对象的AddProvider方法。正是源于对这个方法的调用,我们新注册到LoggerFactory上的LoggerProvider才会自动应用到所有已经创建的Logger对象中。
LoggerProvider类型都实现了IDisposable接口,针对它们的Dispose方法的调用被放在LoggerFactory的同名方法中。换句话说,当LoggerFactory被释放的时候,注册到它之上的所有LoggerProvider会自动被释放。
依赖注入
在一个真正的.NET Core应用中,框架内部会借助ServiceProvider以依赖注入的形式向我们提供用于创建Logger对象的LoggerFactory。这样一个ServiceProvider在根据一个ServiceCollection对象构建之前,我们必然需要在后者之上实施针对LoggerFactory的服务注册,这样的服务注册可以通过针对接口IServiceCollection的扩展方法AddLogging来完成。
1: public static class LoggingServiceCollectionExtensions
2: {
3: public static IServiceCollection AddLogging(this IServiceCollection services)
4: {
5: services.TryAdd(ServiceDescriptor.Singleton<ILoggerFactory, LoggerFactory>());
6: services.TryAdd(ServiceDescriptor.Singleton(typeof(ILogger<>), typeof(Logger<>)));
7: return services;
8: }
9: }
如上面的代码片段所示,扩展方法AddLogging除了以Singleton模式注册了ILoggerFactory接口与实现它的LoggerFactory类型之间的映射之外,还以同样的模式注册了ILogger<>接口和Logger<>类型的映射。如果创建ServiceProvider的ServiceCollection具有这两个服务注册,我们可以利用ServiceProvider直接提供一个Logger<T>,而不需要间接地利用ServiceProvider提供的LoggerFactory来创建它。下面的代码片段展示了Logger<T>的这两种创建方式。
1: IServiceProvider serviceProvider = new ServiceCollection()
2: .AddLogging()
3: .BuildServiceProvider();
4:
5: ILogger<Foobar> logger1 = serviceProvider.GetService<ILoggerFactory>().CreateLogger<Foobar>();
6: ILogger<Foobar> logger2 = serviceProvider.GetService<ILogger<Foobar>>();