Ведение журнала создания исходного кода во время компиляции

На платформе .NET 6 появился тип LoggerMessageAttribute. Этот атрибут является частью пространства имен Microsoft.Extensions.Logging. Он создает исходный код для высокопроизводительных API-интерфейсов ведения журналов. Поддержка ведения журналов создания исходного кода предназначена для создания широко используемого и высокопроизводительного решения по ведению журналов для современных приложений .NET. Автоматически создаваемый исходный код полагается на интерфейс ILogger в сочетании с возможностями LoggerMessage.Define.

Генератор исходного кода активируется при использовании LoggerMessageAttribute с методами ведения журналов partial. При активации генератор может автоматически создавать реализацию методов 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 или более поздней. Компилятор C# 9.0 стал доступен на платформе .NET 5. Чтобы обновить версию компилятора 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);
}

Как правило, первый экземпляр ILogger, LogLevel и Exception обрабатывается специально в сигнатуре метода ведения журнала в генераторе исходного кода. Последующие экземпляры обрабатываются как обычные параметры шаблона сообщения.

// 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 сообщит о DiagnosticSeverity.WarningSYSLIB0025.

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).
  • Предоставляет определенные для ведения журнала диагностика и выдает предупреждения для повторяющихся идентификаторов событий.

Кроме того, применение LoggerMessage.Define вручную предоставляет следующие преимущества:

  • более короткий и простой синтаксис — декларативное использование атрибутов вместо стандартного кода;
  • интерактивное взаимодействие с разработчиками — генератор выводит предупреждения, помогающие выполнить нужное действие;
  • поддержка произвольного числа параметров ведения журналов. LoggerMessage.Define поддерживает не более шести;
  • поддержка динамического уровня ведения журнала. Сейчас она невозможна при использовании только LoggerMessage.Define.

См. также