Serilog 源碼解析——數據的保存(上)

上一篇中,我們主要研究了Serilog是如何解析字元串模板的,它只是單獨對字元串模板的處理,對於日誌記錄時所附帶的數據沒有做任何的操作。在本篇中,我們著重研究日誌數據的存儲方式。(系列目錄)

本篇所解決的內容

本文主要講述在Serilog中日誌記錄器是如何記錄數據的,即在上一篇文章中解析部分的第二件事。和之前的文章架構一樣,本篇文章主要從數據存儲和行為邏輯兩個方面做闡述。

public void Process(string messageTemplate, object[] messageTemplateParameters, out MessageTemplate parsedTemplate, out EventProperty[] properties)
{
    parsedTemplate = _parser.Parse(messageTemplate);  // 第一件事
    properties = _propertyBinder.ConstructProperties(parsedTemplate, messageTemplateParameters);  // 第二件事
}

考慮到數據保存的邏輯比較複雜,涉及到的類結構比較多,計劃將該部分邏輯拆成兩個部分,方便理解。

EventProperty結構體

首先看下數據存儲所使用到的數據類。ConstructProperties方法返回的是EventProperty結構體數組。數組比較好理解,一個數據對應一個EventProperty結構。EventProerty結構從字面意思上可以看出來,下面是EventProperty核心部分。

readonly struct EventProperty
{
    public string Name { get; }
    public LogEventPropertyValue Value { get; }
}

這個結構體非常的簡單,內部只記錄該屬性的名稱和對應的數據,Name好理解,它是該數據的名稱,為字元串類型。另一個則是LogEventPropertyValue對象,它保存了對應數據。另外,該類被readonly所修飾,表明該類是一個只讀的結構體,一旦被創建出來,就無法修改內部的數據。

LogEventProperty

在 Serilog 中,有一個和EventProperty結構體功能差不多的類,即LogEventProperty類。從下面的程式碼可以看出,二者沒有太大的差別。和上面的結構一樣,這兩個程式碼文件均位於 Event 文件夾中,都是和數據相關的。

public class LogEventProperty
{
    public string Name { get; }
    public LogEventPropertyValue Value { get; }
}

LogEventPropertyValue類及其繼承類

在上一節,我們認為LogEventPropertyValue是保存相關數據的。在說明這個類之前,不知道有沒有人會很好奇一點,為什麼會有LogEventPropertyValue這個類?按道理,保存數據對象沒必要那麼大費周章,只需要用object類即可,畢竟object類是萬物所有類的基類,沒有任何必要額外構建新類。那麼,在 Serilog 中,為什麼要使用LogEventPropertyValue來保存數據呢?我們先看下這個類有什麼。

public abstract class LogEventPropertyValue : IFormattable
{
    public abstract void Render(TextWriter output, string format = null, IFormatProvider formatProvider = null);
    public string ToString() => ToString(null, null);
    public string ToString(string format, IFormatProvider formatProvider)
    {
        var output = new StringWriter();
        Render(output, format, formatProvider);
        return output.Tostring();
    }
}

可以看到,LogEventPropertyValue類是一個抽象類,它繼承於IFormattable介面,從其內部的函數可以看出,似乎都是和渲染相關,看不出來和數據保存有什麼關係。是我們弄錯了么?LogEventPropertyValue根本不是保存數據用的?

這裡我自己有一個回答,不一定保證正確。首先,回到上一個問題,為什麼不採用object而是使用新類。實際上,如果只從記錄數據的角度來看,object類足夠用了。然而,使用object類型有一個非常麻煩的問題,那就是不同的數據類型有不同的渲染方式,對於一個object類型的數據如何進行渲染是一個很麻煩的操作。對於原始數據類型,我們只需要調用其ToString方法將其轉換成字元串,數組則將數據渲染到[]中,字典則是將數據渲染到{}中,而更加複雜的數據類型類型,考慮其渲染形式,可能利用其ToString方法渲染($操作符),也有可能解構該對象渲染(@操作符),具體渲染形式由字元串模板內給出。對於這樣一個複雜的渲染邏輯,如果只使用object對象,那麼在渲染階段會構造一段非常複雜且難以維護的if-else語句塊。

public string Render(object obj)
{
    if (obj.GetType() == typeof(int) || obj.GetType() == typeof(double) || ...)
    {
        return obj.ToString();
    }
    else if (obj.GetGenericTypeDefinition() == typeof(IEnumerable<>))
    {
        ...
    }
}

更好的辦法,就是將不同的渲染策略封裝到對應的類中,即通過策略模式在不同的繼承類中重寫對應的渲染邏輯。在 Serilog 中所展現出來的就是,以LogEventPropertyValue為根類,若干不同渲染方法的繼承類ScalarValueSquenceValueDictionaryValueStructureValue。明白了這點後,就可以明白LogEventPropertyValue所提供的函數了,其抽象函數Render就表示子類需要重寫的渲染邏輯。Serilog 將數據的渲染邏輯分成四大類:

  • ScalarValue類:該類的渲染邏輯是直接將數據的ToString方法的結果返回,適用於基礎數據類型和一些強制要求字元串化的複雜數據(字元串模板內以$開頭)。
  • SqeuenceValue類:該類渲染邏輯是將多個數據渲染到[]中,通常數據是一個數組或列表。
  • DictionaryValue類:鍵值對類對象的渲染邏輯,將數據渲染到{}中,它要求數據鍵(key)應該是ScalarValue
  • StructValue類:將數據類解構,以公開的欄位或屬性名作為鍵值,進行渲染。

解決第一個問題後,再來看下第二個問題,作為各大渲染邏輯的基類,為什麼LogEventProperty沒有對數據的引用。我個人比較傾向於兩個方面來解釋。一是,沒有很方便的形式表達這個數據。我們知道四大 Value 類分別保存不同的數據,不同的數據採用不同的形式,這就使得在基類中不能很好地指明數據的類型。另一個就是,對於這些 Value 的派生類,它們更關注的是渲染的結果,而不是保存的數據,數據不是該數據結構中的重點,也就沒有必要在基類中指明數據。

從這個角度,我們就就可以著手查看四個派生類的內容了。基本上,四個類保有不同的數據對象並重寫了相應的Render函數,提供不同的重寫邏輯。

public class ScalarValue : LogEventPropertyValue
{
    public oject Value { get; }
    ...
}

public class SquenceValue : LogEventPropertyValue
{
    readonly LogEventPropertyValue[] _elements;
    ...
}

public class DictionaryValue : LogEventPropertyValue
{
    public IReadonlyDictionary<ScalarValue, LogEventPropertyValue> Elements { get; }
}

public class StructureValue : LogEventPropertyValue
{
    public LogEventPropertyValue[] _properties;
    public string TypeTag { get; }
}
  • ScalarValue類:這個類在Serilog算得上是一個比較重要的類,可以看到,其內部維護了一個object的對象,這和之前我們提到的object描述數據對象的想法一致,其渲染的方法基本上是利用C#主流的格式化方式輸出的。
  • SequenceValue類:該類內部維護了一個LogEventPropertyValue的數組,因為該類主要用於渲染一組數據對象(數組或隊列等)。因此,其內部的每一個元素都是一個LogEventPropertyValue對象。
  • DictionaryValue類:該類描述的是一組鍵值對應關係的渲染邏輯,這裡要求鍵的數據類型應該為ScalarValue
  • StructureValue類:該類主要描述以結構的方式輸出某個類對象內所有的公開屬性值,可以看到其內部維護的也是一個數組,這點和SequenceValue一樣,但它的渲染邏輯和SequenceValue完全不同。此外,該類還有一個TypeTag屬性,目前 Serilog 用它來描述該類對象的類型資訊。

到目前為止,描述數據保存的類就這麼多了,它主要通過EventProperty結構和LogEventProperty類來描述對應數據,這些結構和類中主要包含兩個部分,一個是用來描述當前屬性Token的名稱Name,另一個則是保存相關數據資訊的LogEventPropertyValue對象。LogEventPropertyValue對象則是一個抽象對象,它需要派生類提供一個具體的渲染方法。Serilog 針對不同的數據類型為LogEventPropertyValue提供了4類不同的渲染邏輯。最後,EventProperty結構體數組作為日誌事件的一類數據,也被保存在LogEvent消息日誌中。

PropertyBinder

在了解完對應的結果類後,我們可以看下它是怎麼生成的。Serilog 中,保存日誌數據的功能由PropertyBinder類提供,從名字上就可以看出它做的是綁定功能,即將字元串模板解析的屬性 Token 和對應的日誌數據進行綁定。也就是說,生成的EventProperty結構體數組內的每個元素應對應一個屬性 Token,其Name應該是屬性 Token 的PropertyName,其Value應該是對應的某個LogEventPropertyValue類對象,且該對象包裝了對應的日誌數據。

上一篇中曾經提到,屬性 Token 又主要分為兩類,一類是位置 Token,它在字元串模板中表示為位置序號,表示應該是之後第幾個日誌輸入數據,而另一類則是具名 Token,這類 Token 的數據嚴格按照順序決定,即第一個日誌數據對應第一個具名 Token。Serilog 認為二者不能混用,如果有具名的屬性 Token,則只使用具名 Token。為了降低篇幅,這裡僅分析具名 Token 的綁定邏輯,位置 Token 的綁定邏輯也是差不多的,感興趣的可以直接查看源碼。

class PropertyBinder
{
    readonly PropertyValueConverter _valueConverter;
    ...
    public EventProperty[] ConstructProperties(MessageTemplate messageTemplate, object[] messageTemplateParameters)
    {
        ...
        return ConstructNamedProperties(messageTemplate, messageTemplateParameters);
    }

    EventProperty[] ConstructNamedProperties(MessageTemplate template, object[] messageTemplateParameters)
    {
        // 獲取消息模板中具名屬性Token的個數
        var namedProperties = template.NamedProperties;
        var matchedRun = namedProperties.Length;
        ...

        // 按照具名屬性Token構造相應的EventProperty結構並賦值
        var result = new EventProperty[messageTemplateParameters.Length];
        for (var i = 0; i < matchedRun; ++i)
        {
            var property = template.NamedProperties[i];
            var value = messageTemplateParameters[i];
            result[i] = ConstructProperty(property, value);
        }

        // 如果消息數據還有多的話,則繼續構造,其屬性名為__加序號
        for (var i = matchedRun; i < messageTemplateParameters.Length; ++i)
        {
            var value = _valueConverter.CreatePropertyValue(messageTemplateParameters[i]);
            result[i] = new EventProperty("__" + i, value);
        }
        return result;
    }

    EventProperty ConstructProperty(PropertyToken propertyToken, object value)
    {
        return new EventProperty(
                    propertyToken.PropertyName,
                    _valueConverter.CreatePropertyValue(value, propertyToken.Destructuring));
    }
}

以上為PropertyBinder的部分程式碼。首先是_valueConverter這個PropertyValueConverter對象,有什麼功能,做什麼事暫時不清楚,先放一放。向下繼續,ConstructProperties函數,該函數作為PropertyBinder的唯一公開函數,提供了整個綁定功能。往下,ConstructNamedProperties函數提供了綁定具名屬性 Token 和日誌數據的功能。內部主要做了三件事:

  1. 獲取解析後的MessageTemplate中具名屬性Token對象以及其數目;
  2. 針對每個具名屬性Token在對應的位置構造對應的EventProperty結構
  3. 如果消息記錄時提供了多於解析出具名屬性Token數目的消息數據時,則把後續部分仍保留下來,且設置其Name__加當前序號。

最後,在構造對應某個EventProperty結構時,採用ConstrctProperty函數進行構造。可以看到,通過構造函數,將具名屬性Token的屬性名稱傳給Name值,而具體構造哪種LogEventPropertyValue對象,則有PropertyValueConverterCreatePropertyValue方法進行構造。由此可見,PropertyValueConverter有點類似於工廠,指明當前消息數據應構造什麼LogEventPropertyValue派生類。至於PropertyValueConverter類具體如何做到的,將留到下一篇再講解吧。

總結

本文對字元串模板解析後的屬性 Token 與日誌數據的綁定做了大概的介紹。首先說明的是綁定最終得到了什麼結果,即EventProperty結構體以及LogEventProperty類。在這些結構體/類的內部,通過LogEventPropertValue保存每一個日誌數據,該類是一個抽象類,不同的渲染方式有著不同的繼承類。之後,簡要描述了下綁定過程,即通過PropertyBinder將每一個具名屬性 Token 與對應的日誌數據對象綁定。然而,具體的綁定過程沒有進行交代,這也是下一篇文章的主要內容,即給定一個屬性 Token 與一個日誌對象,如何生成對應的EventProperty結構體。