教程:编写自定义字符串内插处理程序

在本教程中,您将学习如何:

  • 实现字符串内插处理程序模式
  • 在字符串内插操作中与接收方交互。
  • 向字符串内插处理程序添加自变量
  • 了解用于字符串内插的新程序库功能

先决条件

需要将计算机设置为运行 .NET。 Visual Studio 2022.NET SDK中提供了 C# 编译器。

本教程假定你熟悉 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 参数的构造函数,literalLengthformattedCount。 (允许更多参数)。
  • 具有 public void AppendLiteral(string s) 签名的公共 AppendLiteral 方法。
  • 具有 public void AppendFormatted<T>(T t) 签名的一般公共 AppendFormatted 方法。

在内部,生成器会创建格式化字符串,并为客户端提供一个成员来检索该字符串。 下面的代码演示了满足这些需求的 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();
}

现在可以在 Logger 类中将重载添加到 LogMessage,以尝试使用新的内插字符串处理程序:

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.

通过输出跟踪,可以看到编译器如何添加代码来调用处理程序并生成字符串:

  • 编译器添加一个调用来构造处理程序,并传递格式字符串中文本的总长度,以及占位符的数量。
  • 编译器为字面字符串的每个部分以及每个占位符添加对 AppendLiteralAppendFormatted 的调用。
  • 编译器调用 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 的自变量列表,这些参数映射到所需的 literalLengthformattedCount 参数之后的参数。 空字符串(“”),指定接收方。 编译器将 this 所表示的 Logger 对象的值替换为处理程序构造函数的下一个参数。 编译器将 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.

可以看到正在调用 AppendLiteralAppendFormat 方法,但它们未执行任何工作。 处理程序确定不需要最终字符串,因此处理程序不会生成它。 还有一些需要改进的地方。

首先,可以添加 AppendFormatted 的一个重载,该重载将自变量约束为实现 System.IFormattable 的一个类型。 此重载使调用方能够在占位符中添加格式字符串。 进行此更改时,我们还会将其他 AppendFormattedAppendLiteral 方法的返回类型从 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)。 .NET 6 附带的 System.Runtime.CompilerServices.DefaultInterpolatedStringHandler 包含 AppendFormatted 的 9 个重载,用于不同的用途。 在为您的特定需求构建处理程序时,可以将其用作参考。

现在运行示例,你将看到,对于 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 字段。 然后,可以将 AppendLiteralAppendFormatted 的返回类型更改为 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 变量在循环的每次迭代中递增了五次。 由于占位符仅针对 CriticalErrorWarning 级别(而不是 InformationTrace)进行评估,因此 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 方法还可以有更多重载。