在上一篇中,我们简单地查看了 Serilog 的整体需求和大体结构。从这一篇开始,本文开始涉及 Serilog 内的相关实现,着重解决第一个问题,即 Serilog 向哪里写入日志数据的。(系列目录)
基础功能
在开始看 Serilog 怎么将日志记录到 Sinks 之前,先看下整体框架。首先,我们需要了解 Serilog 中最常用的一个接口ILogger
,它提供了对外记录日志的所有功能 API 方法。
ILogger
(核心接口)
在 Serilog 根目录下,保存有 4 个代码文件。类似于 LogDemo,ILogger
内包含各种功能API方法,LogConfiguration
用于构建对应的ILogger
对象。另外,LogExtensions
是向ILogger
中添加新方法,不是LogConfiguration
。
。
为了方便,我们首先看如何使用,在理解完使用方法,再回过头来看怎么创建。首先是ILogger
, 它提供了大量的使用方法,按照功能主要分成以下三类。
方法名 | 说明 |
---|---|
ForContext系列 | 构造子日志记录对象,并添加额外数据 |
Write系列,XXX(日志等级名)系列 | 日志记录功能 |
BindXXX 系列 | 输出模板、属性绑定相关 |
这里面的方法,对我们而言,第二类方法是用的最多地,我们就先看 Serilog 是如何记录日志的吧。
Log
(静态方法类)
这是一个静态类,可以看到内部本质上是对ILogger
的进一步包装,并将所有API方法暴露出来,如下。
public static class Log
{
static ILogger _logger = SilentLogger.Instance;
public static Logger
{
get => _logger;
set => _logger = value ?? throw ...
}
public static void Write(LogEventLevel level, string messageTemplate)
...
}
顺带提一句,类库中的SilentLogger
类是对ILogger
的一个空实现,它可以看成是一个具有调用功能的空类。
在了解到了最为核心的ILogger
接口后,接下来需要了解的是描述日志事件的LogEvent
类,该类在 Events 文件夹下,其作为Write
的输入参数,可以将其想象成LogDemo中的LogData
类,只不过它包含了更多的数据信息。另外,LogEventLevel
是一个枚举,同样位于 Events 文件夹下,该类的内容和 LogDemo 中的LogLevel
完全一致。
LogEvent
(日志事件类)
在 Serilog 中,每当我们发生一次日志记录的行为时,Serilog 都将其封装到一个类中方便使用,即LogEvent
类。和 LogDemo 中的LogData
一样,LogEvent
类包含一些描述日志事件的数据。
public class LogEvent
{
public DateTimeOffset Timestamp { get; }
public LogEventLevel Level { get; }
public Exception Exception { get; }
public MessageTemplate MessageTemplate { get; }
private readonly Dictionary<string, LogEventPropertyValue> _properties;
internal LogEvent Copy()
{
...
}
}
可以看到,在LogEvent中,有若干字段和属性描述一个日志事件。Timestamp
属性描述日志记录的时间,采用DateTimeOffset
这一类型可以统一不同时区下的服务器时间点,确保时间上的统一。Level
就不用多说,描述日志的等级。Exception
属性可以保存任意异常类数据,该属性常用在 Error 和 Fatal 等级中,需要保存异常信息时使用。至于后续的MessageTemplate
和LogEventPropertyValue
,从字面意义上看,属于字符串消息模板和记录数据时所用到,目前我们主力研究记录到 Sink 的处理逻辑,故这两块暂时不关心。
此外,在LogEvent
类中,有一个很特别的函数,名为Copy
函数,这个函数是根据当前LogEvent
对象复制出了一个相同的LogEvent
对象。这个方法可以看成是设计模式中原型模式的一种实现,只不过这个类没有利用IClonable
接口来实现。
Core 目录下的功能类
ILogEventSink
接口
在 LogDemo 中,我们通过ILogTarget
接口定义不同的日志记录目的地。类似地,在 Serilog 中,所有的 Sink 通过ILogEventSink
定义统一的日志记录接口。该接口如下所示。
public interface ILogEventSink
{
void Emit(LogEvent logEvent);
}
该接口形式简单,只有一个函数,输入参数为LogEvent
对象,无返回值,这一点和 LogDemo 中的ILogTarget
接口很像。如果想实现一个 ConsoleSink,只需要将继承该接口并将LogEvent
对象字符串数据写入到Console
即可。实际上,在 Serilog.Sinks.Console 中其核心功能就是这么实现的。
Logger
类
Logger
类是对ILogger
接口的默认实现。类似于 LogDemo 中的Logger
,该类给所有日志记录的使用提供了 API 方法。考虑到本篇只关心日志向哪里写入的。因此,我们只关心其内部的部分字段属性和方法。
public sealed class Logger : ILogger, ILogEventSink, IDisposable
{
readonly ILogEventSink _sink;
readonly Action _dispose;
readonly LogEventLevel _minimumLevel;
// 361行到375行
public void Write(LogEventLevel level, Exception exception, string messageTemplate, params object[] propertyValues)
{
if (!IsEnabled(level)) return;
if (messageTemplate == null) return;
if (propertyValues != null && propertyValues.GetType() != typeof(object[]))
propertyValues = new object[] {propertyValues};
// 解析日志模板
_messageTemplateProcessor.Process(messageTemplate, propertyValues, out var parsedTemplate, out var boundProperties);
// 构造日志事件对象
var logEvent = new LogEvent(DateTimeOffset.Now, level, exception, parsedTemplate, boundProperties);
// 将日志事件分发出去
Dispatch(logEvent);
}
public void Dispatch(LogEvent logEvent)
{
...
// 将日志事件交给Sink进行记录
_sink.Emit(logEvent);
}
}
考虑到篇幅,这里我去掉了部分和当前功能无关的代码,只保留最为核心的代码。
-
首先,我们看下继承关系,
Logger
类除继承ILogger
之外,还继承ILogEventSink
接口,这个继承关系看起来很奇怪,但细想也觉得正常,一个日志记录器不光可以当日志事件的发生器,也可以当其接收器。换而言之,可以将一条日志事件写到另一个日志记录器中,由另一个日志记录器记录到其他 Sinks 中。此外,该类还继承了IDisposable
接口,按照逻辑需求来讲,Logger
是没有东西需要释放的,其需要释放的通常是内部包含的一些对象,比如说 FileSink 如果长时间维持一个文件句柄的话,则需要在Logger
回收后被动释放,因此,这导致了Logger
需要维护一组待释放的对象进行释放。在Logger
内部中,通过添加Action
函数钩子的方式进行释放。 -
之后,我们会发现所有的写入日志方法直接或间接地调用上面给出的Write方法。在该方法的逻辑中,第一行用来判断日志的等级是否满足条件,也就是一类全局的过滤条件,第二行则是判断是否给出日志的输出模板。随后
_messageTemplateProcessor
看这个意思是解析模板和数据(暂且不明,不过多关注)。再往下,则是构造对应的LogEvent
对象。最后通过Dispatch
方法将日志分发到ILogEventSink
。在Dispatch
中,前半部分逻辑和本篇关系不大,最后通过ILogEventSink
将日志消息发送出去。
看到这里,可能会有人好奇一点,Logger
应该拥有一组ILogEventSink
对象才对,这样才能够实现一次向多个 Sink 中写入日志信息,但Logger
只维护一个ILogEventSink
对象,它是怎么做到一次向多个 Sink 中写入日志的呢?我们接着往下看。
功能性 Sink
在 Serilog 的 ./Core/Sinks 文件夹中可以发现,这里面有非常多的ILogEventSink
的实现类。这些实现类都不是向具体的媒介(控制台、文件等)写入日志,反而,他们都是给其他的Sink扩展新功能,典型装饰模式的一种实现。在这个文件夹下,我把部分核心功能摘录出来,如下。(v2.10.0又添加了一些其他的装饰类,这里就不过多说明了)。
class ConditionalSink : ILogEventSink
{
readonly ILogEventSink _warpped;
readonly Func<LogEvent, bool> _condition;
...
public void Emit(LogEvent logEvent)
{
if (_condition(logEvent)) _wrapped.Emit(logEvent);
}
...
}
ConditionalSink
功能非常简单,它也包含了一个ILogEventSink
对象,此外,还包含一个Func<LogEvent, bool>
的泛型委托。这个委托可以按照LogEvent
对象满足某种指定要求做过滤。从Emit函数内可以看出,只有在满足条件时才会将日志事件发送到对应的 Sink 中。它可以看成是带有条件写入的 Sink,这一点和也就是局部过滤功能实现的核心之处。
public interface ILogEventFilter
{
bool IsEnabled(LogEvent logEvent);
}
FilteringSink
所作的事情和ConditiaonalSink
一样,除了 Sink 对象外,它还维护了一组ILogEventFilter
数组用来指定多个日志过滤条件,而ILogEventFilter
接口如上所示,其内部就是按日志对象进行过滤。而RestrictedSink
内除ILogEventSink
对象外,还有一个LoggingLevelSwitch
对象,这个对象用来描述日志记录器能够记录的最小日志等级,所以RestrictedSink
所实现的是依照日志等级的比较判断是否输出日志。
sealed class SecondaryLoggerSink : ILogEventSink
{
readonly ILogger _logger;
readonly bool _attemptDispose;
...
public void Emit(LogEvent logEvent)
{
...
var copy = logEvent.Copy();
_logger.Write(copy);
}
}
和上述其他的ILogEventSink
的继承类相比,SecondaryLoggerSink
在其内部并没有保留对某个ILogEventSink
的引用。相反,它保留对给定的ILogger
对象的引用,这种好处是我们可以让一个日志记录器作为另一个日志记录的Sink
。该类另外的一个变量_attemptDispose
表示该类是否需要执行内部ILogger
对象的释放,之所以这样做是因为有的时候Logger
对象并不一定需要释放,通常由父日志记录器所创建出来的子日志记录器不需要释放,其资源释放可以由父日志记录器进行管理。
class SafeAggregateSink : ILogEventSink
{
readonly ILogEventSink[] _sinks;
...
public void Emit(LogEvent logEvent)
{
foreach (var sink in _sinks)
{
...
sink.Emit(logEvent);
...
}
}
}
除此之外,还剩下AggregrateSink
和SafeAggregrateSink
这两个 Sink 也继承ILogEventSink
接口,且内部都引用了ILogEventSink
数组,且在Emit
函数中基本都是对数组内的ILogEventSink
对象遍历,并调用这些对象内的Emit
函数。二者均在Emit
函数内将所有异常捕捉起来,但AggregateSink
会在捕捉后将这些异常以AggreateException
异常再次抛出。这两个类与之前的类不同,它们将多个 Sink 集合起来,让外界仍以单一的 Sink 来使用。其好处在于,Logger
的设计者不需要关注到底有一个还是多个 Sink,如果有多个 Sink,只需要用这两个类将多个 Sink 包裹起来,外界将这一组 Sink 当成一个 Sink 来使用。
为什么要这样设计?实际上,对Logger
类来说,它并不需要关心记录的 Sink 有一个还是多个,是什么样的状态,达到什么样的条件才能记录,毕竟这些都非常的复杂。对于Logger
来讲,它要做的只有一件事,只要将日志事件向ILogEventSink
对象中发出即可。为达到这样的目的,Serilog 利用设计模式中的装饰模式和组合模式来降低Logger
的设计负担。主要体现在两个方面。
-
通过装饰模式实现带有复杂功能的 Sink,通常通过继承
ILogEventSink
并内部保有一个ILogEventSink
对象来进行功能扩展,前面所提到的ConditionalSink
、FilteringSink
、RestrictedSink
等都属于带有扩展功能的Sink,可以看到,其构造函数均需要外界提供额外的ILogEventSink
对象。 此外,这些装饰类还可以嵌套,即一个装饰类可以拥有另一个装饰类对象,实现功能的聚合。 -
通过组合模式将一组 Sink 以单一 Sink 对象的方式暴露出来,
AggregrateSink
和SafeAggregrateSink
做的就是这件事。就算Logger
需要将日志记录到多个Sink中,从Logger
的角度来看,它也只是写入到一个ILogEventSink
对象中,这让Logger
设计者不需要为了到底是一个还是多个 Sink 而头疼。举个例子,假如你有一个 ConsoleSink,它的作用是将日志输出到控制台,以及一个将日志输出到文件的 FileSink。如果想利用Logger
对象将日志同时输出到控制台和文件,我们只需要构建一个AggregateSink
并将 ConsoleSink 和 FileSink 对象放置到其内部的数组中,再将AggregrateSink
作为Logger
中的ILogEventSink
的对象,那么Logger
能自动将日志分别记录到这两个地方。
总结
以上就是整个 Sink 功能的说明,可以看到的是,这块和之前提到的 LogDemo 项目非常的像。我相信如果在之前对 LogDemo 能够理解的人在这块能够找到非常熟悉的感觉。从下一篇开始,我将开始揭露 Serilog 是如何将 LogEvent 这样的日志事件转换成最终写入到各个Sink中的字符串信息的。