1. 程式人生 > 實用技巧 >Serilog 原始碼解析——資料的儲存(上)

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結構體。