Serilog 原始碼解析——資料的儲存(上)
阿新 • • 發佈:2020-11-16
在[上一篇](https://www.cnblogs.com/iskcal/p/parse-of-string-template.html)中,我們主要研究了Serilog是如何解析字串模板的,它只是單獨對字串模板的處理,對於日誌記錄時所附帶的資料沒有做任何的操作。在本篇中,我們著重研究日誌資料的儲存方式。([系列目錄](https://www.cnblogs.com/iskcal/p/introduction-to-the-source-code-of-serilog.html#目錄))
## 本篇所解決的內容
本文主要講述在Serilog中日誌記錄器是如何記錄資料的,即在上一篇文章中解析部分的第二件事。和之前的文章架構一樣,本篇文章主要從資料儲存和行為邏輯兩個方面做闡述。
```csharp
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`核心部分。
```csharp
readonly struct EventProperty
{
public string Name { get; }
public LogEventPropertyValue Value { get; }
}
```
這個結構體非常的簡單,內部只記錄該屬性的名稱和對應的資料,`Name`好理解,它是該資料的名稱,為字串型別。另一個則是`LogEventPropertyValue`物件,它儲存了對應資料。另外,該類被`readonly`所修飾,表明該類是一個只讀的結構體,一旦被創建出來,就無法修改內部的資料。
## `LogEventProperty`類
在 Serilog 中,有一個和`EventProperty`結構體功能差不多的類,即`LogEventProperty`類。從下面的程式碼可以看出,二者沒有太大的差別。和上面的結構一樣,這兩個程式碼檔案均位於 Event 資料夾中,都是和資料相關的。
```csharp
public class LogEventProperty
{
public string Name { get; }
public LogEventPropertyValue Value { get; }
}
```
## `LogEventPropertyValue`類及其繼承類
在上一節,我們認為`LogEventPropertyValue`是儲存相關資料的。在說明這個類之前,不知道有沒有人會很好奇一點,為什麼會有`LogEventPropertyValue`這個類?按道理,儲存資料物件沒必要那麼大費周章,只需要用`object`類即可,畢竟`object`類是萬物所有類的基類,沒有任何必要額外構建新類。那麼,在 Serilog 中,為什麼要使用`LogEventPropertyValue`來儲存資料呢?我們先看下這個類有什麼。
```csharp
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`語句塊。
```csharp
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`為根類,若干不同渲染方法的繼承類`ScalarValue`、`SquenceValue`、`DictionaryValue`、`StructureValue`。明白了這點後,就可以明白`LogEventPropertyValue`所提供的函數了,其抽象函式`Render`就表示子類需要重寫的渲染邏輯。Serilog 將資料的渲染邏輯分成四大類:
+ `ScalarValue`類:該類的渲染邏輯是直接將資料的`ToString`方法的結果返回,適用於基礎資料型別和一些強制要求字串化的複雜資料(字串模板內以$開頭)。
+ `SqeuenceValue`類:該類渲染邏輯是將多個數據渲染到`[]`中,通常資料是一個數組或列表。
+ `DictionaryValue`類:鍵值對類物件的渲染邏輯,將資料渲染到`{}`中,它要求資料鍵(key)應該是`ScalarValue`。
+ `StructValue`類:將資料類解構,以公開的欄位或屬性名作為鍵值,進行渲染。
解決第一個問題後,再來看下第二個問題,作為各大渲染邏輯的基類,為什麼`LogEventProperty`沒有對資料的引用。我個人比較傾向於兩個方面來解釋。一是,沒有很方便的形式表達這個資料。我們知道四大 Value 類分別儲存不同的資料,不同的資料採用不同的形式,這就使得在基類中不能很好地指明資料的型別。另一個就是,對於這些 Value 的派生類,它們更關注的是渲染的結果,而不是儲存的資料,資料不是該資料結構中的重點,也就沒有必要在基類中指明資料。
從這個角度,我們就就可以著手檢視四個派生類的內容了。基本上,四個類保有不同的資料物件並重寫了相應的`Render`函式,提供不同的重寫邏輯。
```csharp
public class ScalarValue : LogEventPropertyValue
{
public oject Value { get; }
...
}
public class SquenceValue : LogEventPropertyValue
{
readonly LogEventPropertyValue[] _elements;
...
}
public class DictionaryValue : LogEventPropertyValue
{
public IReadonlyDictionary 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 的繫結邏輯也是差不多的,感興趣的可以直接檢視原始碼。
```csharp
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`物件,則有`PropertyValueConverter`的`CreatePropertyValue`方法進行構造。由此可見,`PropertyValueConverter`有點類似於工廠,指明當前訊息資料應構造什麼`LogEventPropertyValue`派生類。至於`PropertyValueConverter`類具體如何做到的,將留到下一篇再講解吧。
# 總結
本文對字串模板解析後的屬性 Token 與日誌資料的繫結做了大概的介紹。首先說明的是繫結最終得到了什麼結果,即`EventProperty`結構體以及`LogEventProperty`類。在這些結構體/類的內部,通過`LogEventPropertValue`儲存每一個日誌資料,該類是一個抽象類,不同的渲染方式有著不同的繼承類。之後,簡要描述了下繫結過程,即通過`PropertyBinder`將每一個具名屬性 Token 與對應的日誌資料物件繫結。然而,具體的繫結過程沒有進行交代,這也是下一篇文章的主要內容,即給定一個屬性 Token 與一個日誌物件,如何生成對應的`EventProperty`結