跳到主要内容

JEP 430:字符串模板(预览)

QWen Max 中英对照 JEP 430: String Templates (Preview)

总结

通过字符串模板增强 Java 编程语言。字符串模板通过将文本字面量与嵌入式表达式和模板处理器相结合,补充了 Java 现有的字符串字面量和文本块,从而生成专门的结果。这是一个预览语言功能和 API

目标

  • 通过简化表达包含运行时计算值的字符串的方式,简化 Java 程序的编写。

  • 提高混合文本和表达式表达式的可读性,无论文本是否适合单个源代码行(如字符串字面量),还是跨越多行源代码(如文本块)。

  • 通过支持对模板及其嵌入表达式的值进行验证和转换,提高将用户提供的值组合成字符串并传递给其他系统(例如,为数据库构建查询)的 Java 程序的安全性。

  • 通过允许 Java 库定义字符串模板中使用的格式化语法,保持灵活性。

  • 简化接受非 Java 语言编写的字符串的 API 的使用(例如 SQL、XML 和 JSON)。

  • 允许从字面文本和嵌入表达式创建非字符串值,而无需通过中间字符串表示形式。

非目标

  • 不会为 Java 的字符串连接运算符(+)引入语法糖,因为这将绕过验证的目标。

  • 不会废弃或移除传统上用于复杂或程序化字符串组合的 StringBuilderStringBuffer 类。

动机

开发者通常会通过组合字面文本和表达式来构建字符串。Java 提供了几种字符串合成的机制,但不幸的是,这些方法都有其缺点。

  • 使用 + 运算符进行字符串连接会产生难以阅读的代码:

    String s = x + " plus " + y + " equals " + (x + y);
  • StringBuilder 过于冗长:

    String s = new StringBuilder()
    .append(x)
    .append(" plus ")
    .append(y)
    .append(" equals ")
    .append(x + y)
    .toString();
  • String::formatString::formatted 将格式字符串与参数分离,容易引发参数数量和类型不匹配的问题:

    String s = String.format("%2$d plus %1$d equals %3$d", x, y, x + y);
    String t = "%2$d plus %1$d equals %3$d".formatted(x, y, x + y);
  • java.text.MessageFormat 需要过多的仪式化代码,并且格式字符串使用了不熟悉的语法:

    MessageFormat mf = new MessageFormat("{0} plus {1} equals {2}");
    String s = mf.format(x, y, x + y);

字符串插值

许多编程语言提供了字符串插值作为字符串拼接的替代方案。通常,这会采用包含嵌入式表达式和字面文本的字符串字面量的形式。将表达式原地嵌入意味着读者可以轻松辨别预期的结果。在运行时,嵌入的表达式会被它们的(字符串化的)值替换 —— 这些值被称为插值到字符串中。以下是一些其他语言中插值的示例:

C#             $"{x} plus {y} equals {x + y}"
Visual Basic $"{x} plus {y} equals {x + y}"
Python f"{x} plus {y} equals {x + y}"
Scala s"$x plus $y equals ${x + y}"
Groovy "$x plus $y equals ${x + y}"
Kotlin "$x plus $y equals ${x + y}"
JavaScript `${x} plus ${y} equals ${x + y}`
Ruby "#{x} plus #{y} equals #{x + y}"
Swift "\(x) plus \(y) equals \(x + y)"

其中一些语言对所有字符串字面量启用插值,而其他语言则要求在需要时启用插值,例如通过在字面量的起始分隔符前加上 $f。嵌入式表达式的语法也各不相同,但通常涉及 ${ } 等字符,这意味着除非这些字符被转义,否则它们不能按字面意思出现。

编写代码时,插值不仅比连接更方便,而且在阅读代码时也提供了更高的清晰度。对于较大的字符串,这种清晰度尤其显著。例如,在 JavaScript 中:

const title = "My Web Page";
const text = "Hello, world";

var html = `<html>
<head>
<title>${title}</title>
</head>
<body>
<p>${text}</p>
</body>
</html>`;

字符串插值是危险的

但是,插值的便利性也有其缺点:很容易构造出会被其他系统解释但在这些系统中却存在危险错误的字符串。

包含 SQL 语句、HTML/XML 文档、JSON 片段、Shell 脚本以及自然语言文本的字符串,都需要根据特定领域的规则进行验证和清理。由于 Java 编程语言无法强制执行所有这些规则,因此使用插值的开发者需要负责验证和清理。通常,这意味着要记住将嵌入的表达式包装在 escapevalidate 方法的调用中,并依靠 IDE 或 静态分析工具 来帮助验证字面文本。

插值对 SQL 语句尤其危险,因为它可能导致注入攻击。例如,考虑以下包含嵌入表达式 ${name} 的假设 Java 代码:

String query = "SELECT * FROM Person p WHERE p.last_name = '${name}'";
ResultSet rs = connection.createStatement().executeQuery(query);

如果 name 具有令人困扰的值

Smith' OR p.last_name <> 'Smith

那么查询字符串将是

SELECT * FROM Person p WHERE p.last_name = 'Smith' OR p.last_name <> 'Smith'

并且代码会选择所有行,可能会暴露机密信息。使用简单插值来构建查询字符串和使用传统的连接方法一样不安全:

String query = "SELECT * FROM Person p WHERE p.last_name = '" + name + "'";

我们可以做得更好吗?

对于 Java,我们希望拥有一项字符串组合功能,该功能可以达到插值的清晰度,但开箱即用地实现更安全的结果,也许会牺牲少量的便利性以换取大量的安全性。

例如,在编写 SQL 语句时,嵌入表达式值中的任何引号都必须进行转义,并且整个字符串必须具有平衡的引号。考虑到上面显示的 name 的麻烦值,应该编写的安全查询为:

SELECT * FROM Person p WHERE p.last_name = '\'Smith\' OR p.last_name <> \'Smith\''

几乎每个字符串插值的使用都涉及将字符串结构化以适应某种模板:SQL 语句通常遵循 SELECT ... FROM ... WHERE ... 的模板,HTML 文档遵循 <html> ... </html> 的模板,甚至自然语言中的消息也遵循在字面文本中插入动态值(例如用户名)的模板。每种模板都有其验证和转换规则,例如 SQL 语句需要“转义所有引号”,HTML 文档要求“只允许合法的字符实体”,而自然语言消息则需“根据操作系统配置的语言进行本地化”。

理想情况下,字符串的模板可以直接在代码中表达,就像注释字符串一样,Java 运行时会自动对字符串应用特定于模板的规则。这样就可以得到带有转义引号的 SQL 语句、没有非法实体的 HTML 文档以及没有样板代码的消息本地化。通过模板来构建字符串可以省去开发者繁琐地转义每个嵌入式表达式的工作,也不需要调用 validate() 来验证整个字符串,或者使用 java.util.ResourceBundle 来查找本地化的字符串。

再比如,我们可以构造一个表示 JSON 文档的字符串,然后将其传递给 JSON 解析器,以获得强类型的 JSONObject

String name    = "Joan Smith";
String phone = "555-123-4567";
String address = "1 Maple Drive, Anytown";
String json = """
{
"name": "%s",
"phone": "%s",
"address": "%s"
}
""".formatted(name, phone, address);

JSONObject doc = JSON.parse(json);
... doc.entrySet().stream().map(...) ...

理想情况下,字符串的 JSON 结构可以直接在代码中表达,并且 Java 运行时会自动将字符串转换为 JSONObject。通过解析器的手动迂回操作将不再必要。

总之,如果我们有一个基于模板的一流字符串组合机制,就可以提高几乎每个 Java 程序的可读性和可靠性。这样的功能将提供插值的好处(在其他编程语言中可以看到),但不太容易引入安全漏洞。它还将减少处理以复杂字符串作为输入的库时的繁琐工作。

描述

模板表达式 是 Java 编程语言中的一种新型表达式。模板表达式可以执行字符串插值,但它们也具有可编程性,有助于开发者安全高效地组合字符串。此外,模板表达式不仅限于组合字符串 —— 它们可以根据特定领域的规则,将结构化文本转换为任何类型的对象。

从语法上看,模板表达式类似于带前缀的字符串字面量。此代码的第二行有一个模板表达式:

String name = "Joan";
String info = STR."My name is \{name}";
assert info.equals("My name is Joan"); // true

模板表达式 STR."My name is \{name}" 由以下部分组成:

  1. 一个模板处理器STR);
  2. 一个点字符(U+002E),在其他类型的表达式中也可以看到;以及
  3. 一个模板"My name is \{name}"),其中包含一个嵌入式表达式\{name})。

当模板表达式在运行时被求值时,它的模板处理器会将模板中的文本与嵌入表达式的值结合起来,以生成结果。模板处理器的结果,也就是模板表达式求值的结果,通常是一个 String —— 但并不总是如此。

STR 模板处理器

STR 是 Java 平台中定义的一个模板处理器。它通过将模板中的每个嵌入式表达式替换为该表达式的(字符串化)值来执行字符串插值。使用 STR 的模板表达式求值结果是一个 String;例如,"My name is Joan"

在日常对话中,开发人员在提到整个模板表达式(包括模板处理器)或模板表达式中的模板部分(即模板处理器的参数)时,都可能会使用“模板”这个术语。只要注意不要混淆这些概念,这种非正式用法是合理的。

STR 是一个会自动导入到每个 Java 源文件的 public static final 字段。

以下是使用 STR 模板处理器的模板表达式的更多示例。左边距中的符号 | 表示该行显示前一条语句的值,类似于 jshell

// Embedded expressions can be strings
String firstName = "Bill";
String lastName = "Duck";
String fullName = STR."\{firstName} \{lastName}";
| "Bill Duck"
String sortName = STR."\{lastName}, \{firstName}";
| "Duck, Bill"

// Embedded expressions can perform arithmetic
int x = 10, y = 20;
String s = STR."\{x} + \{y} = \{x + y}";
| "10 + 20 = 30"

// Embedded expressions can invoke methods and access fields
String s = STR."You have a \{getOfferType()} waiting for you!";
| "You have a gift waiting for you!"
String t = STR."Access at \{req.date} \{req.time} from \{req.ipAddress}";
| "Access at 2022-03-25 15:34 from 8.8.8.8"

为了帮助重构,双引号字符可以在嵌入表达式中使用,而无需转义为 \"。这意味着嵌入表达式可以像在模板表达式外部一样出现在模板表达式中,从而简化了从串联 (+) 到模板表达式的转换。例如:

String filePath = "tmp.dat";
File file = new File(filePath);
String old = "The file " + filePath + " " + (file.exists() ? "does" : "does not") + " exist";
String msg = STR."The file \{filePath} \{file.exists() ? "does" : "does not"} exist";
| "The file tmp.dat does exist" or "The file tmp.dat does not exist"

为了提高可读性,嵌入式表达式可以在源文件中跨多行展开,而不会在结果中引入换行符。嵌入式表达式的值会在表达式 \ 所在的位置插入到结果中;然后模板被视为继续与 \ 位于同一行。例如:

String time = STR."The time is \{
// The java.time.format package is very useful
DateTimeFormatter
.ofPattern("HH:mm:ss")
.format(LocalTime.now())
} right now";
| "The time is 12:34:56 right now"

字符串模板表达式中嵌入表达式的数量没有限制。嵌入表达式会从左到右进行求值,就像方法调用表达式中的参数一样。例如:

// Embedded expressions can be postfix increment expressions
int index = 0;
String data = STR."\{index++}, \{index++}, \{index++}, \{index++}";
| "0, 1, 2, 3"

任何 Java 表达式都可以用作嵌入式表达式 —— 甚至是模板表达式。例如:

// Embedded expression is a (nested) template expression
String[] fruit = { "apples", "oranges", "peaches" };
String s = STR."\{fruit[0]}, \{STR."\{fruit[1]}, \{fruit[2]}"}";
| "apples, oranges, peaches"

这里的模板表达式 STR."\{fruit[1]}, \{fruit[2]}" 嵌套在另一个模板表达式的模板中。由于存在大量的 "\{ } 字符,这段代码难以阅读,因此最好将其格式化为:

String s = STR."\{fruit[0]}, \{
STR."\{fruit[1]}, \{fruit[2]}"
}";

或者,由于嵌入的表达式没有副作用,可以将其重构为一个单独的模板表达式:

String tmp = STR."\{fruit[1]}, \{fruit[2]}";
String s = STR."\{fruit[0]}, \{tmp}";

多行模板表达式

模板表达式的模板可以跨越多行源代码,其语法类似于文本块。 (我们在上面看到了一个跨越多行的嵌入式表达式,但包含该嵌入式表达式的模板在逻辑上是一行。)

以下是表示 HTML 文本、JSON 文本和区域表的模板表达式示例,它们都分布在多行上:

String title = "My Web Page";
String text = "Hello, world";
String html = STR."""
<html>
<head>
<title>\{title}</title>
</head>
<body>
<p>\{text}</p>
</body>
</html>
""";
| """
| <html>
| <head>
| <title>My Web Page</title>
| </head>
| <body>
| <p>Hello, world</p>
| </body>
| </html>
| """

String name = "Joan Smith";
String phone = "555-123-4567";
String address = "1 Maple Drive, Anytown";
String json = STR."""
{
"name": "\{name}",
"phone": "\{phone}",
"address": "\{address}"
}
""";
| """
| {
| "name": "Joan Smith",
| "phone": "555-123-4567",
| "address": "1 Maple Drive, Anytown"
| }
| """

record Rectangle(String name, double width, double height) {
double area() {
return width * height;
}
}
Rectangle[] zone = new Rectangle[] {
new Rectangle("Alfa", 17.8, 31.4),
new Rectangle("Bravo", 9.6, 12.4),
new Rectangle("Charlie", 7.1, 11.23),
};
String table = STR."""
Description Width Height Area
\{zone[0].name} \{zone[0].width} \{zone[0].height} \{zone[0].area()}
\{zone[1].name} \{zone[1].width} \{zone[1].height} \{zone[1].area()}
\{zone[2].name} \{zone[2].width} \{zone[2].height} \{zone[2].area()}
Total \{zone[0].area() + zone[1].area() + zone[2].area()}
""";
| """
| Description Width Height Area
| Alfa 17.8 31.4 558.92
| Bravo 9.6 12.4 119.03999999999999
| Charlie 7.1 11.23 79.733
| Total 757.693
| """

FMT 模板处理器

FMT 是 Java 平台中定义的另一个模板处理器。FMT 类似于 STR,因为它执行插值,但它还会解释出现在嵌入表达式左侧的格式说明符。这些格式说明符与 java.util.Formatter 中定义的相同。以下是区域表的示例,模板中的格式说明符使其更加整洁:

record Rectangle(String name, double width, double height) {
double area() {
return width * height;
}
}
Rectangle[] zone = new Rectangle[] {
new Rectangle("Alfa", 17.8, 31.4),
new Rectangle("Bravo", 9.6, 12.4),
new Rectangle("Charlie", 7.1, 11.23),
};
String table = FMT."""
Description Width Height Area
%-12s\{zone[0].name} %7.2f\{zone[0].width} %7.2f\{zone[0].height} %7.2f\{zone[0].area()}
%-12s\{zone[1].name} %7.2f\{zone[1].width} %7.2f\{zone[1].height} %7.2f\{zone[1].area()}
%-12s\{zone[2].name} %7.2f\{zone[2].width} %7.2f\{zone[2].height} %7.2f\{zone[2].area()}
\{" ".repeat(28)} Total %7.2f\{zone[0].area() + zone[1].area() + zone[2].area()}
""";
| """
| Description Width Height Area
| Alfa 17.80 31.40 558.92
| Bravo 9.60 12.40 119.04
| Charlie 7.10 11.23 79.73
| Total 757.69
| """

确保安全

模板表达式 STR."..." 是调用 STR 模板处理器的 process 方法的快捷方式。也就是说,现在我们熟悉的例子如下:

String name = "Joan";
String info = STR."My name is \{name}";

等价于:

String name = "Joan";
StringTemplate st = RAW."My name is \{name}";
String info = STR.process(st);

其中 RAW 是一个标准模板处理器,它会生成一个未经处理的 StringTemplate 对象。

模板表达式的设计故意使得无法直接从带有嵌入表达式的字符串字面量或文本块生成插值了表达式值的 String。这防止了危险的错误字符串在程序中传播。字符串字面量由模板处理器处理,该处理器负有安全插值和验证结果(无论是 String 还是其他类型)的明确责任。因此,如果我们忘记使用诸如 STRRAWFMT 之类的模板处理器,则会报告编译时错误:

String name = "Joan";
String info = "My name is \{name}";
| error: processor missing from template expression

语法和语义

模板表达式中的四种模板通过其语法展示,该语法从 TemplateExpression 开始:

TemplateExpression:
TemplateProcessor . TemplateArgument

TemplateProcessor:
Expression

TemplateArgument:
Template
StringLiteral
TextBlock

Template:
StringTemplate
TextBlockTemplate

StringTemplate:
Resembles a StringLiteral but has one or more embedded expressions,
and can be spread over multiple lines of source code

TextBlockTemplate:
Resembles a TextBlock but has one or more embedded expressions

Java 编译器会扫描术语 "...",并根据是否存在嵌入式表达式来确定将其解析为 StringLiteral 还是 StringTemplate。编译器同样会扫描术语 """...""",并确定将其解析为 TextBlock 还是 TextBlockTemplate。我们统一将这些术语中的 ... 部分称为 字符串字面量、字符串模板、文本块 或文本块模板的 内容

我们强烈建议 IDE 在视觉上区分字符串模板和字符串字面量,以及文本块模板和文本块。在字符串模板或文本块模板的内容中,IDE 应该在视觉上区分嵌入式表达式和字面文本。

Java 编程语言区分字符串字面量和字符串模板,以及文本块和文本块模板,主要是因为字符串模板或文本块模板的类型并不是我们熟悉的 java.lang.String。字符串模板或文本块模板的类型是 java.lang.StringTemplate,这是一个接口,而 String 并未实现 StringTemplate。因此,当模板表达式的模板是一个字符串字面量或文本块时,Java 编译器会自动将模板所表示的 String 转换为一个没有嵌入表达式的 StringTemplate

在运行时,模板表达式的求值过程如下:

  1. 左边的表达式将被求值以获得嵌套接口 StringTemplate.Processor 的一个实例,即模板处理器。

  2. 右边的表达式将被求值以获得 StringTemplate 的一个实例。

  3. StringTemplate 实例传递给 StringTemplate.Processor 实例的 process 方法,该方法生成一个结果。

模板表达式的类型是 StringTemplate.Processor 实例的 process 方法的返回类型。

模板处理器在运行时执行,而不是在编译时执行,因此它们无法对模板执行编译时处理。它们既不能获取出现在源代码中模板的准确字符,也无法获得嵌入表达式本身,而只能获得嵌入表达式的值。

模板表达式中的字符串字面量

能够使用字符串字面量或文本块作为模板参数提高了模板表达式的灵活性。开发者可以编写最初在字符串字面量中包含占位符文本的模板表达式,例如

String s = STR."Welcome to your account";
| "Welcome to your account"

并逐渐将表达式嵌入到文本中,以创建一个字符串模板,且不改变任何分隔符或插入任何特殊前缀:

String s = STR."Welcome, \{user.firstName()}, to your account \{user.accountNumber()}";
| "Welcome, Lisa, to your account 12345"

用户自定义模板处理器

前面我们看到了模板处理器 STRFMT,它们让人觉得模板处理器就像是通过字段访问的对象。这是一种有用的简写方式,但更准确地说,模板处理器是一个实现了函数式接口 StringTemplate.Processor 的对象。具体来说,该对象的类实现了该接口的单一抽象方法 process,这个方法接收一个 StringTemplate 并返回一个对象。像 STR 这样的静态字段只是存储了这样一个类的实例。(实际存储在 STR 中的类实例有一个 process 方法,用于执行无状态插值操作,这种情况下使用单例模式是合适的,因此字段名使用大写字母。)

开发者可以轻松创建用于模板表达式的模板处理器。然而,在讨论如何创建模板处理器之前,我们必须先讨论 StringTemplate 类。

StringTemplate 的一个实例表示字符串模板或文本块模板,它作为模板表达式中的模板出现。考虑以下代码:

int x = 10, y = 20;
StringTemplate st = RAW."\{x} plus \{y} equals \{x + y}";
String s = st.toString();
| StringTemplate{ fragments = [ "", " plus ", " equals ", "" ], values = [10, 20, 30] }

结果可能有些出乎意料。102030 插值到文本 " plus "" equals " 中的位置在哪里?回想一下,模板表达式的目标之一是提供安全的字符串组合。如果让 StringTemplate::toString 简单地将 "10"" plus ""20"" equals ""30" 连接成一个 String,这将会绕过该目标。相反,toString 方法呈现了 StringTemplate 的两个有用部分:

  • 文本 片段"", " plus ", " equals ", "",以及
  • 10, 20, 30

StringTemplate 类直接暴露了这些部分:

  • StringTemplate::fragments 返回字符串模板或文本块模板中嵌入表达式前后文本片段的列表:

    int x = 10, y = 20;
    StringTemplate st = RAW."\{x} plus \{y} equals \{x + y}";
    List<String> fragments = st.fragments();
    String result = String.join("\\{}", fragments);
    | "\{} plus \{} equals \{}"
  • StringTemplate::values 返回按源代码中出现顺序评估嵌入表达式所产生的值列表。在当前示例中,这等价于 List.of(x, y, x + y)

    int x = 10, y = 20;
    StringTemplate st = RAW."\{x} plus \{y} equals \{x + y}";
    List<Object> values = st.values();
    | [10, 20, 30]

StringTemplatefragments() 在模板表达式的所有求值过程中都是恒定的,而 values() 则在每次求值时都会重新计算。例如:

int y = 20;
for (int x = 0; x < 3; x++) {
StringTemplate st = RAW."\{x} plus \{y} equals \{x + y}";
System.out.println(st);
}
| ["Adding ", " and ", " yields ", ""](0, 20, 20)
| ["Adding ", " and ", " yields ", ""](1, 20, 21)
| ["Adding ", " and ", " yields ", ""](2, 20, 22)

使用 fragments()values(),我们可以通过将 lambda 表达式传递给静态工厂方法 StringTemplate.Processor::of 来轻松创建一个插值模板处理器:

var INTER = StringTemplate.Processor.of((StringTemplate st) -> {
String placeHolder = "•";
String stencil = String.join(placeHolder, st.fragments());
for (Object value : st.values()) {
String v = String.valueOf(value);
stencil = stencil.replaceFirst(placeHolder, v);
}
return stencil;
});

int x = 10, y = 20;
String s = INTER."\{x} plus \{y} equals \{x + y}";
| 10 plus 20 equals 30

我们可以利用每个模板代表片段和值的交替序列这一事实,通过从片段和值构建结果,使这个插值模板处理器更高效:

var INTER = StringTemplate.Processor.of((StringTemplate st) -> {
StringBuilder sb = new StringBuilder();
Iterator<String> fragIter = st.fragments().iterator();
for (Object value : st.values()) {
sb.append(fragIter.next());
sb.append(value);
}
sb.append(fragIter.next());
return sb.toString();
});

int x = 10, y = 20;
String s = INTER."\{x} plus \{y} equals \{x + y}";
| 10 and 20 equals 30

实用方法 StringTemplate::interpolate 做了同样的事情,依次连接片段和值:

var INTER = StringTemplate.Processor.of(StringTemplate::interpolate);

鉴于嵌入表达式的值通常不可预测,模板处理器对其生成的 String 进行内部化(intern)通常是不值得的。例如,STR 不会对其结果进行内部化。然而,如果需要的话,创建一个进行内部化和插值的模板处理器是非常简单的:

var INTERN = StringTemplate.Processor.of(st -> st.interpolate().intern());

模板处理器 API

到目前为止,所有的示例都是使用工厂方法 StringTemplate.Processor::of 创建模板处理器。这些示例处理器返回 String 类型的实例,并且不会抛出异常,因此使用它们的模板表达式将始终成功求值。

相比之下,直接实现 StringTemplate.Processor 接口的模板处理器可以完全通用。它可以返回任何类型的对象,而不仅仅是 String。如果处理失败,它还可以抛出受检异常,原因可能是模板无效或其它原因,例如 I/O 错误。如果某个处理器抛出受检异常,那么在模板表达式中使用它的开发者必须通过 try-catch 语句来处理这些失败,或者将异常传播给调用者。

StringTemplate.Processor 接口的声明如下:

package java.lang;
public interface StringTemplate {
...
@FunctionalInterface
public interface Processor<R, E extends Throwable> {
R process(StringTemplate st) throws E;
}
...
}

前面显示的插值字符串的代码:

var INTER = StringTemplate.Processor.of(StringTemplate::interpolate);
...
String s = INTER."\{x} plus \{y} equals \{x + y}";

等价于:

StringTemplate.Processor<String, RuntimeException> INTER =
StringTemplate.Processor.of(StringTemplate::interpolate);
...
String s = INTER."\{x} plus \{y} equals \{x + y}";

模板处理器 INTER 的返回类型由第一个类型参数指定,这里是 String。处理器抛出的异常由第二个类型参数指定,在本例中为 RuntimeException,因为此处理器不会抛出任何受检异常。

下面是一个模板处理器,它返回的不是字符串,而是 JSONObject 的实例:

var JSON = StringTemplate.Processor.of(
(StringTemplate st) -> new JSONObject(st.interpolate())
);

String name = "Joan Smith";
String phone = "555-123-4567";
String address = "1 Maple Drive, Anytown";
JSONObject doc = JSON."""
{
"name": "\{name}",
"phone": "\{phone}",
"address": "\{address}"
};
""";

上面的 JSON 声明等价于:

StringTemplate.Processor<JSONObject, RuntimeException> JSON =
StringTemplate.Processor.of(
(StringTemplate st) -> new JSONObject(st.interpolate())
);

比较第一个类型参数 JSONObject 与上面给 INTER 的第一个类型参数 String

这个假设的 JSON 处理器的用户永远看不到 st.interpolate() 生成的 String。然而,以这种方式使用 st.interpolate() 可能会将注入漏洞传播到 JSON 结果中。我们可以谨慎一些,修改代码以先检查模板的值,如果某个值可疑,则抛出一个受检异常 JSONException

StringTemplate.Processor<JSONObject, JSONException> JSON_VALIDATE =
(StringTemplate st) -> {
String quote = "\"";
List<Object> filtered = new ArrayList<>();
for (Object value : st.values()) {
if (value instanceof String str) {
if (str.contains(quote)) {
throw new JSONException("Injection vulnerability");
}
filtered.add(quote + str + quote);
} else if (value instanceof Number ||
value instanceof Boolean) {
filtered.add(value);
} else {
throw new JSONException("Invalid value type");
}
}
String jsonSource =
StringTemplate.interpolate(st.fragments(), filtered);
return new JSONObject(jsonSource);
};

String name = "Joan Smith";
String phone = "555-123-4567";
String address = "1 Maple Drive, Anytown";
try {
JSONObject doc = JSON_VALIDATE."""
{
"name": \{name},
"phone": \{phone},
"address": \{address}
};
""";
} catch (JSONException ex) {
...
}

此版本的处理器会抛出一个受检异常,因此我们无法使用工厂方法 StringTemplate.Processor::of 来创建它。相反,我们直接在右侧使用 lambda 表达式。而这又意味着我们无法在左侧使用 var,因为 Java 要求为 lambda 表达式指定明确的目标类型。

为了提高效率,我们可以通过将模板片段编译为带有占位符值的 JSONObject 并缓存结果,来 记忆化 此处理器。如果处理器的下一次调用使用了相同的片段,则它可以将嵌入表达式的值注入到缓存对象的新深层副本中;这样在任何地方都不会有中间的 String

安全地编写和执行数据库查询

下面的模板处理器类 QueryBuilder 首先从字符串模板创建一个 SQL 查询字符串。然后,它从该查询字符串创建一个 JDBC PreparedStatement,并将其参数设置为嵌入表达式的值。

record QueryBuilder(Connection conn)
implements StringTemplate.Processor<PreparedStatement, SQLException> {

public PreparedStatement process(StringTemplate st) throws SQLException {
// 1. Replace StringTemplate placeholders with PreparedStatement placeholders
String query = String.join("?", st.fragments());

// 2. Create the PreparedStatement on the connection
PreparedStatement ps = conn.prepareStatement(query);

// 3. Set parameters of the PreparedStatement
int index = 1;
for (Object value : st.values()) {
switch (value) {
case Integer i -> ps.setInt(index++, i);
case Float f -> ps.setFloat(index++, f);
case Double d -> ps.setDouble(index++, d);
case Boolean b -> ps.setBoolean(index++, b);
default -> ps.setString(index++, String.valueOf(value));
}
}

return ps;
}
}

如果我们为特定的 Connection 实例化这个假设的 QueryBuilder

var DB = new QueryBuilder(conn);

然后代替不安全的、容易受到注入攻击的代码

String query = "SELECT * FROM Person p WHERE p.last_name = '" + name + "'";
ResultSet rs = conn.createStatement().executeQuery(query);

我们可以编写更安全、更易读的代码

PreparedStatement ps = DB."SELECT * FROM Person p WHERE p.last_name = \{name}";
ResultSet rs = ps.executeQuery();

让模板处理器本身执行查询并返回 ResultSet,从而让客户端编写:ResultSet rs = DB."SELECT ...";,这看起来似乎很方便。然而,模板处理器触发可能长时间运行的操作以生成结果是不明智的。同样,开始具有副作用的操作(例如更新数据库)也是不明智的。强烈建议模板处理器的作者专注于验证其输入,并生成一个为客户端提供最大灵活性的结果。

简化本地化

FMT 模板处理器,如前所示,是模板处理器类 java.util.FormatProcessor 的一个实例。虽然 FMT 使用默认的区域设置,但通过不同方式实例化该类,可以轻松创建适用于其他区域设置的模板处理器。例如,以下代码为泰语区域创建了一个模板处理器:

Locale thaiLocale = Locale.forLanguageTag("th-TH-u-nu-thai");
FormatProcessor THAI = new FormatProcessor(thaiLocale);
for (int i = 1; i <= 10000; i *= 10) {
String s = THAI."This answer is %5d\{i}";
System.out.println(s);
}
| This answer is ๑
| This answer is ๑๐
| This answer is ๑๐๐
| This answer is ๑๐๐๐
| This answer is ๑๐๐๐๐

简化资源包的使用

下面的模板处理器类 LocalizationProcessor 简化了资源包的操作。对于给定的区域设置,它将字符串映射到资源包中的相应属性。

record LocalizationProcessor(Locale locale)
implements StringTemplate.Processor<String, RuntimeException> {

public String process(StringTemplate st) {
ResourceBundle resource = ResourceBundle.getBundle("resources", locale);
String stencil = String.join("_", st.fragments());
String msgFormat = resource.getString(stencil.replace(' ', '.'));
return MessageFormat.format(msgFormat, st.values().toArray());
}
}

假设每个语言环境都有一个属性文件资源包:

# resources_en_CA.properties file
no.suitable._.found.for._(_)=\
no suitable {0} found for {1}({2})

# resources_zh_CN.properties file
no.suitable._.found.for._(_)=\
\u5BF9\u4E8E{1}({2}), \u627E\u4E0D\u5230\u5408\u9002\u7684{0}

# resources_jp.properties file
no.suitable._.found.for._(_)=\
{1}\u306B\u9069\u5207\u306A{0}\u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093({2})

然后,程序可以根据属性组成一个本地化的字符串:

var userLocale = Locale.of("en", "CA");
var LOCALIZE = new LocalizationProcessor(userLocale);
...
var symbolKind = "field", name = "tax", type = "double";
System.out.println(LOCALIZE."no suitable \{symbolKind} found for \{name}(\{type})");

模板处理器会将字符串映射到本地化资源包中相应的属性:

no suitable field found for tax(double)

如果程序转而执行了

var userLocale = Locale.of("zh", "CN");

那么输出将是:

对于tax(double), 找不到合适的field

最后,如果程序执行了

var userLocale = Locale.of("ja");

那么输出将是:

taxに適切なfieldが見つかりません(double)

替代方案

  • 当字符串模板在没有模板处理器的情况下出现时,我们可以简单地执行基本的插值。然而,这种选择会违反安全目标。例如,使用插值来构建 SQL 查询会太过于诱人,这总体上会降低 Java 程序的安全性。始终要求一个模板处理器可以确保开发人员至少认识到字符串模板中特定领域规则的可能性。

  • 模板表达式的语法 —— 模板处理器出现在首位 —— 并不是严格必要的。可以将模板处理器表示为 StringTemplate::process 的参数。例如:

    String s = "The answer is %5d\{i}".process(FMT);

    首选让模板处理器出现在第一位是因为评估模板表达式的结果 完全 取决于模板处理器的操作。

  • 对于嵌入式表达式的语法,我们考虑过使用 ${...},但这需要在字符串模板上添加标记(前缀或不同于 " 的分隔符),以避免与遗留代码冲突。我们还考虑了 \[...]\(...),但 [ ]( ) 很可能出现在嵌入式表达式中;而 { } 出现的可能性较小,因此从视觉上更容易确定嵌入式表达式的起始和结束位置。

  • 可以像 C# 中那样将格式说明符直接嵌入到字符串模板中:

    var date = DateTime.Now;
    Console.WriteLine($"The time is {date:HH:mm}");

    但这样每次引入新的格式说明符时都需要对 Java 语言规范进行更改。

风险与假设

java.util.FormatProcessor 的实现严重依赖于 java.util.Formatter,这可能需要进行大量的重写。