Serilog 原始碼解析——Sink 的實現
阿新 • • 發佈:2020-11-09
在[上一篇](https://www.cnblogs.com/p/usage-and-structure-of-serilog)中,我們簡單地查看了 Serilog 的整體需求和大體結構。從這一篇開始,本文開始涉及 Serilog 內的相關實現,著重解決第一個問題,即 Serilog 向哪裡寫入日誌資料的。([系列目錄](https://www.cnblogs.com/iskcal/p/introduction-to-the-source-code-of-serilog.html#目錄))
## 基礎功能
在開始看 Serilog 怎麼將日誌記錄到 Sinks 之前,先看下整體框架。首先,我們需要了解 Serilog 中最常用的一個介面`ILogger`,它提供了對外記錄日誌的所有功能 API 方法。
### `ILogger`(核心介面)
在 Serilog 根目錄下,儲存有 4 個程式碼檔案。類似於 LogDemo,`ILogger`內包含各種功能API方法,`LogConfiguration`用於構建對應的`ILogger`物件。另外,`LogExtensions`是向`ILogger`中新增新方法,不是`LogConfiguration`。
。
為了方便,我們首先看如何使用,在理解完使用方法,再回過頭來看怎麼建立。首先是`ILogger`, 它提供了大量的使用方法,按照功能主要分成以下三類。
| 方法名 | 說明 |
| ---- | ---- |
| ForContext系列 | 構造子日誌記錄物件,並新增額外資料 |
| Write系列,XXX(日誌等級名)系列 | 日誌記錄功能 |
| BindXXX 系列 | 輸出模板、屬性繫結相關 |
這裡面的方法,對我們而言,第二類方法是用的最多地,我們就先看 Serilog 是如何記錄日誌的吧。
### `Log`(靜態方法類)
這是一個靜態類,可以看到內部本質上是對`ILogger`的進一步包裝,並將所有API方法暴露出來,如下。
```csharp
public static class Log
{
static ILogger _logger = SilentLogger.Instance;
public static Logger
{
get => _logger;
set => _logger = value ?? throw ...
}
public static void Write(LogEventLevel level, string messageTemplate)
...
}
```
順帶提一句,類庫中的`SilentLogger`類是對`ILogger`的一個空實現,它可以看成是一個具有呼叫功能的空類。
在瞭解到了最為核心的`ILogger`介面後,接下來需要了解的是描述日誌事件的`LogEvent`類,該類在 Events 資料夾下,其作為`Write`的輸入引數,可以將其想象成LogDemo中的`LogData`類,只不過它包含了更多的資料資訊。另外,`LogEventLevel`是一個列舉,同樣位於 Events 資料夾下,該類的內容和 LogDemo 中的`LogLevel`完全一致。
### `LogEvent`(日誌事件類)
在 Serilog 中,每當我們發生一次日誌記錄的行為時,Serilog 都將其封裝到一個類中方便使用,即`LogEvent`類。和 LogDemo 中的`LogData`一樣,`LogEvent`類包含一些描述日誌事件的資料。
```csharp
public class LogEvent
{
public DateTimeOffset Timestamp { get; }
public LogEventLevel Level { get; }
public Exception Exception { get; }
public MessageTemplate MessageTemplate { get; }
private readonly Dictionary _properties;
internal LogEvent Copy()
{
...
}
}
```
可以看到,在LogEvent中,有若干欄位和屬性描述一個日誌事件。`Timestamp`屬性描述日誌記錄的時間,採用`DateTimeOffset`這一型別可以統一不同時區下的伺服器時間點,確保時間上的統一。`Level`就不用多說,描述日誌的等級。`Exception`屬性可以儲存任意異常類資料,該屬性常用在 Error 和 Fatal 等級中,需要儲存異常資訊時使用。至於後續的`MessageTemplate`和`LogEventPropertyValue`,從字面意義上看,屬於字串訊息模板和記錄資料時所用到,目前我們主力研究記錄到 Sink 的處理邏輯,故這兩塊暫時不關心。
此外,在`LogEvent`類中,有一個很特別的函式,名為`Copy`函式,這個函式是根據當前`LogEvent`物件複製出了一個相同的`LogEvent`物件。這個方法可以看成是設計模式中原型模式的一種實現,只不過這個類沒有利用`IClonable`介面來實現。
# Core 目錄下的功能類
### `ILogEventSink`介面
在 LogDemo 中,我們通過`ILogTarget`介面定義不同的日誌記錄目的地。類似地,在 Serilog 中,所有的 Sink 通過`ILogEventSink`定義統一的日誌記錄介面。該介面如下所示。
```csharp
public interface ILogEventSink
{
void Emit(LogEvent logEvent);
}
```
該介面形式簡單,只有一個函式,輸入引數為`LogEvent`物件,無返回值,這一點和 LogDemo 中的`ILogTarget`介面很像。如果想實現一個 ConsoleSink,只需要將繼承該介面並將`LogEvent`物件字串資料寫入到`Console`即可。實際上,在 Serilog.Sinks.Console 中其核心功能就是這麼實現的。
### `Logger`類
`Logger`類是對`ILogger`介面的預設實現。類似於 LogDemo 中的`Logger`,該類給所有日誌記錄的使用提供了 API 方法。考慮到本篇只關心日誌向哪裡寫入的。因此,我們只關心其內部的部分欄位屬性和方法。
```csharp
public sealed class Logger : ILogger, ILogEventSink, IDisposable
{
readonly ILogEventSink _sink;
readonly Action _dispose;
readonly LogEventLevel _minimumLevel;
// 361行到375行
public void Write(LogEventLevel level, Exception exception, string messageTemplate, params object[] propertyValues)
{
if (!IsEnabled(level)) return;
if (messageTemplate == null) return;
if (propertyValues != null && propertyValues.GetType() != typeof(object[]))
propertyValues = new object[] {propertyValues};
// 解析日誌模板
_messageTemplateProcessor.Process(messageTemplate, propertyValues, out var parsedTemplate, out var boundProperties);
// 構造日誌事件物件
var logEvent = new LogEvent(DateTimeOffset.Now, level, exception, parsedTemplate, boundProperties);
// 將日誌事件分發出去
Dispatch(logEvent);
}
public void Dispatch(LogEvent logEvent)
{
...
// 將日誌事件交給Sink進行記錄
_sink.Emit(logEvent);
}
}
```
考慮到篇幅,這裡我去掉了部分和當前功能無關的程式碼,只保留最為核心的程式碼。
1. 首先,我們看下繼承關係,`Logger`類除繼承`ILogger`之外,還繼承`ILogEventSink`介面,這個繼承關係看起來很奇怪,但細想也覺得正常,一個日誌記錄器不光可以當日志事件的發生器,也可以當其接收器。換而言之,可以將一條日誌事件寫到另一個日誌記錄器中,由另一個日誌記錄器記錄到其他 Sinks 中。此外,該類還繼承了`IDisposable`介面,按照邏輯需求來講,`Logger`是沒有東西需要釋放的,其需要釋放的通常是內部包含的一些物件,比如說 FileSink 如果長時間維持一個檔案控制代碼的話,則需要在`Logger`回收後被動釋放,因此,這導致了`Logger`需要維護一組待釋放的物件進行釋放。在`Logger`內部中,通過新增`Action`函式鉤子的方式進行釋放。
2. 之後,我們會發現所有的寫入日誌方法直接或間接地呼叫上面給出的Write方法。在該方法的邏輯中,第一行用來判斷日誌的等級是否滿足條件,也就是一類全域性的過濾條件,第二行則是判斷是否給出日誌的輸出模板。隨後`_messageTemplateProcessor`看這個意思是解析模板和資料(暫且不明,不過多關注)。再往下,則是構造對應的`LogEvent`物件。最後通過`Dispatch`方法將日誌分發到`ILogEventSink`。在`Dispatch`中,前半部分邏輯和本篇關係不大,最後通過`ILogEventSink`將日誌訊息傳送出去。
看到這裡,可能會有人好奇一點,`Logger`應該擁有一組`ILogEventSink`物件才對,這樣才能夠實現一次向多個 Sink 中寫入日誌資訊,但`Logger`只維護一個`ILogEventSink`物件,它是怎麼做到一次向多個 Sink 中寫入日誌的呢?我們接著往下看。
# 功能性 Sink
在 Serilog 的 ./Core/Sinks 資料夾中可以發現,這裡面有非常多的`ILogEventSink`的實現類。這些實現類都不是向具體的媒介(控制檯、檔案等)寫入日誌,反而,他們都是給其他的Sink擴充套件新功能,典型裝飾模式的一種實現。在這個資料夾下,我把部分核心功能摘錄出來,如下。(v2.10.0又添加了一些其他的裝飾類,這裡就不過多說明了)。
```csharp
class ConditionalSink : ILogEventSink
{
readonly ILogEventSink _warpped;
readonly Func _condition;
...
public void Emit(LogEvent logEvent)
{
if (_condition(logEvent)) _wrapped.Emit(logEvent);
}
...
}
```
`ConditionalSink`功能非常簡單,它也包含了一個`ILogEventSink`物件,此外,還包含一個`Func`的泛型委託。這個委託可以按照`LogEvent`物件滿足某種指定要求做過濾。從Emit函式內可以看出,只有在滿足條件時才會將日誌事件傳送到對應的 Sink 中。它可以看成是帶有條件寫入的 Sink,這一點和也就是區域性過濾功能實現的核心之處。
```csharp
public interface ILogEventFilter
{
bool IsEnabled(LogEvent logEvent);
}
```
`FilteringSink`所作的事情和`ConditiaonalSink`一樣,除了 Sink 物件外,它還維護了一組`ILogEventFilter`陣列用來指定多個日誌過濾條件,而`ILogEventFilter`介面如上所示,其內部就是按日誌物件進行過濾。而`RestrictedSink`內除`ILogEventSink`物件外,還有一個`LoggingLevelSwitch`物件,這個物件用來描述日誌記錄器能夠記錄的最小日誌等級,所以`RestrictedSink`所實現的是依照日誌等級的比較判斷是否輸出日誌。
```csharp
sealed class SecondaryLoggerSink : ILogEventSink
{
readonly ILogger _logger;
readonly bool _attemptDispose;
...
public void Emit(LogEvent logEvent)
{
...
var copy = logEvent.Copy();
_logger.Write(copy);
}
}
```
和上述其他的`ILogEventSink`的繼承類相比,`SecondaryLoggerSink`在其內部並沒有保留對某個`ILogEventSink`的引用。相反,它保留對給定的`ILogger`物件的引用,這種好處是我們可以讓一個日誌記錄器作為另一個日誌記錄的`Sink`。該類另外的一個變數`_attemptDispose`表示該類是否需要執行內部`ILogger`物件的釋放,之所以這樣做是因為有的時候`Logger`物件並不一定需要釋放,通常由父日誌記錄器所創建出來的子日誌記錄器不需要釋放,其資源釋放可以由父日誌記錄器進行管理。
```csharp
class SafeAggregateSink : ILogEventSink
{
readonly ILogEventSink[] _sinks;
...
public void Emit(LogEvent logEvent)
{
foreach (var sink in _sinks)
{
...
sink.Emit(logEvent);
...
}
}
}
```
除此之外,還剩下`AggregrateSink`和`SafeAggregrateSink`這兩個 Sink 也繼承`ILogEventSink`介面,且內部都引用了`ILogEventSink`陣列,且在`Emit`函式中基本都是對陣列內的`ILogEventSink`物件遍歷,並呼叫這些物件內的`Emit`函式。二者均在`Emit`函式內將所有異常捕捉起來,但`AggregateSink`會在捕捉後將這些異常以`AggreateException`異常再次丟擲。這兩個類與之前的類不同,它們將多個 Sink 集合起來,讓外界仍以單一的 Sink 來使用。其好處在於,`Logger`的設計者不需要關注到底有一個還是多個 Sink,如果有多個 Sink,只需要用這兩個類將多個 Sink 包裹起來,外界將這一組 Sink 當成一個 Sink 來使用。
為什麼要這樣設計?實際上,對`Logger`類來說,它並不需要關心記錄的 Sink 有一個還是多個,是什麼樣的狀態,達到什麼樣的條件才能記錄,畢竟這些都非常的複雜。對於`Logger`來講,它要做的只有一件事,只要將日誌事件向`ILogEventSink`物件中發出即可。為達到這樣的目的,Serilog 利用設計模式中的裝飾模式和組合模式來降低`Logger`的設計負擔。主要體現在兩個方面。
1. 通過裝飾模式實現帶有複雜功能的 Sink,通常通過繼承`ILogEventSink`並內部保有一個`ILogEventSink`物件來進行功能擴充套件,前面所提到的`ConditionalSink`、`FilteringSink`、`RestrictedSink`等都屬於帶有擴充套件功能的Sink,可以看到,其建構函式均需要外界提供額外的`ILogEventSink`物件。 此外,這些裝飾類還可以巢狀,即一個裝飾類可以擁有另一個裝飾類物件,實現功能的聚合。
2. 通過組合模式將一組 Sink 以單一 Sink 物件的方式暴露出來,`AggregrateSink`和`SafeAggregrateSink`做的就是這件事。就算`Logger`需要將日誌記錄到多個Sink中,從`Logger`的角度來看,它也只是寫入到一個`ILogEventSink`物件中,這讓`Logger`設計者不需要為了到底是一個還是多個 Sink 而頭疼。舉個例子,假如你有一個 ConsoleSink,它的作用是將日誌輸出到控制檯,以及一個將日誌輸出到檔案的 FileSink。如果想利用`Logger`物件將日誌同時輸出到控制檯和檔案,我們只需要構建一個`AggregateSink`並將 ConsoleSink 和 FileSink 物件放置到其內部的陣列中,再將`AggregrateSink`作為`Logger`中的`ILogEventSink`的物件,那麼`Logger`能自動將日誌分別記錄到這兩個地方。
# 總結
以上就是整個 Sink 功能的說明,可以看到的是,這塊和之前提到的 LogDemo 專案非常的像。我相信如果在之前對 LogDemo 能夠理解的人在這塊能夠找到非常熟悉的感覺。從下一篇開始,我將開始揭露 Serilog 是如何將 LogEvent 這樣的日誌事件轉換成最終寫入到各個Sink中的字串信