Serilog 源码解析——Demo 实现(下)

上篇文章中,我们提到,面向过程的代码无法利用配置来减少调用时期的冗余代码,面向对象的编程方式似乎是一种解决方案。在本篇文章中我们利用面向对象的编程方式来看下它是如何做到减少重复代码的。(系列目录

版本五(改头换面新设计)

甲方:我就不说啥了,你都把我要说的都说完了,你看着改吧。

之前说到,对象可以减少过程式代码中参数的配置问题,那么怎么才能做到呢?因为在每次调用函数时都需要将相关参数传入,那么我们可以将参数首先保存在类对象中,为此,我们需要一个新的类,按照v4版本中的LogToConsoleAndFiles的调用参数,我们将logToConsole以及logFilePaths两个参数由函数的输入参数提升到类成员字段里,并通过构造函数的方式进行赋值,如下所示。

public class Logger
{
    private bool _logToConsole;
    private string[] _logFilePaths;

    public Logger(bool logToConsole = false, string[] logFilePaths = null)
    {
        _logToConsole = logToConsole;
        _logFilePaths = logFilePaths;
    }
    ...
}

这样一来,只要我们保持了对Logger类对象的引用,那么自然可以利用这两个字段中的值。接下来,我们定义日志记录的 API 方法。

public class Logger
{
    ...
    public void LogToTarget(LogLevel level, string message)
    {
        var logData = new LogData(level, message);
        if (_logToConsole) LogToConsole(logData);
        if (_logFilePaths != null) LogToFiles(logData);
    }

    private void LogToConsole(LogData logData)
    {
        Console.WriteLine(logData);
    }

    private void LogToFiles(LogData logData)
    {
        foreach(string path in _logFilePaths)
        {
            using var fs = new FileStream(path, FileMode.Append);
            using var sw = new StreamWriter(fs);
            sw.WriteLine(logData);
        }
    }
    ...
}

这里,和 v1~v4 版本中的LogHelper静态类中的相关函数逻辑一样,只不过暴露出的API方法改名为LogToTarget。在LogToTarget方法中,我们通过开关来控制我们将日志记录到哪,它不负责具体的写入逻辑,具体写入部分由之后两个私有方法处理。这样的方法架构比较遵循单一职责的原则,在单一职责的原则中,每个函数只负责一个功能。这里,LogToTarget函数作为API方法由外界进行调用调用,LogToConsole函数负责向控制台写入日志,LogToFile函数负责向多个文件写入。值得一提的是,我们改变了API方法的名称,不过无所谓了,本身的使用方式就已经发生大变化了,就不要在意这些小的兼容性。反正甲方的脾气好,兼容之前版本的问题不是我们要考虑的,再加上咱们又不是真正意义上的开源项目,只是一个小的 demo。另外,既然有新的入口,那么旧的入口类LogHelper就没有存在的必要了,也就一并删了吧。

对于v5版本,日志记录的使用方式就发生了变化。对于上回的使用场景,他就变成了这样:

var log = new Logger(true, new string[] { "./log.txt" });

log.LogToTarget(LogLevel.Information, "正在登陆...");
log.LogToTarget(LogLevel.Information, "正在验证账号合法性...");
log.LogToTarget(LogLevel.Information, "正在验证密码是否正确...");
log.LogToTarget(LogLevel.Information, "登陆完成");

可以看到,和之前的写法相比,这种写法简便了不少,但是仍发现有部分冗余的代码,LogLevel.Information这玩意需要你对这个类库有一定了解才会使用的,在你不接触这个类库前,你并不知道这里需要填什么,虽然有VS强大的智能提示,但是仍还是有一定的心理负担,就不能像之前提供默认值的方式么?这里,这里有一个更好的方式,我们对LogToTarget不同等级的日志记录做进一步的封装。将6个等级封装成6个函数。

public class Logger
{
    ...
    public void LogVerbose(string message)
        => LogToTarget(LogLevel.Verbose, message);

    public void LogDebug(string message)
        => LogToTarget(LogLevel.Debug, message);

    public void LogInformation(string message)
        => LogToTarget(LogLevel.Information, message);

    public void LogWarning(string message)
        => LogToTarget(LogLevel.Warning, message);

    public void LogError(string message)
        => LogToTarget(LogLevel.Error, message);

    public void LogFatal(string message)
        => LogToTarget(LogLevel.Fatal, message);
}

我们将不同等级的日志写入逻辑进一步封装。这样,当你需要写入某个特定等级的日志时通过这些 API 方法写入只需要提供日志字符串即可,进一步降低了使用门槛。比如说,原先的调用方式进一步简化成这样的写法。

var log = new Logger(true, new string[] { "./log.txt" });

log.LogInformation("正在登陆...");
log.LogInformation("正在验证账号合法性...");
log.LogInformation("正在验证密码是否正确...");
log.LogInformation("登陆完成");

版本六(扩展,无限的可能)

在v5版中,甲方发现现在的类库用起来还不错,使用方便,调用方式符合直觉。不过……

甲方:之前版本挺好的,用起来挺舒服的。不过我这边需要将日志写道邮箱里、通过Http写到指定网站里,写到日志数据库里,我也不要求你都给我写出来,你把接口预留给我,具体的写法我自己来处理吧。

看,我设定的甲方还是很仁慈的,没有要求你去写邮件、网络和数据库部分,实际上这也不是这个Demo的重点。这次需求的重点在于,日志记录库需要有扩展性,理论上来说日志库不可能提供向所有目的地记录日志的功能,因此需要预留接口供他人二次开发。

怎么做呢?可能最先想到的利用继承来解决,通过继承Logger类,向其内部注入新的函数LogToEmail()LogToHttp()LogToSqlite()等等。然而,这里有个问题,因为所有的日志记录API都是通过LogToTarget完成的,如果我们添加新目的地的日志写入功能,需要重新编写LogToTarget方法。然而,对该方法的重写是很危险的,就像下面这样,如果不清楚内部逻辑,一旦稍有不慎,就会丢掉原先的逻辑。

public class AdvancedLogger : Logger
{
    ...
    public void LogToTarget(LogLevel level, string message)
    {
        LogToEmail(level, message);
        LogToHttp(level, message);
        LogToSqlite(level, message);
    }
    private void LogToEmail(LogLevel level, string message)
    {
        // 邮件记录
    }
    private void LogToHttp(LogLevel level, string message)
    {
        // Http记录
    }
    private void LogToSqlite(LogLevel level, string message)
    {
        // Sqlite记录
    }
}

这个继承类有以下几个问题。

  1. 它覆盖掉了原有的Logger内中LogToTarget函数的逻辑。为了添加向邮件、Http 以及 sqlite 的支持,则必须要重写这个函数,这很正常,但问题是稍不注意则会丢弃原有的运行逻辑。并且,这种错误并不会在编译期报错,而会在运行期间以不符合预料的行为现象表现出来,增加了软件调试的难度。按照软件设计的理论,我们应该尽可能地让错误尽早暴露出来,将错误由最后运行期暴露不是一个好的方案。

  2. 对于后续新增的LogToXXX函数,没有固定好其函数的调用形式。在v5版本中,我们将LogData类对象作为唯一的输入参数输入,由LogData对象内的ToString方法自行将其转换成字符串并交给LogToXXX方法写入对应目的地中,也就是说,日志字符串的生成工作由LogData负责,而具体的LogToXXX则只负责将生成好的字符串写道对应目的地中。但在AdvancedLogger中对其他写入放法的规则是自己编写的,可以使用LogData,也可以使用其他任意值。换句话来说,函数没有一个约束输入参数的规则。

可以看出,继承的做法并不是比较好的方法。那么,有办法做到本轮需求且不引入这两个缺点么?有的。我们先回顾v5版本中的Logger类,我们发现Logger类担任了太多职责了。对于Logger类来说,它既负责了提供调用的 API(LogToTargetLogXXX)又提供了具体的写入操作(LogToConsoleLogToFiles)。从单一职责的角度来看,这种设计会严重加大类设计的复杂性。如果一个类有太多的职责的话,常规的做法是将其切分成多个类,每个职责一个类,减少类设计的复杂度。从我们的使用场景来看,Logger类应该是提供调用的API而不需要负责具体的写入。Logger类只需要保有对写入对象的引用,在LogToTarget中对其一一引发即可。

那么,负责具体写入的应该是什么呢?通过观察LogToConsole以及LogToFiles可以发现,其函数名均带有一个LogData的输入以及无返回值的输出(void LogToXXX(LogData logData))。那么我们只需要认为负责具体写入的类包含这样的函数即可。考虑到该函数没有默认的方法实现,我们使用接口来描述。

public interface ILogTarget
{
    void WriteLog(LogData logData);
}

通过继承ILogTarget,我们实现了负责输出到控制台以及文件的类设计:

public class ConsoleTarget : ILogTarget
{
    public void WriteLog(LogData logData)
    {
        Console.WriteLine(logData);
    }
}

public class FileTarget : ILogTarget
{
    private string _filePath;

    public FileTarget(string filePath)
    {
        _filePath = filePath;
    }

    public void WriteLog(LogData logData)
    {
        using var fs = new FileStream(_filePath, FileMode.Append);
        using var sw = new StreamWriter(fs);
        sw.WriteLine(logData);
    }
}

注意一点的是这里的文件写入对象只负责一个文件的写入,而非像以前那样通过提供路径数组写入多个,这样写的好处在于每个FileTarget类对象负责一个文件的写入,不同的文件写入方法有不同的配置,更加灵活。

这样,如果有人需要做二次开发,提供更多写入目的地的实现,只需要继承ILogTarget接口,并编写接口内需要提供的逻辑即可。不仅如此,在继承中可以塞入更多的配置信息,来灵活处理不同的日志写入。例如FileTarget中,通过在构造函数内提供路径来保存每次写入哪个文件。

剩下来就是改造Logger类了。在我们将具体写入逻辑从该类剥离出来后,它只需要保留对ILogTarget列表的引用,当写入日志时,构造对应的LogData对象,再依次调用数组中每个写入方法即可。剩余的LogXXX系列的 API 无需改动,其本身就是对LogToTarget的调用而已。

public class Logger
{
    public List<ILogTarget> Targets { get; set; } = new List<ILogTarget>();

    public void LogToTarget(LogLevel level, string message)
    {
        var logData = new LogData(level, message);
        foreach (var target in Targets)
        {
            target.WriteLog(logData);
        }
    }
    ...
}

最后,我们看下这个版本的使用方式,这对需求方来讲是最重要的。这里使用三行代码来构建相对应的logger对象,通过给定的 API 来写。当然如果需求方想设计自己的写入对象(Email),他只需要实现ILogTarget接口,并将该对象添加到logger.Target对象即可。扩展性得到了大大的增强。

Logger logger = new Logger();
logger.Targets.Add(new ConsoleTarget());
logger.Targets.Add(new FileTarget("./log.txt"));

logger.LogInformation("用户登录成功");

另外,纵观v6版本,里面的类开始慢慢变多了,为了方便区分,我们把相同相似的功能放在一个文件夹下。如下图,Data表示保存日志事件相关数据,Target则保存类库内部提供好的若干具体写入逻辑。

项目结构

版本七(再好的玩意都需要易用的设计)

甲方:v6版本用起来确实还不错,扩展点也合理,不过,就是最初创建Logger对象的时候,比较麻烦。我把这个库给其他人用,他们说ConsoleTargetFileTarget是啥,为什么FileTarget构造函数必须要带参数,为什么要将这两个玩意添加到Targets属性里,logger里面的Targets是啥?你能不能提供一种更好的创建方式?

确实,在v6版本中,我们通过三行来构建了一个可以写入控制台和文件的日志记录器,第一条语句不用说,通过new关键字创建对应对象,但后两句容易让新手不易理解,就像甲方所说的,似乎为了最基础的使用,我们需要了解类库的内部,这让新人使用起来非常有负担。至少为了使用这玩意,他们需要记住有ConsoleTargetFileTarget这些类,以及相关的使用方法。

在修前之前,我们再次回顾下v6中的三行创建语句。本质上来讲,调用方只需要针对Logger中的Targets数组添加对应的写入模式对象即可,写入控制台就添加ConsoleTarget对象,写入文件则添加FileTarget对象。那么我们用一个新类LogBuilder来描述具体的创建过程,如下所示。

public class LogBuilder
{
    public List<ILogTarget> Targets { get; }

    public LogBuilder()
    {
        Targets = new List<ILogTarget>();
    }

    public Logger CreateLogger()
    {
        return new Logger(Targets.ToArray());
    }
}

LogBuilder中,我们维护了一个ILogTarget的列表,在类初始化时就给它设置默认长度的列表。之后,在CreateLogger函数中,我们将Targets通过Logger的构造函数注入到Logger类中的_targets字段中。为此,我们修改Logger中的相关代码,将原本的ILogTarget的列表转变成数组,这是因为Logger只用于记录日志,此时其写入的目的地就已经固定了,不需要再改动。

public class Logger
{
    public ILogTarget[] _targets;

    public Logger(ILogTarget[] targets)
    {
        _targets = targets;
    }
    ...
}

我们定义好了Logger以及LogBuilder后,原本 v6 中向Logger内添加对应的ILogTarget对象则转变成如何向LogBuilder中的Targets添加对象。这里,常用的办法是通过扩展方法添加写入功能。

public static class LogBuilderExtentions
{
    public static LogBuilder AddConsole(this LogBuilder builder)
    {
        builder.Targets.Add(new ConsoleTarget());
        return builder;
    }

    public static LogBuilder AddFile(this LogBuilder builder, string path)
    {
        builder.Targets.Add(new FileTarget(path));
        return builder;
    }
}

基于上述改动,我们的使用方法变得更加简单,如下所示。这将原先的三行构造语句缩减成了一行,虽然这行语句由4行构成,但和 v6 版本的调用方式相比,该版本具有更强的语义性。首先是创建LogBuilder对象,通过名字我们知道它是核心类Logger的创建器,之后通过AddXXX表示添加对应的日志写入目的地,AddConsole表明目的地是控制台,AddFile表明目的地是给定文件,最后通过CreateLogger创建出指定的Logger对象。这种方法的好处在于,它非常方便需求方以及其他调用方的使用,可以看到,它只需要调用方知道构造LogBuilder类,然后通过AddXXX系列的函数添加不同的日志写入媒介逻辑,最后通过CreateLogger方法创建出Logger对象。

Logger logger = new LogBuilder()
                .AddConsole()
                .AddFile("./log.txt")
                .CreateLogger();
logger.LogInformation("test...");

如果有熟悉设计模式的小伙伴就会发现,这就是建造者模式的一种实现,针对复杂的对象创建,构造一个建造者对其建造。建造的选项和核心类区分开,也是单一职责原则的体现。

v7项目结构

考虑到v7版本中较为多的类,我给出了v7版本的类图。

项目类图

  1. 在类图的右上半部分,LogLevel描述了日志等级的枚举,LogData描述了一次日志记录应该包含哪些信息,其中它包含对日志等级的引用。
  2. 在类图的右下半部分,ILogTarget描述了将日志信息LogData对象写入目的地的接口,继承该接口的ConsoleTarget以及FileTarget类分别实现了对控制台和文件的日志记录。
  3. 在正中间,Logger类负责日志的记录,内部维护了ILogTarget数组,该数组表示Logger对象应该向哪些目的地写入日志信息,其具体的目的地写入信息由继承ILogTarget接口的类负责。另外,Logger提供了若干 API 方法负责对不同日志等级的日志写入。
  4. 在左边,LogBuilder类负责构造Logger对象,其内部维护ILogTarget列表,添加新的日志记录地也就是向列表中添加新的对象,CreateLogger函数利用该列表创建对应的Logger对象。在左下角部分,Demo 通过扩展方法向LogBuilder类提供向控制台和文件写入功能的函数AddConsole以及AddFile

总结

好了,到v7版本位置,LogDemo 的演化到这就结束了,如果大家对这个小 Demo 能够理解的话,那么再理解后续的 Serilog 源码中最核心的部分就会轻松很多。实际上,Serilog 能够利用强大的扩展性添加不同的功能和本 Demo 类似,大多通过接口进行功能扩展的。下一篇文章主要通过对 Serilog 的使用方式来了解Serilog一些基本功能,以及如何获取Serilog相应的源码。

posted @ 2020-11-04 15:52  iskcal  阅读(675)  评论(2编辑  收藏  举报