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文件的話,我們就不需要在每次調用時再顯式提供這些值。為達到這樣的目的,我們需要一些變數來存儲我們的數據。然而,普通的函數是做不到存儲數據的,因為函數執行完畢後,其內部的本地變數都會被拋棄(高階函數除外)。這時候,我們就需要對象來幫助我們了。

總結

本文主要從過程式的思維角度來嘗試設計日誌記錄庫,從開始最初始化的需求到後期將日誌記錄到多目的地中,隨著設計的演化,我們逐漸發現基於過程式的開發思想不夠用了,我們需要面向對象的設計思想來幫助我們設計更加優秀的類庫。不過這部分就交給下一篇吧。