Serilog 源码解析——Sink 的实现

上一篇中,我们简单地查看了 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 等级中,需要保存异常信息时使用。至于后续的MessageTemplateLogEventPropertyValue,从字面意义上看,属于字符串消息模板和记录数据时所用到,目前我们主力研究记录到 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);
    }
}

考虑到篇幅,这里我去掉了部分和当前功能无关的代码,只保留最为核心的代码。

  1. 首先,我们看下继承关系,Logger类除继承ILogger之外,还继承ILogEventSink接口,这个继承关系看起来很奇怪,但细想也觉得正常,一个日志记录器不光可以当日志事件的发生器,也可以当其接收器。换而言之,可以将一条日志事件写到另一个日志记录器中,由另一个日志记录器记录到其他 Sinks 中。此外,该类还继承了IDisposable接口,按照逻辑需求来讲,Logger是没有东西需要释放的,其需要释放的通常是内部包含的一些对象,比如说 FileSink 如果长时间维持一个文件句柄的话,则需要在Logger回收后被动释放,因此,这导致了Logger需要维护一组待释放的对象进行释放。在Logger内部中,通过添加Action函数钩子的方式进行释放。

  2. 之后,我们会发现所有的写入日志方法直接或间接地调用上面给出的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);
            ...
        }
    }
}

除此之外,还剩下AggregrateSinkSafeAggregrateSink这两个 Sink 也继承ILogEventSink接口,且内部都引用了ILogEventSink数组,且在Emit函数中基本都是对数组内的ILogEventSink对象遍历,并调用这些对象内的Emit函数。二者均在Emit函数内将所有异常捕捉起来,但AggregateSink会在捕捉后将这些异常以AggreateException异常再次抛出。这两个类与之前的类不同,它们将多个 Sink 集合起来,让外界仍以单一的 Sink 来使用。其好处在于,Logger的设计者不需要关注到底有一个还是多个 Sink,如果有多个 Sink,只需要用这两个类将多个 Sink 包裹起来,外界将这一组 Sink 当成一个 Sink 来使用。

为什么要这样设计?实际上,对Logger类来说,它并不需要关心记录的 Sink 有一个还是多个,是什么样的状态,达到什么样的条件才能记录,毕竟这些都非常的复杂。对于Logger来讲,它要做的只有一件事,只要将日志事件向ILogEventSink对象中发出即可。为达到这样的目的,Serilog 利用设计模式中的装饰模式和组合模式来降低Logger的设计负担。主要体现在两个方面。

  1. 通过装饰模式实现带有复杂功能的 Sink,通常通过继承ILogEventSink并内部保有一个ILogEventSink对象来进行功能扩展,前面所提到的ConditionalSinkFilteringSinkRestrictedSink等都属于带有扩展功能的Sink,可以看到,其构造函数均需要外界提供额外的ILogEventSink对象。 此外,这些装饰类还可以嵌套,即一个装饰类可以拥有另一个装饰类对象,实现功能的聚合。

  2. 通过组合模式将一组 Sink 以单一 Sink 对象的方式暴露出来,AggregrateSinkSafeAggregrateSink做的就是这件事。就算Logger需要将日志记录到多个Sink中,从Logger的角度来看,它也只是写入到一个ILogEventSink对象中,这让Logger设计者不需要为了到底是一个还是多个 Sink 而头疼。举个例子,假如你有一个 ConsoleSink,它的作用是将日志输出到控制台,以及一个将日志输出到文件的 FileSink。如果想利用Logger对象将日志同时输出到控制台和文件,我们只需要构建一个AggregateSink并将 ConsoleSink 和 FileSink 对象放置到其内部的数组中,再将AggregrateSink作为Logger中的ILogEventSink的对象,那么Logger能自动将日志分别记录到这两个地方。

总结

以上就是整个 Sink 功能的说明,可以看到的是,这块和之前提到的 LogDemo 项目非常的像。我相信如果在之前对 LogDemo 能够理解的人在这块能够找到非常熟悉的感觉。从下一篇开始,我将开始揭露 Serilog 是如何将 LogEvent 这样的日志事件转换成最终写入到各个Sink中的字符串信息的。

posted @ 2020-11-09 15:39  iskcal  阅读(1004)  评论(0编辑  收藏  举报