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

在阅读 Serilog 类库前,这里通过一个 Demo 的设计来快速理清日志记录库的需求以及较为基础的设计方案是什么。本篇及下篇文章主要通过甲方提需求的方式来逐渐演化 Demo 的架构,最终达到一个较为可用的地步,为 Serilog 源码的阅读奠定基础。ok,话不多说,咱们现在就开始。(系列目录

版本一(万事开头易?)

甲方:先不说别的,就整个可用的,可以记录信息的,简单点的,日志输出到控制台中的。

为让第一版的逻辑尽可能地简单,我让甲方提了一个最最最基本的需求。我估计现实生活中应该没有这么好说话的甲方吧。

public static class LogHelper
{
    public static viod LogToConsole(string message)
    {
        Console.WriteLine(message);        
    }
}

当然,这个版本的代码非常简单,简单到只需要短短几行就能说清楚。其核心逻辑是直接调用Console类中的WriteLine方法将日志信息写到控制台中,非常简单。可能有的人会说,你搞得这么麻烦干嘛,直接在需要调用的地方写Console.WriteLine不比你写LogHelper.LogToConsole简单么。别急,这只是最简单的封装,后续还需要再往里面加内容的。

版本二(加班加点加功能)

甲方:你这个类库不太好用啊,我知道它能够记录信息,但也就只有信息,日志时间呢?日志等级呢?啥都没有,就只有日志信息。

ok,需求方开始抱怨啦,赶紧加新功能,加加加,赶快点。

在添加新功能前,首先明确需求方提的两个概念,即日志时间和日志等级。日志时间,顾名思义,就是记录日志的时间,这个数据需要添加到日志中,方便我们快速通过时间定位到具体日志。而所谓日志等级,则指明日志信息的重要程度,通常分为若干个不同等级,在不同框架里面名字及数目可能不太一样,这里取 Serilog 内的等级分法。Serilog 将日志等级分成 6 个部分,主要如下:

  • Verbose 等级,该等级是6个等级中最低的等级,它一般用作详细流程中的具体事件记录,通常用在项目开发过程中。
  • Debug 等级,等级比Verbose略高,用于项目开发过程中使用。
  • Information 等级,一般用在正常流程中,打印一些你较为感兴趣的或者是重要的信息。
  • Warning 等级,警告信息,一般用于意外的非正常但不影响系统主流程的场合。
  • Error 等级,错误信息,常用在系统内部抛出异常需要记录的场合。
  • Fatal 等级,致命信息,该信息通常用在发生了严重错误使得系统崩溃或者项目重启等严重场合。

为此,我们首先利用枚举定义日志等级。如下所示。

public enum LogLevel
{
    Verbose, Debug, Information, Warning, Error, Fatal
}

其次,每一次记录日志都会涉及时间、等级和消息这三要素。为表方便,我们将这些数据封装在类中,构造LogData类对象来描述一个事件信息,如下所示。可以看到,这些数据以只读的方式向外暴露,只通过构造函数的参数对其设值。另外,值得说明的一点是时间不由外界传入而是采用 Utc 的Now时间。之所有用 Utc 时间,是因为日志记录可能会涉及到多个时区(比如说有多台服务器在多个时区中),为了方便统一,所有时间采用 Utc 时间,而非所在地时间。最后,通过重写ToString()函数告知程序如何将LogData对象转化成字符串。

public class LogData
{
    public DateTime Time { get; }
    public LogLevel Level { get; }
    public string Message { get; }

    public LogData(LogLevel level, string message)
    {
        Time = DateTime.UtcNow;
        Level = level;
        Message = message;
    }

    public override string ToString()
    {
        return $"[{Time.ToString()}][{Level.ToString()}]{Message}";
    }
}

构造了这两个类后,我们就可以修改原有的 API 了。我们修改了原有函数的参数。为保持和前一版本的兼容性,我们将等级信息作为默认参数传入,其默认值为 Information 等级。在函数内部,我们通过构造对应的LogData类来描述一个日志事件,然后通过控制台将这个事件信息格式化输出出来。考虑到我们在LogData类中定义了ToString()方法,所以我们可以直接将该对象作为输入参数传入其中。

public static LogHelper
{
    public static void LogToConsole(string message, LogLevel level = LogLevel.Information)
    {
        var logData = new LogData(level, message);
        Console.WriteLine(logData);
    }
}

版本三(新的需求纷至沓来)

甲方:那个,每次只将日志记录到控制台不太好,下次重新运行之前的日志都会被丢了,考虑让日志持久化,比如说记录到文件里?

新需求又来了,老老实实弄吧。对于这个需求,解决的办法也很简单,我们只需要增加一个函数,通过文件的写入将数据写入即可。熟悉文件操作的小伙伴对这段代码逻辑应该不会感觉到陌生,通过写入流向给定的文件内写入日志数据,搞定。

public static LogHelper
{
    ...
    public static void LogToFile(string message, string logFilePath, LogLevel level = LogLevel.Information)
    {
        var logData = new LogData(level, message);

        using var fs = new FileStream(logFilePath, FileMode.Append);
        using var sw = new StreamWriter(fs);
        sw.WriteLine(logData);
    }
}

在该版本中,我们可以发现LogHelper暴露两个API方法,即LogToConsole()以及LogToFile(),这两个方法分别实现将日志记录到控制台以及文件中。先不说好不好用,至少目前在当前需求下是可以做到了。

第四版(缝缝补补又是一版)

甲方:我之前是说增加将日志记录到文件的功能,但是你这玩意也太不好用了吧。我要是想把信息同时记录到控制台和文件那我每次都要写两行代码调用两次函数才能实现,这也太麻烦了。

在处理需求前,明确一点的是,将一条日志内容记录到多个媒介中,这样的需求是广泛存在的。比如说将日志信息记录到控制台和文件中,控制台方便我们实时查看当前时间记录下来的日志,好判断当前时间是否发生过异常或错误;而文件则方便我们去寻找特定时间点的日志,核对某个特定时间段的业务流程。甲方的需求具有普遍性。而在v3版本中,如果想将一条日志信息记录到多个目的地,则需要写多条语句,太麻烦了,谁都不想写这样的玩意。

LogHelper.LogToConsole("尝试登录...");
LogHelper.LogToFile("尝试登录...", "./log.txt");
...

另外,如果你想将日志信息记录到多个文件里,还需要多次调用LogToFile()函数,这样下来,就会发现,甲方在一次日志记录中要将日志记录到多少个媒介,就需要调用多少次LogToXXX方法,这还只是一次日志记录,如果有几条十几条,那就是乘法了,这对类库的使用者来说是非常不友好的。有更加轻松的办法么?当然有,最容易想到的方法就是再提供一个合并后的 API 方法,通过布尔参数指明是否需要将日志记录到控制台中,以及通过路径字符串数组指明日志需要记录到哪些文件中。

public static LogHelper
{
    ...
    public static void LogToConsoleAndFile(string message, bool logToConsole, string[] logFilePaths, LogLevel level = LogLevel.Information)
    {
        if (logToConsole) LogToConsole(message, level);
        foreach(var path in logFilePaths)
        {
            LogToFile(message, path, level);
        }
    }
}

本质上,该方法仅仅只是将先前的两个接口函数做了聚合。可以看到,在该函数内部,其功能的实现就是通过调用另外两个函数达到目的,如果LogToConsole()LogToFile()函数内的逻辑发生变化,则该函数可以自动适配新版本的功能(前提是 API 方法的调用形式没有变化)。

嗯,看起来很完美,需求方的需求得到完美解决。v4 和v3 相比,它将同一条日志信息写入多处目的地的调用方式由多行缩减到一行,但是这种方式是最好的处理方法么?也不见得,当我们在多次记录不同日志时,仍会发现其使用方式过于繁琐。举个例子,如果我们想写入多条日志,如下所示,我们仍旧能够找到一些问题。

LogHelper.LogToConsoleAndFiles("正在登陆...", true, new string[] { "./log.txt" });
LogHelper.LogToConsoleAndFiles("正在验证账号合法性...", true, new string[] { "./log.txt" });
LogHelper.LogToConsoleAndFiles("正在验证密码是否正确...", true, new string[] { "./log.txt" });
LogHelper.LogToConsoleAndFiles("登陆完成", true, new string[] { "./log.txt" }, LogLevel.Information);

一方面,通常情况下,在一个上下文中,我们所记录的日志所写入的媒介对象往往是确定且相同的。因此,可以看到,上面有大量的true, new string[] { "./log.txt" }都是重复的。我们知道要把日志记录到控制台和对应文件中,但是因为设计不善,每次日志记录都需要显示指定所有的目标地(控制台和文件)。另一方面,回过头看下LogToFile以及LogToConsoleAndFiles这两个函数的输入参数,我们会发现一个很奇怪的地方:输入参数messagelevel都是同一个日志内的数据,为什么一个在最开始位置,一个在最末尾的位置?好的 API 设计应该把相关参数放在一起。这里之所以要把等级参数放在最后,是因为我们需要给等级提供一个默认参数,使得别人使用时在平时调用时减少重复性代码。这一点初衷是好的,但是为了减少重复性代码,就把等级参数放在最后也有点不太合适。

说到底,上面讲的两个问题,其根本原因都在于配置。试想一下,如果我们在记录前事先指定了记录器将日志记录到控制台以及./log.txt文件的话,我们就不需要在每次调用时再显式提供这些值。为达到这样的目的,我们需要一些变量来存储我们的数据。然而,普通的函数是做不到存储数据的,因为函数执行完毕后,其内部的本地变量都会被抛弃(高阶函数除外)。这时候,我们就需要对象来帮助我们了。

总结

本文主要从过程式的思维角度来尝试设计日志记录库,从开始最初始化的需求到后期将日志记录到多目的地中,随着设计的演化,我们逐渐发现基于过程式的开发思想不够用了,我们需要面向对象的设计思想来帮助我们设计更加优秀的类库。不过这部分就交给下一篇吧。

posted @ 2020-11-03 11:06  iskcal  阅读(814)  评论(1编辑  收藏  举报