Skip to content
查看文章關聯網路

淺談 .NET 預設 Logger 及其優化技巧

範例專案

本文的可執行範例:CloudyWing/DotNetLoggingSample

紀錄等級

以下是根據 在 C# 和 .NET 中進行日誌記錄 所定義的各種等級及建議使用時機。其中 None 值最高,主要用於在 Filter 設定中完全停用所有 LOG。

等級方法描述
追蹤 (Trace)0LogTrace包含最詳細的訊息。這些訊息可能包含敏感性應用程式資料。這些訊息預設為停用,且不應在生產環境中啟用。
偵錯 (Debug)1LogDebug用於偵錯和開發。由於用量大,在生產環境中請小心使用。
資訊 (Information)2LogInformation追蹤一般應用程式流程。可能具有長期價值。
警告 (Warning)3LogWarning用於處理異常或意外事件。通常包含不會導致應用程式失敗的錯誤或狀況。
錯誤 (Error)4LogError無法處理的錯誤和例外情況。這些訊息指出目前作業或要求中發生失敗,而不是整個應用程式的失敗。
重要 (Critical)5LogCritical需要立即處理的故障。範例:資料遺失情況、磁碟空間不足。
None6指定不應該寫入任何訊息。

基本注入設定

WebApplication.CreateBuilder() 在建立時會自動設定預設的 Logger。查看 .NET 原始碼可以發現,在 HostingHostBuilderExtensions.csAddDefaultServices() 方法中,系統會自動設定基本的 Logger Provider:

csharp
// 來自 .NET 原始碼
services.AddLogging(logging => {
    bool isWindows =
#if NET
        OperatingSystem.IsWindows();
#elif NETFRAMEWORK
        Environment.OSVersion.Platform == PlatformID.Win32NT;
#else
        RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
#endif

    // IMPORTANT: This needs to be added *before* configuration is loaded, this lets
    // the defaults be overridden by the configuration.
    if (isWindows) {
        // Default the EventLogLoggerProvider to warning or above
        logging.AddFilter<EventLogLoggerProvider>(level => level >= LogLevel.Warning);
    }

    logging.AddConfiguration(hostingContext.Configuration.GetSection("Logging"));
#if NET
    if (!OperatingSystem.IsBrowser() && !OperatingSystem.IsWasi())
#endif
    {
        logging.AddConsole();
    }
    logging.AddDebug();
    logging.AddEventSourceLogger();

    if (isWindows) {
        // Add the EventLogLoggerProvider on windows machines
        logging.AddEventLog();
    }

    logging.Configure(options => {
        options.ActivityTrackingOptions =
            ActivityTrackingOptions.SpanId |
            ActivityTrackingOptions.TraceId |
            ActivityTrackingOptions.ParentId;
    });
});

這表示預設情況下,WebApplication.CreateBuilder() 已經為我們設定了下列 Logger Provider:

  • 從 Configuration 的 "Logging" 區段讀取設定。
  • Console Logger (輸出到控制台)
  • Debug Logger (輸出到偵錯工具)
  • EventSource Logger (用於 EventSource 追蹤)

實際使用時,我們可以透過以下方式自訂 Logger 設定:

csharp
// CreateBuilder 本身就會有預設設定
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

// 可以在 DI 方式調整增加的 LoggerProvider
builder.Services.AddLogging(logging => {
    logging.ClearProviders(); // 清除預設的 Provider
    logging.AddConsole();     // 加入 Console Provider
    logging.AddDebug();       // 加入 Debug Provider
});

// 也可以直接簡化成如下
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Logging.AddDebug();

程式注入 Logger 的方式

在 ASP.NET Core 中,Logger 通常透過依賴注入方式使用。以下是一個簡化的範例:

csharp
namespace LoggerTest.Controllers {
    [ApiController]
    [Route("api/[controller]")]
    public class UserController : ControllerBase {
        private readonly ILogger<UserController> logger;

        public UserController(ILogger<UserController> logger) {
            this.logger = logger;
            this.logger.LogInformation("UserController 已初始化");
        }
    }
}

使用 Appsettings.json 控制日誌等級

我們可以透過 appsettings.json 檔案來設定不同元件的日誌等級。以下是一個特殊情境的範例,使用多種類型的 Logger:

csharp
namespace LoggerTest.Controllers {
    [ApiController]
    [Route("api/[controller]")]
    public class TestController : Controller {
        public TestController(ILogger<TestService1> logger1, ILogger<TestService2> logger2) {
            logger1.LogInformation("TestController 已成功初始化並注入 Service1 的 Logger。");
            logger2.LogInformation("TestController 已成功初始化並注入 Service2 的 Logger。");
        }
    }
}

namespace LoggerTest.Services {
    public class TestService1 {
        public TestService1() {
        }

        public void TestMethod() {
            // Do something
        }
    }

    public class TestService2 {
        public TestService2() {
        }

        public void TestMethod() {
            // Do something
        }
    }
}

配合以下的 appsettings.json 設定:

json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning",
      "LoggerTest.Services.TestService1": "Warning"
    },
    "Debug": {
      "LogLevel": {
        "Default": "Information",
        "Microsoft.Hosting": "Trace"
      }
    },
    "EventSource": {
      "LogLevel": {
        "Default": "Warning"
      }
    }
  },
  "AllowedHosts": "*"
}

在這個設定檔中:

  • 外層的 "LogLevel" 設定適用於所有 Logger Provider。
  • "Debug" 和 "EventSource" 區段中的設定只適用於指定的 Provider。
  • "Default" 表示預設的最低日誌等級。
  • 特定命名空間如 "Microsoft.AspNetCore" 可以設定自己的日誌等級。

使用上述設定執行程式後,會發現只有 Service2 的日誌會被記錄,因為 Service1 的日誌等級被設為 Warning。

bash
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: https://localhost:7109
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5164
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
      Content root path: D:\Programming\Projects\LoggerTest\LoggerTest
info: LoggerTest.Services.TestService2[0]
      TestController 已成功初始化並注入 Service2 Logger。

使用萬用字元進行設定

如果想要針對多個命名空間套用相同設定,可以使用萬用字元 *。例如,"*.Services": "Warning" 會將所有以 .Services 結尾的命名空間日誌等級設為 Warning。

程式碼中的進階過濾設定

若需要更精細的控制,可以在程式碼中使用 Filter 進行設定:

csharp
// 根據命名空間過濾
builder.Logging.AddFilter("Microsoft", LogLevel.Warning);

// 根據提供者類型過濾
builder.Logging.AddFilter<ConsoleLoggerProvider>("Microsoft", LogLevel.Warning);
builder.Logging.AddFilter<DebugLoggerProvider>("Microsoft.AspNetCore.Mvc", LogLevel.Debug);

// 使用萬用字元
builder.Logging.AddFilter("Microsoft.*", LogLevel.Warning);
builder.Logging.AddFilter("*.Repository", LogLevel.Debug);

// 針對特定類別的日誌器
builder.Logging.AddFilter(typeof(Program).FullName, LogLevel.Debug);

// 使用更精細的控制 - 使用函數型過濾器
builder.Logging.AddFilter((provider, category, logLevel) => {
    // 在工作日顯示更詳細的日誌
    if (DateTime.Now.DayOfWeek != DayOfWeek.Saturday
        && DateTime.Now.DayOfWeek != DayOfWeek.Sunday) {
        return logLevel >= LogLevel.Debug;
    }

    // 週末只顯示警告及以上等級的日誌
    return logLevel >= LogLevel.Warning;
});

使用 Logger.IsEnabled 提高效能

在需要執行昂貴操作來產生日誌訊息的情況下,我們可以先檢查特定日誌等級是否已啟用,以避免不必要的運算:

csharp
if (logger.IsEnabled(LogLevel.Information)) {
    // 這裡可能會執行較耗資源的操作,例如從資料庫查詢或複雜計算
    int processedRecords = await database.GetProcessedRecordsCount();
    logger.LogInformation("系統已完成資料更新,共處理 {Count} 筆資料。", processedRecords);
}

這樣做的好處是,當設定檔將日誌等級設為 Warning 或更高時,我們可以完全跳過這些耗資源的操作,但需要注意當變更 Log 等級時,要記得同時更改 IsEnabled 的日誌等級。

結構化日誌的優勢與使用

結構化日誌的主要好處

  1. 效能優勢:結構化日誌最大的優勢在於避免不必要的字串串接操作。Logger 只會在實際需要寫入日誌時才執行字串組合,這就是所謂的「遞延執行」。當日誌等級設定為不記錄某些層級時,相關的字串處理完全不會執行,從而提升應用程式效能。

  2. 資料整合便利性:生成的日誌可以輕鬆被 Elasticsearch、Kibana、Logstash (ELK) 等工具解析,便於集中式日誌管理。

結構化日誌的使用方式

csharp
// Visual Studio 和 .NET 分析器會透過 CA2254 警告提醒避免以下寫法
logger.LogInformation(
    "使用者 " + user.Id + " 已登入系統,所屬部門: " + user.Department
);

// 正確的結構化日誌寫法
logger.LogInformation(
    "使用者 {UserId} 已登入系統,所屬部門: {Department}",
    user.Id,
    user.Department
);

大括號的特殊用法

如果你需要在日誌訊息中包含大括號字元 {},需要使用雙大括號 來進行轉義。例如:

csharp
// 如果想輸出含有大括號的文字,需要用雙大括號
logger.LogInformation("這是一個 JSON 範例:{{\"name\": \"value\"}}");

// 輸出結果為:這是一個 JSON 範例:{"name": "value"}

// 注意:{XXX} 中的 XXX 是作為變數佔位符使用,與傳入的參數位置相關
// 不是根據參數名稱對應,而是根據參數順序
logger.LogInformation("名稱: {Name}, 年齡: {Age}", "小明", 25);
// 輸出:名稱: 小明, 年齡: 25

使用 JSON 格式輸出

透過將 AddConsole() 替換成 AddJsonConsole(),我們可以獲得結構化的 JSON 輸出:

csharp
builder.Logging.AddConsole();
// 改為
builder.Logging.AddJsonConsole();

產生的 JSON 日誌格式如下(為了閱讀方便,有進行排版):

json
{
    "EventId": 0,
    "LogLevel": "Information",
    "Category": "LoggerTest.Controllers.TestController",
    "Message": "使用者 U12345 已登入系統,所屬部門: 研發部",
    "State": {
        "UserId": "U12345",
        "Department": "研發部",
        "{OriginalFormat}": "使用者 {UserId} 已登入系統,所屬部門: {Department}"
    },
    "Timestamp": "2025-03-23T15:30:00.123Z"
}

這種結構化的 JSON 格式特別適合與 ELK 等日誌分析系統整合,讓我們能更有效地搜尋、過濾和分析日誌。這也是為什麼近年來 Serilog 逐漸取代 NLog 成為 .NET 日誌框架首選的主要原因之一。

使用 LoggerMessage.Define

前面提到的結構化日誌雖然避免了字串串接,但執行時還是有兩個固定成本沒解決:

  • 傳入的參數會被收進 object[],如果是 Value Type(例如 intGuid),會在這一步發生 Boxing。
  • Message 模板的解析是在執行期才會進行。雖然 Microsoft.Extensions.Logging 內部會用 ConcurrentDictionary 快取 LogValuesFormatter 避免每次都要重新解析,但呼叫時仍要走一次 dictionary lookup,並建立一個 FormattedLogValues 物件。

針對這兩個成本,.NET Core 從早期就提供了 LoggerMessage.Define<T1, T2, ...> API。它使用泛型方法回傳一個強型別委派 Action<ILogger, T1, T2, ..., Exception?>,傳入的參數都透過泛型型別參數承載,不會被收進 object[],自然也就不會發生 Boxing;Message 模板則在 Define 呼叫的當下就解析完成,回傳的委派內部直接套用,後續每次呼叫不需要再走執行期 lookup。

程式碼範例:

csharp
public class UserService {
    private readonly ILogger<UserService> logger;

    private static readonly Action<ILogger, string, string, Exception?> LogUserLoggedIn =
        LoggerMessage.Define<string, string>(
            LogLevel.Information,
            new EventId(1001, nameof(LogUserLoggedIn)),
            "使用者 {UserId} 已登入系統,所屬部門: {Department}"
        );

    public UserService(ILogger<UserService> logger) {
        this.logger = logger;
    }

    public void Login(User user) {
        LogUserLoggedIn(logger, user.Id, user.Department, null);
    }
}

兩種寫法的成本邊界:

項目傳統 Log / LogInformationLoggerMessage.Define
呼叫端 object[] 配置每次呼叫都配置✅ 不配置
呼叫端 Value Type Boxing✅ 消除
Message 模板處理執行期 ConcurrentDictionary lookup + 建立 FormattedLogValues✅ Define 時解析一次
輸出端讀取參數的 Boxing仍會發生

下圖比較兩種寫法的呼叫端處理流程:

圖表

使用 LoggerMessage Source Generator

LoggerMessage.Define 雖然解掉了呼叫端的 Boxing 成本,但寫起來偏冗長,每筆訊息都要單獨維護一個 static 委派欄位、EventId 與型別參數;之後要新增或調整 placeholder 時,還得同步修改 Define<...> 的泛型參數、委派型別,以及呼叫端的傳參。為了把這段樣板自動化,.NET 6 提供了 [LoggerMessage] 這顆語法糖。

使用 [LoggerMessage] 時,只需要把方法宣告為 partial method 並加上 attribute,編譯期 Source Generator 就會自動補上對應的靜態委派、EventId、IsEnabled 檢查與呼叫鏈。寫法比 Define 簡潔,雖然比直接呼叫 Log / LogInformation 仍多了 partial method 宣告。

基本範例

LoggerMessage 需宣告為 partial method,由 Source Generator 在編譯期補上實作。Source Generator 取得 ILogger 的方式有三種:

  • 模式一:方法沒有 ILogger 參數,Source Generator 會存取所屬類別中的 ILogger 欄位或屬性作為日誌來源(要求類別內恰好只有一個 ILogger 成員)。
  • 模式二:方法有傳入 ILogger 參數,但不是擴充方法。適用於類別內有多個 ILogger、或想由呼叫端決定使用哪個 logger 的情境。
  • 模式三:方法是擴充方法(static,第一個參數為 this ILogger)。放在獨立靜態類別內,可跨類別共用同一段日誌邏輯。

模式一:方法沒有 ILogger 參數,自動使用類別欄位

csharp
public partial class UserService {
    private readonly ILogger<UserService> logger;

    public UserService(ILogger<UserService> logger) {
        this.logger = logger;
    }

    public void Login(User user) {
        LogUserLoggedIn(user.Id, user.Department);
    }

    [LoggerMessage(
        EventId = 1001,
        Level = LogLevel.Information,
        Message = "使用者 {UserId} 已登入系統,所屬部門: {Department}"
    )]
    private partial void LogUserLoggedIn(string userId, string department);
}

模式二:方法傳入 ILogger 參數,方法不是擴充方法

csharp
public partial class OrderService {
    private readonly ILogger<OrderService> auditLogger;
    private readonly ILogger<OrderService> generalLogger;

    public OrderService(
        ILogger<OrderService> auditLogger,
        ILogger<OrderService> generalLogger) {
        this.auditLogger = auditLogger;
        this.generalLogger = generalLogger;
    }

    public void ProcessOrder(Order order) {
        // 由呼叫端決定要用哪個 logger
        LogOrderProcessed(auditLogger, order.Id);
    }

    [LoggerMessage(
        EventId = 2001,
        Level = LogLevel.Information,
        Message = "訂單 {OrderId} 已處理"
    )]
    private partial void LogOrderProcessed(ILogger logger, string orderId);
}

模式三:方法是擴充方法,跨類別共用

csharp
internal static partial class LoggerExtensions {
    [LoggerMessage(
        EventId = 3001,
        Level = LogLevel.Information,
        Message = "使用者 {UserId} 已登入系統"
    )]
    public static partial void LogUserLoggedIn(this ILogger logger, string userId);
}

partial method 命名建議

  • 沿用 Log 開頭的動詞短句,描述「發生了什麼事」,例如 LogUserLoggedInLogOrderCreatedLogPaymentFailed
  • 不要把 LogLevel 編進方法名稱(如 LogInformationUserLoggedIn),等級由 Attribute 的 Level 控制,命名聚焦事件語意。
  • 參數名稱與 Message 的 {Placeholder} 一致,保持結構化欄位名稱可預期。

展開後大概長這樣

Source Generator 會為每個 [LoggerMessage] 標註的 partial method 補上實作:先在方法內部加上 IsEnabled 檢查,再用一個強型別的 state struct 呼叫 logger.Log<TState>;同時為每個方法產生一個專屬的 state struct。前面提到的三種模式,partial method 主體差別只在「ILogger 從哪裡取得」,state struct 的結構則完全相同。

以模式一為例,編譯後產生的程式碼大致如下(IEnumerable 樣板與建構子指派已省略):

csharp
private partial void LogUserLoggedIn(string userId, string department)
{
    // 等級不符就直接 return,避免後續 state 配置
    if (!logger.IsEnabled(LogLevel.Information)) return;

    // 強型別 state 直接傳給泛型 Log<TState>,呼叫路徑上沒有 object[]
    logger.Log(
        LogLevel.Information,
        new EventId(1001, nameof(LogUserLoggedIn)),
        new __State(userId, department),
        exception: null,
        __State.Format);
}

private readonly struct __State : IReadOnlyList<KeyValuePair<string, object?>>
{
    // 欄位以原始型別承載參數
    public readonly string _userId;
    public readonly string _department;
    // 建構子省略:把建構參數逐一指派給上面欄位

    // 模板在編譯期就組好,static readonly 委派整個程式只配置一次
    public static readonly Func<__State, Exception?, string> Format =
        (s, _) => $"使用者 {s._userId} 已登入系統,所屬部門: {s._department}";

    // 編譯期決定的 placeholder 對應,供 Provider 讀取結構化欄位
    public KeyValuePair<string, object?> this[int index] => index switch
    {
        0 => new("UserId", _userId),
        1 => new("Department", _department),
        2 => new("{OriginalFormat}", "使用者 {UserId} 已登入系統,所屬部門: {Department}"),
        _ => throw new ArgumentOutOfRangeException(nameof(index))
    };

    public int Count => 3;

    // GetEnumerator 等 IEnumerable 樣板省略
}

這裡的 __State 角色相當於 Define 回傳委派內部的 state:欄位都以強型別承載、呼叫路徑上沒有 object[] 配置、Message 模板也在編譯期就解析完成。差別在於,Define 用泛型方法產一個 Action<...> 委派,Source Generator 則是用自訂 struct 配上 formatter 委派處理,兩者避免 Boxing 的原理是一樣的。

從展開的程式碼也可以看到,[LoggerMessage] 已經在 partial method 內部加了 IsEnabled 檢查。所以遇到比較吃資源的日誌呼叫時,不需要再額外用 IsEnabled 把它包起來;等級不符時,後續的委派呼叫與 state 建立都不會執行,也避免了 IsEnabled 與日誌等級不一致的可能。

異動歷程

    • 初版文件建立。
    • 新增「使用 LoggerMessage.Define」與「使用 LoggerMessage Source Generator」章節,並加入範例專案連結。