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

本教程介绍以下操作:

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

先决条件

需要将计算机设置为运行 .NET 6,包括 C# 10 编译器。 自 Visual Studio 2022.NET 6 SDK 起,开始提供 C# 10 编译器。

本教程假设你熟悉 C# 和 .NET,包括 Visual Studio 或 .NET CLI。

新大纲

C# 10 增加了对自定义内插字符串处理程序的支持。 内插字符串处理程序是处理内插字符串中的占位符表达式的一种类型。 如果没有自定义处理程序,占位符的处理方式与 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 应用于该类型。
  • 采用 literalLengthformatCount 这两个 int 参数的构造函数。 (允许采用更多的参数)。
  • 具有 public void AppendLiteral(string s) 签名的公共 AppendLiteral 方法。
  • 具有 public void AppendFormatted<T>(T t) 签名的一般公共 AppendFormatted 方法。

在内部,生成器创建格式化字符串,并提供一个成员供客户端检索该字符串。 下面的代码演示了满足这些需求的 LogInterpolatedStringHandler 类型:

[InterpolatedStringHandler]
public ref 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 的调用。
  • 编译器使用 CoreInterpolatedStringHandler 作为自变量来调用 LogMessage 方法。

最后,请注意,最后一个警告不会调用内插字符串处理程序。 自变量是一个 string,因此该调用使用一个字符串参数来调用另一个重载。

向处理程序添加更多功能

上一版本的内插字符串处理程序实现了模式。 为了避免处理每个占位符表达式,需要在处理程序中提供更多信息。 在本部分,你将改进处理程序,使它在构建的字符串不会写入日志时完成更少的工作。 使用 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 变量在循环的每个迭代中都递增了 5 次。 由于占位符只对 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 方法还可以有更多重载。