Поделиться через


Руководство. Создание пользовательского обработчика интерполяции строк

В этом руководстве описано, как:

  • Реализация шаблона обработчика интерполяции строк
  • Взаимодействие с приемником в операциях интерполяции строк.
  • Добавление аргументов в обработчик интерполяции строк
  • Общие сведения о новых функциях библиотеки для интерполяции строк

Необходимые условия

Необходимо настроить компьютер для запуска .NET. Компилятор C# доступен с Visual Studio 2022 или пакета SDK для .NET.

В этом руководстве предполагается, что вы знакомы с C# и .NET, включая Visual Studio или .NET CLI.

Можно написать настраиваемый интерполированный обработчик строк. Интерполированный обработчик строк — это тип, обрабатывающий выражение заполнителя в интерполированной строке. Без пользовательского обработчика заполнители обрабатываются аналогично String.Format. Каждый заполнитель форматируется как текст, а затем компоненты объединяются для формирования результирующей строки.

Обработчик можно написать для любого сценария, где используются сведения о результирующей строке. Будет ли она использоваться? Какие ограничения относятся к формату? Ниже приведены некоторые примеры:

  • Может потребоваться, чтобы ни одна из результирующей строки не превышала некоторые ограничения, например 80 символов. Можно обработать интерполированные строки для заполнения буфера фиксированной длины и остановить обработку после достижения этой длины буфера.
  • У вас может быть табличный формат, и каждый местозаполнитель должен иметь фиксированную длину. Настраиваемый обработчик может применять это, вместо того чтобы вынуждать весь клиентский код соответствовать.

В этом руководстве вы создадите обработчик интерполяции строк для одного из основных сценариев производительности: библиотеки ведения журнала. В зависимости от установленного уровня ведения журнала, работу по созданию сообщения журнала можно не выполнять. Если ведение журнала отключено, работа по созданию строки из интерполированного строкового выражения не требуется. Сообщение никогда не печатается, поэтому любое объединение строк можно пропустить. Кроме того, не обязательно выполнять все выражения, используемые в заполнителях, включая создание трассировок стека.

Интерполированный обработчик строк может определить, будет ли использоваться отформатированная строка, и при необходимости выполнять только необходимую работу.

Начальная реализация

Начнем с базового класса Logger, поддерживающего различные уровни:

public enum LogLevel
{
    Off,
    Critical,
    Error,
    Warning,
    Information,
    Trace
}

public class Logger
{
    public LogLevel EnabledLevel { get; init; } = LogLevel.Error;

    public void LogMessage(LogLevel level, string msg)
    {
        if (EnabledLevel < level) return;
        Console.WriteLine(msg);
    }
}

Эта Logger поддерживает шесть различных уровней. Если сообщение не проходит фильтр по уровню журналирования, оно не выводится. Общедоступный API для средства ведения журнала принимает (полностью отформатированную) строку в качестве сообщения. Все действия по созданию строки уже выполнены.

Реализуйте шаблон обработчика

Этот шаг заключается в создании интерполированного обработчика строк, который воссоздает текущее поведение. Интерполированный обработчик строк — это тип, который должен иметь следующие характеристики:

  • System.Runtime.CompilerServices.InterpolatedStringHandlerAttribute был применён к типу.
  • Конструктор с двумя параметрами int, literalLength и formattedCount. (Разрешено больше параметров).
  • Открытый метод AppendLiteral с сигнатурой: public void AppendLiteral(string s).
  • Универсальный открытый AppendFormatted метод с сигнатурой: public void AppendFormatted<T>(T t).

Внутри этого построителя создает форматированную строку и предоставляет элемент для клиента для получения этой строки. В следующем коде показан тип LogInterpolatedStringHandler, соответствующий этим требованиям:

[InterpolatedStringHandler]
public struct LogInterpolatedStringHandler
{
    // Storage for the built-up string
    StringBuilder builder;

    public LogInterpolatedStringHandler(int literalLength, int formattedCount)
    {
        builder = new StringBuilder(literalLength);
        Console.WriteLine($"\tliteral length: {literalLength}, formattedCount: {formattedCount}");
    }

    public void AppendLiteral(string s)
    {
        Console.WriteLine($"\tAppendLiteral called: {{{s}}}");
        
        builder.Append(s);
        Console.WriteLine($"\tAppended the literal string");
    }

    public void AppendFormatted<T>(T t)
    {
        Console.WriteLine($"\tAppendFormatted called: {{{t}}} is of type {typeof(T)}");

        builder.Append(t?.ToString());
        Console.WriteLine($"\tAppended the formatted object");
    }

    internal string GetFormattedText() => builder.ToString();
}

Теперь можно добавить перегрузку для LogMessage в классе Logger, чтобы попробовать новый обработчик интерполированной строки.

public void LogMessage(LogLevel level, LogInterpolatedStringHandler builder)
{
    if (EnabledLevel < level) return;
    Console.WriteLine(builder.GetFormattedText());
}

Не нужно удалять исходный метод LogMessage, компилятор предпочитает метод с параметром интерполированного обработчика для метода с параметром string, если аргумент является интерполированным строковым выражением.

Вы можете убедиться, что новый обработчик вызывается с помощью следующего кода в качестве основной программы:

var logger = new Logger() { EnabledLevel = LogLevel.Warning };
var time = DateTime.Now;

logger.LogMessage(LogLevel.Error, $"Error Level. CurrentTime: {time}. This is an error. It will be printed.");
logger.LogMessage(LogLevel.Trace, $"Trace Level. CurrentTime: {time}. This won't be printed.");
logger.LogMessage(LogLevel.Warning, "Warning Level. This warning is a string, not an interpolated string expression.");

Запуск приложения создает выходные данные, аналогичные следующему тексту:

        literal length: 65, formattedCount: 1
        AppendLiteral called: {Error Level. CurrentTime: }
        Appended the literal string
        AppendFormatted called: {10/20/2021 12:19:10 PM} is of type System.DateTime
        Appended the formatted object
        AppendLiteral called: {. This is an error. It will be printed.}
        Appended the literal string
Error Level. CurrentTime: 10/20/2021 12:19:10 PM. This is an error. It will be printed.
        literal length: 50, formattedCount: 1
        AppendLiteral called: {Trace Level. CurrentTime: }
        Appended the literal string
        AppendFormatted called: {10/20/2021 12:19:10 PM} is of type System.DateTime
        Appended the formatted object
        AppendLiteral called: {. This won't be printed.}
        Appended the literal string
Warning Level. This warning is a string, not an interpolated string expression.

В процессе трассировки результата можно увидеть, как компилятор добавляет код для вызова обработчика и создания строки.

  • Компилятор добавляет вызов для создания обработчика, передав общую длину литерального текста в строке формата и количество заполнителей.
  • Компилятор добавляет вызовы функций AppendLiteral и AppendFormatted для каждого раздела строки литерала и для каждого заполнителя.
  • Компилятор вызывает метод LogMessage с помощью CoreInterpolatedStringHandler в качестве аргумента.

Наконец, обратите внимание, что последнее предупреждение не вызывает интерполированный обработчик строк. Аргумент представляет собой string, поэтому вызов вызывает другую перегрузку строковым параметром.

Важный

Используйте ref struct для интерполированных обработчиков строк, только если это необходимо. Использование ref struct будет иметь ограничения, так как они должны храниться в стеке. Например, они не будут работать, если интерполированное строковое отверстие содержит выражение await, так как компилятору потребуется сохранить обработчик в реализации IAsyncStateMachine компилятора.

Расширьте возможности обработчика

Предыдущая версия интерполированного обработчика строк реализует шаблон. Чтобы избежать обработки каждого выражения замещающего элемента, вам нужна дополнительная информация в обработчике. В этом разделе вы улучшаете обработчик, чтобы он выполнял меньше работы, если созданная строка не записывается в журнал. Вы используете System.Runtime.CompilerServices.InterpolatedStringHandlerArgumentAttribute, чтобы указать сопоставление параметров с общедоступным API и параметрами конструктора обработчика. Это предоставляет обработчику сведения, необходимые для определения необходимости вычисления интерполированной строки.

Начнем с изменений обработчика. Сначала добавьте поле для отслеживания, включен ли обработчик. Добавьте два параметра в конструктор: один, чтобы указать уровень журнала для этого сообщения, а другой — ссылку на объект журнала:

private readonly bool enabled;

public LogInterpolatedStringHandler(int literalLength, int formattedCount, Logger logger, LogLevel logLevel)
{
    enabled = logger.EnabledLevel >= logLevel;
    builder = new StringBuilder(literalLength);
    Console.WriteLine($"\tliteral length: {literalLength}, formattedCount: {formattedCount}");
}

Затем используйте поле, чтобы обработчик добавлял только литералы или отформатированные объекты, когда будет использоваться окончательная строка:

public void AppendLiteral(string s)
{
    Console.WriteLine($"\tAppendLiteral called: {{{s}}}");
    if (!enabled) return;

    builder.Append(s);
    Console.WriteLine($"\tAppended the literal string");
}

public void AppendFormatted<T>(T t)
{
    Console.WriteLine($"\tAppendFormatted called: {{{t}}} is of type {typeof(T)}");
    if (!enabled) return;

    builder.Append(t?.ToString());
    Console.WriteLine($"\tAppended the formatted object");
}

Затем необходимо обновить объявление LogMessage таким образом, чтобы компилятор передает дополнительные параметры конструктору обработчика. Это обрабатывается с помощью System.Runtime.CompilerServices.InterpolatedStringHandlerArgumentAttribute в аргументе обработчика:

public void LogMessage(LogLevel level, [InterpolatedStringHandlerArgument("", "level")] LogInterpolatedStringHandler builder)
{
    if (EnabledLevel < level) return;
    Console.WriteLine(builder.GetFormattedText());
}

Этот атрибут задает список аргументов для LogMessage, которые сопоставлены с параметрами, следующими после обязательных параметров literalLength и formattedCount. Пустая строка (""), указывает приемник. Компилятор подставляет значение объекта Logger, который представлен как this, для следующего аргумента конструктора обработчика. Компилятор заменяет значение level для следующего аргумента. Вы можете указать любое количество аргументов для любого обработчика, который вы пишете. Добавляемые аргументы являются строковыми аргументами.

Эту версию можно запустить с помощью того же тестового кода. На этот раз вы увидите следующие результаты:

        literal length: 65, formattedCount: 1
        AppendLiteral called: {Error Level. CurrentTime: }
        Appended the literal string
        AppendFormatted called: {10/20/2021 12:19:10 PM} is of type System.DateTime
        Appended the formatted object
        AppendLiteral called: {. This is an error. It will be printed.}
        Appended the literal string
Error Level. CurrentTime: 10/20/2021 12:19:10 PM. This is an error. It will be printed.
        literal length: 50, formattedCount: 1
        AppendLiteral called: {Trace Level. CurrentTime: }
        AppendFormatted called: {10/20/2021 12:19:10 PM} is of type System.DateTime
        AppendLiteral called: {. This won't be printed.}
Warning Level. This warning is a string, not an interpolated string expression.

Вы можете увидеть, что вызываются методы AppendLiteral и AppendFormat, но они не производят работы. Обработчик определил, что окончательная строка не нужна, поэтому обработчик не создает его. Есть еще несколько улучшений, чтобы сделать.

Сначала можно добавить перегрузку AppendFormatted, которая ограничивает аргумент типом, реализующим System.IFormattable. Эта перегрузка позволяет пользователям добавлять форматные строки в заполнители. При внесении этого изменения давайте также изменим тип возвращаемого значения других AppendFormatted и AppendLiteral методов с void на bool (если у любого из этих методов разные типы возвращаемых значений, то возникает ошибка компиляции). Это изменение позволяет выполнять короткое замыкание. Методы возвращают false, чтобы указать, что обработка интерполированного строкового выражения должна быть остановлена. Возврат значения true указывает, что процесс должен продолжаться. В этом примере вы используете его для остановки обработки, когда результирующая строка не нужна. Короткое замыкание поддерживает более детализированные действия. Вы можете остановить обработку выражения после достижения определенной длины, чтобы поддерживать буферы фиксированной длины. Или какое-то условие может указать, что остальные элементы не нужны.

public void AppendFormatted<T>(T t, string format) where T : IFormattable
{
    Console.WriteLine($"\tAppendFormatted (IFormattable version) called: {t} with format {{{format}}} is of type {typeof(T)},");

    builder.Append(t?.ToString(format, null));
    Console.WriteLine($"\tAppended the formatted object");
}

С помощью этого дополнения можно указать строки формата в интерполированном строковом выражении:

var time = DateTime.Now;

logger.LogMessage(LogLevel.Error, $"Error Level. CurrentTime: {time}. The time doesn't use formatting.");
logger.LogMessage(LogLevel.Error, $"Error Level. CurrentTime: {time:t}. This is an error. It will be printed.");
logger.LogMessage(LogLevel.Trace, $"Trace Level. CurrentTime: {time:t}. This won't be printed.");

:t в первом сообщении указывает "короткий формат времени" для текущего времени. В предыдущем примере показана одна из перегрузок метода AppendFormatted, который можно создать для обработчика. Не нужно указывать универсальный аргумент для отформатированного объекта. Возможно, у вас есть более эффективные способы преобразования типов, создаваемых в строку. Вы можете создавать перегрузки функций AppendFormatted, которые принимают эти типы вместо универсального аргумента. Компилятор выбирает лучшую перегрузку. Среда выполнения использует этот метод для преобразования System.Span<T> в строковые выходные данные. Можно добавить целочисленный параметр, чтобы указать выравнивание выходных данных, с IFormattableили без IFormattable. System.Runtime.CompilerServices.DefaultInterpolatedStringHandler, который поставляется в комплекте с .NET 6, содержит девять перегрузок AppendFormatted для различных видов использования. Его можно использовать в качестве ссылки при создании обработчика для ваших целей.

Запустите пример, и вы увидите, что для сообщения Trace вызывается только первая AppendLiteral:

        literal length: 60, formattedCount: 1
        AppendLiteral called: Error Level. CurrentTime:
        Appended the literal string
        AppendFormatted called: 10/20/2021 12:18:29 PM is of type System.DateTime
        Appended the formatted object
        AppendLiteral called: . The time doesn't use formatting.
        Appended the literal string
Error Level. CurrentTime: 10/20/2021 12:18:29 PM. The time doesn't use formatting.
        literal length: 65, formattedCount: 1
        AppendLiteral called: Error Level. CurrentTime:
        Appended the literal string
        AppendFormatted (IFormattable version) called: 10/20/2021 12:18:29 PM with format {t} is of type System.DateTime,
        Appended the formatted object
        AppendLiteral called: . This is an error. It will be printed.
        Appended the literal string
Error Level. CurrentTime: 12:18 PM. This is an error. It will be printed.
        literal length: 50, formattedCount: 1
        AppendLiteral called: Trace Level. CurrentTime:
Warning Level. This warning is a string, not an interpolated string expression.

Вы можете внести одно окончательное обновление в конструктор обработчика, который повышает эффективность. Обработчик может добавить окончательный параметр out bool. Установка этого параметра на false указывает, что обработчик не должен вызываться вообще для обработки интерполированного строкового выражения:

public LogInterpolatedStringHandler(int literalLength, int formattedCount, Logger logger, LogLevel level, out bool isEnabled)
{
    isEnabled = logger.EnabledLevel >= level;
    Console.WriteLine($"\tliteral length: {literalLength}, formattedCount: {formattedCount}");
    builder = isEnabled ? new StringBuilder(literalLength) : default!;
}

Это изменение означает, что вы можете удалить поле enabled. Затем можно изменить тип возвращаемого значения AppendLiteral и AppendFormatted на void. Теперь при запуске примера вы увидите следующие выходные данные:

        literal length: 60, formattedCount: 1
        AppendLiteral called: Error Level. CurrentTime:
        Appended the literal string
        AppendFormatted called: 10/20/2021 12:19:10 PM is of type System.DateTime
        Appended the formatted object
        AppendLiteral called: . The time doesn't use formatting.
        Appended the literal string
Error Level. CurrentTime: 10/20/2021 12:19:10 PM. The time doesn't use formatting.
        literal length: 65, formattedCount: 1
        AppendLiteral called: Error Level. CurrentTime:
        Appended the literal string
        AppendFormatted (IFormattable version) called: 10/20/2021 12:19:10 PM with format {t} is of type System.DateTime,
        Appended the formatted object
        AppendLiteral called: . This is an error. It will be printed.
        Appended the literal string
Error Level. CurrentTime: 12:19 PM. This is an error. It will be printed.
        literal length: 50, formattedCount: 1
Warning Level. This warning is a string, not an interpolated string expression.

Единственным выходным результатом, когда был указан LogLevel.Trace, является выходные данные конструктора. Обработчик указал, что он не включен, поэтому ни один из Append методов не был вызван.

В этом примере показана важная точка интерполированных обработчиков строк, особенно при использовании библиотек ведения журнала. Любые побочные эффекты в заполнителях могут не возникать. Добавьте следующий код в основную программу и просмотрите это поведение в действии:

int index = 0;
int numberOfIncrements = 0;
for (var level = LogLevel.Critical; level <= LogLevel.Trace; level++)
{
    Console.WriteLine(level);
    logger.LogMessage(level, $"{level}: Increment index a few times {index++}, {index++}, {index++}, {index++}, {index++}");
    numberOfIncrements += 5;
}
Console.WriteLine($"Value of index {index}, value of numberOfIncrements: {numberOfIncrements}");

Вы можете видеть, что переменная index увеличивается пять раз в каждую итерацию цикла. Так как заполнители оцениваются только для уровней Critical, Error и Warning, а не для Information и Trace, окончательное значение index не соответствует ожиданиям.

Critical
Critical: Increment index a few times 0, 1, 2, 3, 4
Error
Error: Increment index a few times 5, 6, 7, 8, 9
Warning
Warning: Increment index a few times 10, 11, 12, 13, 14
Information
Trace
Value of index 15, value of numberOfIncrements: 25

Интерполированные обработчики строк обеспечивают больший контроль над преобразованием интерполированного строкового выражения в строку. Команда среды выполнения .NET использовала эту функцию для повышения производительности в нескольких областях. Вы можете использовать ту же возможность в собственных библиотеках. Дополнительные сведения см. в System.Runtime.CompilerServices.DefaultInterpolatedStringHandler. Это обеспечивает более полную реализацию, чем вы создали здесь. Вы видите много дополнительных перегрузок, которые возможны для методов Append.