编译时日志记录源生成

.NET 6 引入了 LoggerMessageAttribute 类型。 此属性是 Microsoft.Extensions.Logging 命名空间的一部分,使用时,它会以源生成的方式生成高性能的日志记录 API。 源生成日志记录支持旨在为新式 .NET 应用程序提供高度可用且高性能的日志记录解决方案。 自动生成的源代码依赖于 ILogger 接口和 LoggerMessage.Define 功能。

partial 日志记录方法上使用 LoggerMessageAttribute 时,系统会触发源生成器。 触发后,它既可以自动生成其修饰的 partial 方法的实现,也可以生成包含正确用法提示的编译时诊断。 与现有的日志记录方法相比,编译时日志记录解决方案在运行时通常要快得多。 这是因为它最大限度地消除了装箱、临时分配和副本。

基本用法

若要使用 LoggerMessageAttribute,使用的类和方法必须为 partial。 代码生成器在编译时触发,并生成 partial 方法的实现。

public static partial class Log
{
    [LoggerMessage(
        EventId = 0,
        Level = LogLevel.Critical,
        Message = "Could not open socket to `{HostName}`")]
    public static partial void CouldNotOpenSocket(
        ILogger logger, string hostName);
}

在上面的示例中,日志记录方法为 static,日志级别在属性定义中指定。 在静态上下文中使用特性时,需要 ILogger 实例作为参数,或者修改定义以使用 this 关键字将方法定义为扩展方法。

public static partial class Log
{
    [LoggerMessage(
        EventId = 0,
        Level = LogLevel.Critical,
        Message = "Could not open socket to `{HostName}`")]
    public static partial void CouldNotOpenSocket(
        this ILogger logger, string hostName);
}

你也可以选择在非静态上下文中使用该属性。 请参考以下示例,其中日志记录方法声明为实例方法。 在此上下文中,日志记录方法通过访问包含类中的 ILogger 字段来获取记录器。

public partial class InstanceLoggingExample
{
    private readonly ILogger _logger;

    public InstanceLoggingExample(ILogger logger)
    {
        _logger = logger;
    }

    [LoggerMessage(
        EventId = 0,
        Level = LogLevel.Critical,
        Message = "Could not open socket to `{HostName}`")]
    public partial void CouldNotOpenSocket(string hostName);
}

从 .NET 9 开始,日志记录方法还可以从包含类中的 ILogger 主构造函数参数获取记录器。

public partial class InstanceLoggingExample(ILogger logger)
{
    [LoggerMessage(
        EventId = 0,
        Level = LogLevel.Critical,
        Message = "Could not open socket to `{HostName}`")]
    public partial void CouldNotOpenSocket(string hostName);
}

如果同时存在 ILogger 字段和主构造函数参数,则日志记录方法将从该字段获取记录器。

有时,日志级别必须是动态的,而不是静态内置于代码中。 为此,可以省略该属性中的日志级别,改为要求将它作为日志记录方法的参数。

public static partial class Log
{
    [LoggerMessage(
        EventId = 0,
        Message = "Could not open socket to `{HostName}`")]
    public static partial void CouldNotOpenSocket(
        ILogger logger,
        LogLevel level, /* Dynamic log level as parameter, rather than defined in attribute. */
        string hostName);
}

你可以省略日志记录消息,系统将为该消息提供 String.Empty。 状态将包含格式化为键值对的参数。

using System.Text.Json;
using Microsoft.Extensions.Logging;

using ILoggerFactory loggerFactory = LoggerFactory.Create(
    builder =>
    builder.AddJsonConsole(
        options =>
        options.JsonWriterOptions = new JsonWriterOptions()
        {
            Indented = true
        }));

ILogger<SampleObject> logger = loggerFactory.CreateLogger<SampleObject>();
logger.PlaceOfResidence(logLevel: LogLevel.Information, name: "Liana", city: "Seattle");

readonly file record struct SampleObject { }

public static partial class Log
{
    [LoggerMessage(EventId = 23, Message = "{Name} lives in {City}.")]
    public static partial void PlaceOfResidence(
        this ILogger logger,
        LogLevel logLevel,
        string name,
        string city);
}

请参考使用 JsonConsole 格式化程序时的日志记录输出示例。

{
  "EventId": 23,
  "LogLevel": "Information",
  "Category": "\u003CProgram\u003EF...9CB42__SampleObject",
  "Message": "Liana lives in Seattle.",
  "State": {
    "Message": "Liana lives in Seattle.",
    "name": "Liana",
    "city": "Seattle",
    "{OriginalFormat}": "{Name} lives in {City}."
  }
}

日志方法约束

在日志记录方法上使用 LoggerMessageAttribute 时,必须遵循一些约束:

  • 日志记录方法必须为 partial 并返回 void
  • 日志记录方法名称不得以下划线开头。
  • 日志记录方法的参数名称不得以下划线开头。
  • 日志记录方法不得在嵌套类型中定义。
  • 日志记录方法不能是泛型方法。
  • 如果日志记录方法是 static,则需要 ILogger 实例作为参数。

代码生成模型依赖于使用新式 C# 编译器 9 或更高版本编译的代码。 .NET 5 提供了 C# 9.0 编译器。 若要升级到新式 C# 编译器,请编辑项目文件以面向 C# 9.0。

<PropertyGroup>
  <LangVersion>9.0</LangVersion>
</PropertyGroup>

有关详细信息,请参阅 C# 语言版本控制

日志方法剖析

ILogger.Log 签名接受 LogLevel,还可以接受 Exception,如下所示。

public interface ILogger
{
    void Log<TState>(
        Microsoft.Extensions.Logging.LogLevel logLevel,
        Microsoft.Extensions.Logging.EventId eventId,
        TState state,
        System.Exception? exception,
        Func<TState, System.Exception?, string> formatter);
}

通常,在源生成器的日志方法签名中,ILoggerLogLevelException 的第一个实例将得到特殊处理。 后续实例则被当作消息模板的正常参数进行处理:

// This is a valid attribute usage
[LoggerMessage(
    EventId = 110, Level = LogLevel.Debug, Message = "M1 {Ex3} {Ex2}")]
public static partial void ValidLogMethod(
    ILogger logger,
    Exception ex,
    Exception ex2,
    Exception ex3);

// This causes a warning
[LoggerMessage(
    EventId = 0, Level = LogLevel.Debug, Message = "M1 {Ex} {Ex2}")]
public static partial void WarningLogMethod(
    ILogger logger,
    Exception ex,
    Exception ex2);

重要

发出的警告提供有关 LoggerMessageAttribute 的正确用法的详细信息。 在上面的示例中,WarningLogMethod 将报告 SYSLIB0025DiagnosticSeverity.Warning

Don't include a template for `ex` in the logging message since it is implicitly taken care of.

不区分大小写的模板名称支持

生成器会在消息模板中的项与日志消息中的参数名称之间进行不区分大小写的比较。 这意味着当 ILogger 枚举状态时,消息模板会获取参数,这可以使日志更好使用:

public partial class LoggingExample
{
    private readonly ILogger _logger;

    public LoggingExample(ILogger logger)
    {
        _logger = logger;
    }

    [LoggerMessage(
        EventId = 10,
        Level = LogLevel.Information,
        Message = "Welcome to {City} {Province}!")]
    public partial void LogMethodSupportsPascalCasingOfNames(
        string city, string province);

    public void TestLogging()
    {
        LogMethodSupportsPascalCasingOfNames("Vancouver", "BC");
    }
}

请参考使用 JsonConsole 格式化程序时的日志记录输出示例:

{
  "EventId": 13,
  "LogLevel": "Information",
  "Category": "LoggingExample",
  "Message": "Welcome to Vancouver BC!",
  "State": {
    "Message": "Welcome to Vancouver BC!",
    "City": "Vancouver",
    "Province": "BC",
    "{OriginalFormat}": "Welcome to {City} {Province}!"
  }
}

不确定的参数顺序

日志方法参数的排序没有约束。 开发人员可以将 ILogger 定义为最后一个参数,尽管看起来可能有些突兀。

[LoggerMessage(
    EventId = 110,
    Level = LogLevel.Debug,
    Message = "M1 {Ex3} {Ex2}")]
static partial void LogMethod(
    Exception ex,
    Exception ex2,
    Exception ex3,
    ILogger logger);

提示

日志方法上参数的顺序不需要与模板占位符的顺序相对应。 而模板中的占位符名称应与参数匹配。 请参考以下 JsonConsole 输出和错误顺序。

{
  "EventId": 110,
  "LogLevel": "Debug",
  "Category": "ConsoleApp.Program",
  "Message": "M1 System.Exception: Third time's the charm. System.Exception: This is the second error.",
  "State": {
    "Message": "M1 System.Exception: Third time's the charm. System.Exception: This is the second error.",
    "ex2": "System.Exception: This is the second error.",
    "ex3": "System.Exception: Third time's the charm.",
    "{OriginalFormat}": "M1 {Ex3} {Ex2}"
  }
}

其他日志记录示例

下面的示例演示如何检索事件名称、动态设置日志级别以及设置日志记录参数的格式。 日志记录方法包括:

  • LogWithCustomEventName:通过 LoggerMessage 属性检索事件名称。
  • LogWithDynamicLogLevel:动态设置日志级别,以允许基于配置输入设置日志级别。
  • UsingFormatSpecifier:使用格式说明符来格式化日志记录参数。
public partial class LoggingSample
{
    private readonly ILogger _logger;

    public LoggingSample(ILogger logger)
    {
        _logger = logger;
    }

    [LoggerMessage(
        EventId = 20,
        Level = LogLevel.Critical,
        Message = "Value is {Value:E}")]
    public static partial void UsingFormatSpecifier(
        ILogger logger, double value);

    [LoggerMessage(
        EventId = 9,
        Level = LogLevel.Trace,
        Message = "Fixed message",
        EventName = "CustomEventName")]
    public partial void LogWithCustomEventName();

    [LoggerMessage(
        EventId = 10,
        Message = "Welcome to {City} {Province}!")]
    public partial void LogWithDynamicLogLevel(
        string city, LogLevel level, string province);

    public void TestLogging()
    {
        LogWithCustomEventName();

        LogWithDynamicLogLevel("Vancouver", LogLevel.Warning, "BC");
        LogWithDynamicLogLevel("Vancouver", LogLevel.Information, "BC");

        UsingFormatSpecifier(logger, 12345.6789);
    }
}

请参考使用 SimpleConsole 格式化程序时的日志记录输出示例:

trce: LoggingExample[9]
      Fixed message
warn: LoggingExample[10]
      Welcome to Vancouver BC!
info: LoggingExample[10]
      Welcome to Vancouver BC!
crit: LoggingExample[20]
      Value is 1.234568E+004

请参考使用 JsonConsole 格式化程序时的日志记录输出示例:

{
  "EventId": 9,
  "LogLevel": "Trace",
  "Category": "LoggingExample",
  "Message": "Fixed message",
  "State": {
    "Message": "Fixed message",
    "{OriginalFormat}": "Fixed message"
  }
}
{
  "EventId": 10,
  "LogLevel": "Warning",
  "Category": "LoggingExample",
  "Message": "Welcome to Vancouver BC!",
  "State": {
    "Message": "Welcome to Vancouver BC!",
    "city": "Vancouver",
    "province": "BC",
    "{OriginalFormat}": "Welcome to {City} {Province}!"
  }
}
{
  "EventId": 10,
  "LogLevel": "Information",
  "Category": "LoggingExample",
  "Message": "Welcome to Vancouver BC!",
  "State": {
    "Message": "Welcome to Vancouver BC!",
    "city": "Vancouver",
    "province": "BC",
    "{OriginalFormat}": "Welcome to {City} {Province}!"
  }
}
{
  "EventId": 20,
  "LogLevel": "Critical",
  "Category": "LoggingExample",
  "Message": "Value is 1.234568E+004",
  "State": {
    "Message": "Value is 1.234568E+004",
    "value": 12345.6789,
    "{OriginalFormat}": "Value is {Value:E}"
  }
}

总结

随着 C# 源生成器的出现,编写高性能的日志记录 API 变得更加容易。 使用源生成器方法有几个主要好处:

  • 允许保留日志记录结构,并启用消息模板所需的确切格式语法。
  • 允许为模板占位符提供替代名称,允许使用格式说明符。
  • 允许按原样传递所有原始数据,在对其进行处理之前,不需要进行任何复杂的存储(除了创建 string)。
  • 提供特定于日志记录的诊断,针对重复的事件 ID 发出警告。

与手动使用 LoggerMessage.Define 相比,还有一些好处:

  • 语法更短、更简单:使用声明性属性,而不是对样本进行编码。
  • 引导式开发人员体验:生成器会发出警告,帮助开发人员做正确的事。
  • 支持任意数量的日志记录参数。 LoggerMessage.Define 最多支持六个。
  • 支持动态日志级别。 单独使用 LoggerMessage.Define 不可能做到这一点。

另请参阅