Skip to content

JEP 459: String Templates (Second Preview) | 字符串模板(第二次预览)

摘要

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

历史

字符串模板最初由 JEP 430 在 JDK 21 中作为预览特性提出。我们在这里提出第二次预览,以获得更多的经验和反馈。除了模板表达式类型的技术变化外,与第一次预览相比没有变化。

目标

  • 通过使编写包含在运行时计算的值的字符串变得容易,简化 Java 程序的编写。

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

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

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

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

  • 能够从字面文本和嵌入表达式创建非字符串值,而无需通过中间字符串表示进行转换。

非目标

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

  • 弃用或删除 StringBuilderStringBuffer 类不是目标,这些类传统上用于复杂或程序化的字符串组合。

动机

开发人员通常从字面文本和表达式的组合中组成字符串。Java 语言和 API 提供了几种字符串组合机制,但不幸的是都有缺点。

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

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

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

    java
    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 需要太多的仪式感,并且在格式字符串中使用不熟悉的语法:

    java
    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}"
Pythonf"{x} plus {y} equals {x + y}"
Scalas" $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 中:

java
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 代码:

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

那么查询字符串将是

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

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

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

我们能做得更好吗?

对于 Java 平台,我们希望有一个字符串组合功能,它可以实现插值的清晰度,但开箱即用即可实现更安全的结果,也许可以牺牲一点便利性来获得更大的安全性。

例如,在组成 SQL 语句时,嵌入表达式的值中的任何引号都必须转义,并且整个字符串必须有平衡的引号。对于上面显示的麻烦的 name 值,应该组成的查询是一个安全的查询:

sql
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

java
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 编程语言中的一种新表达式。模板表达式可以执行字符串插值,但也可以以一种有助于开发人员安全高效地组合字符串的方式进行编程。此外,模板表达式不仅限于组合字符串——它们可以根据特定领域的规则将结构化文本转换为任何类型的对象。

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

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 是一个 public static final 字段,会自动导入到每个 Java 源文件中。

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

java
// 嵌入表达式可以是字符串
String firstName = "Bill";
String lastName = "Duck";
String fullName = STR."\{firstName} \{lastName}";
| "Bill Duck"
String sortName = STR."\{lastName}, \{firstName}";
| "Duck, Bill"

// 嵌入表达式可以进行算术运算
int x = 10, y = 20;
String s = STR."\{x} + \{y} = \{x + y}";
| "10 + 20 = 30"

// 嵌入表达式可以调用方法和访问字段
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"

为了便于重构,双引号字符可以在嵌入表达式中使用而无需像 \" 那样进行转义。这意味着嵌入表达式可以在模板表达式中出现的方式与在模板表达式之外出现的方式完全相同,从而简化了从连接(+)到模板表达式的切换。例如:

java
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""The file tmp.dat does not exist"

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

java
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"

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

java
// 嵌入表达式可以是后缀递增表达式
int index = 0;
String data = STR."\{index++}, \{index++}, \{index++}, \{index++}";
| "0, 1, 2, 3"

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

java
// 嵌入表达式是一个(嵌套的)模板表达式
String[] fruit = { "apples", "oranges", "peaches" };
String s = STR."\{fruit[0]}, \{STR."\{fruit[1]}, \{fruit[2]}"}";
| "apples, oranges, peaches"

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

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

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

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

多行模板表达式

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

以下是表示 HTML 文本、JSON 文本和区域表的模板表达式的例子,它们都跨越多行:

java
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 平台中定义的另一个模板处理器。FMTSTR 类似,它执行插值,但它也解释出现在嵌入表达式左侧的格式说明符。格式说明符与在 java.util.Formatter 中定义的相同。这里是区域表的例子,通过模板中的格式说明符进行整理:

java
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 方法的快捷方式。也就是说,现在熟悉的例子:

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

等同于:

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

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

模板表达式的设计故意使得不可能直接从带有嵌入表达式的字符串字面量或文本块转换为带有插值表达式值的 String。这可以防止危险的错误字符串在程序中传播。字符串字面量由模板处理器处理,模板处理器明确负责安全地插值和验证结果(无论是 String 还是其他类型)。因此,如果我们忘记使用像 STRRAWFMT 这样的模板处理器,那么就会报告一个编译时错误:

java
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:
  类似于字符串字面量,但有一个或多个嵌入表达式

TextBlockTemplate:
  类似于文本块,但有一个或多个嵌入表达式

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

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

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

TemplateExpression 中,TemplateProcessor 的类型必须是 StringTemplate.Processor 的子类型。模板表达式的类型是 TemplateProcessor 类型中 process(StringTemplate) 方法的返回类型。如果该方法抛出已检查异常,那么模板表达式必须被包装在一个 try-catch 块中,或者封闭方法必须声明它抛出那些异常;更多细节,请见 下面

在运行时,模板表达式的评估如下:

  1. 评估 TemplateProcessor 表达式以获得 StringTemplate.Processor 接口的实例,即一个模板处理器。

  2. 评估 TemplateArgument 表达式以获得 StringTemplate 的实例。

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

模板处理器在运行时执行,而不是在编译时执行,所以它们不能对模板进行编译时处理。它们也不能获得源代码中模板中出现的确切字符;只有嵌入表达式的值是可用的,而不是嵌入表达式本身。

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

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

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

然后逐渐将表达式嵌入到文本中以创建一个字符串模板,而无需更改任何分隔符或插入任何特殊前缀:

java
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 的实例表示在模板表达式中作为模板出现的字符串模板或文本块模板。考虑这段代码:

java
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 ", "",以及
  • 102030

StringTemplate 类直接公开这些部分:

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

    java
    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)

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

StringTemplatefragments() 在模板表达式的所有评估中是恒定的,而 values() 在每次评估时都是新计算的。例如:

java
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 来轻松创建一个插值模板处理器:

java
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

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

java
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 做同样的事情,连续地连接片段和值:

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

鉴于嵌入表达式的值通常是不可预测的,对于模板处理器来说,对它生成的 String 进行内部化通常是不值得的。例如,STR 不会内部化它的结果。然而,如果需要,可以很容易地创建一个内部化和插值的模板处理器:

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

模板处理器 API

到目前为止,所有的例子都是使用工厂方法 StringTemplate.Processor::of 来创建模板处理器。这些示例处理器返回 String 的实例并且不抛出任何异常,所以使用它们的模板表达式总是会成功地进行求值。

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

StringTemplate.Processor 接口的声明如下:

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

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

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

等同于:

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

模板表达式 INTER."..." 的类型由 INTER 类型的第一个类型参数指定,即 String。模板处理器 INTER 抛出的已检查异常由 INTER 类型的第二个类型参数指定。INTER 不抛出已检查异常,但是由于第二个类型参数是强制的,我们必须通过指定一个未检查异常(RuntimeException)来表达这个事实。

这里是一个不返回字符串而是返回 JSONObject 实例的模板处理器:

java
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 的声明等同于:

java
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

java
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,因为语言要求 lambda 表达式有一个显式的目标类型。

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

安全地组合和执行数据库查询

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

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

    public PreparedStatement process(StringTemplate st) throws SQLException {
        // 1. 用 PreparedStatement 的占位符替换 StringTemplate 的占位符
        String query = String.join("?", st.fragments());

        // 2. 在连接上创建 PreparedStatement
        PreparedStatement ps = conn.prepareStatement(query);

        // 3. 设置 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

java
var DB = new QueryBuilder(conn);

那么,代替不安全、容易受到注入攻击的代码:

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

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

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

让模板处理器本身执行查询并返回 ResultSet 可能看起来很方便,这样我们就可以简单地写成:

java
ResultSet rs = DB."SELECT...";

然而,让模板处理器为了组合一个结果而触发可能长时间运行的操作是不明智的。让处理器执行可能有副作用的操作,例如更新数据库,也是不明智的。强烈建议模板处理器的作者专注于验证他们的输入并组合一个给调用者最大灵活性的结果。

简化本地化

前面展示过的FMT 模板处理器是模板处理器类 java.util.FormatProcessor 的一个实例。虽然 FMT 使用默认的区域设置,但是通过不同的方式实例化这个类来为不同的区域设置创建一个模板处理器是很简单的。例如,这段代码为泰语区域设置创建一个模板处理器:

java
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 简化了对资源包的使用。对于给定的区域设置,它将一个字符串映射到资源包中的相应属性。

java
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 文件
no.suitable._.found.for._(_)=\
    no suitable {0} found for {1}({2})

# resources_zh_CN.properties 文件
no.suitable._.found.for._(_)=\
    对于{1}({2}),找不到合适的{0}

# resources_jp.properties 文件
no.suitable._.found.for._(_)=\
    {1}に適切な{0}が見つかりません({2})

那么程序可以基于该属性组合一个本地化的字符串:

java
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)

如果程序改为执行:

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

那么输出将是:

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

最后,如果程序改为执行:

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

那么输出将是:

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

替代方案

  • 一种替代设计是允许没有模板处理器的字符串模板,并执行基本的字符串插值。例如:

    java
    String s = "\{x} + \{y} = \{x + y}";

    然而,这种设计将违反安全目标。例如,使用插值构建 SQL 查询会很诱人,这总体上会降低 Java 程序的安全性。始终要求一个模板处理器确保开发人员至少认识到字符串模板中特定领域规则的可能性。

  • 模板处理器的视觉突出性,即 STR 出现在字符串模板之前,并不是严格必要的。相反,我们可以将处理器作为参数传递给 StringTemplate::process 方法。例如:

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

    然而,让模板处理器首先出现是更可取的,因为评估模板表达式的结果完全依赖于模板处理器的操作。

  • 对于字符串模板中的嵌入表达式,我们考虑采用 Java EE 表达式语言(EL) 中的 ${...} 语法而不是 \{...}。然而,这将迫使开发人员在字符串模板的字面文本中转义每个 $ 符号,同时在字符串字面量和文本块中继续不转义地编写 $。这种不一致很容易导致错误。

    此外,在字符串字面量和文本块中使用 EL 的代码迁移到字符串模板会特别棘手:${...} 的含义会改变,因为现在它将由 Java 语言而不是框架确定。例如,字符串模板 "Hello ${user.name}" 将不是指 EL 解释器上下文中的 user 对象,而是指源代码中作用域内的 user 变量。再例如,包含嵌入表达式 ${header['user-agent']} 或嵌入表达式 ${empty param.Customer} 的字符串模板将无法编译,因为 header['user-agent']empty param.Customer 不是合法的 Java 表达式。必须转义大多数 EL 表达式中的 $ 符号,以便 Java 语言不将它们视为自己的嵌入表达式,这会很痛苦。

    对于我们选择的语法,框架可以通过提供理解 EL 语法的模板处理器来简化迁移。开发人员可以继续使用 ${...},同时也可以通过 \{...} 访问 Java 环境:

    java
    EL."""
      Hello ${user.name}
      \{server.isBusy()? "Please try later" : ""}
    """
  • 我们也考虑过使用不同的分隔符,如 \[...]\(...),但是 [ ]( ) 很可能出现在嵌入表达式中。{ } 不太可能出现,这使得更容易在视觉上确定嵌入表达式的开始和结束。

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

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

    然而,这将要求每次引入新的格式说明符时都要更改 Java 语言规范。