1. 程式人生 > 實用技巧 >Serilog 原始碼解析——Demo 實現(下)

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相應的原始碼。