字符串模板(预览)

2023/07/05

概括

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

目标

  • 通过轻松表达包含运行时计算值的字符串,简化Java程序的编写。
  • 增强混合文本和表达式的表达式的可读性,无论文本适合单个源行(如字符串文本)还是跨越多个源行(如文本块)。
  • 通过支持模板及其嵌入表达式的值的验证和转换,提高从用户提供的值组成字符串并将其传递给其他系统(例如,为数据库构建查询)的Java程序的安全性。
  • 通过允许Java库定义字符串模板中使用的格式化语法来保持灵活性。
  • 简化接受非Java语言(例如SQL、XML和JSON)编写的字符串的API的使用。
  • 允许创建根据文字文本和嵌入表达式计算的非字符串值,而无需通过中间字符串表示形式进行传输。

非目标

  • 为Java的字符串拼接运算符(+)引入语法糖并不是我们的目标,因为这会绕过验证的目标。
  • 弃用或删除StringBuilder和StringBuffer类并不是我们的目标,它们传统上用于复杂或编程化字符串组合。

动机

开发人员通常会根据文字文本和表达式的组合来组成字符串。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::format和String::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编程语言不可能强制执行所有此类规则,因此开发人员需要使用插值来进行验证和清理。通常,这意味着记住将嵌入表达式包装在对escape或validate方法的调用中,并依靠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. 包含嵌入表达式(\{name})的模板(“My name is \{name}”)

当在运行时计算模板表达式时,其模板处理器将模板中的文本与嵌入表达式的值组合起来,以生成结果。模板处理器的结果,以及模板表达式的求值结果,通常是一个字符串-尽管并非总是如此。

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对象的标准模板处理器。

模板表达式的设计故意使得不可能直接从具有嵌入表达式的字符串文本或文本块转换为具有表达式值内插的字符串,这可以防止危险的错误字符串在程序中传播。字符串文本由模板处理器处理,模板处理器明确负责安全地插值和验证结果(字符串或其他内容)。因此,如果我们忘记使用模板处理器(例如STR、RAW、FMT),则会报告编译时错误:

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编译器扫描术语”…“并根据嵌入表达式的存在来确定是将其解析为字符串文本还是字符串模板。编译器类似地扫描术语”"”…”"”并确定是将其解析为文本块还是文本块模板。我们统一将这些术语的…部分称为字符串文本、字符串模板、文本块或文本块模板的内容。

我们强烈鼓励IDE在视觉上区分字符串模板和字符串文本,以及文本块模板和文本块。在字符串模板或文本块模板的内容中,IDE应直观地将嵌入表达式与文本区分开来。

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

在运行时,按如下方式计算模板表达式:

  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"

用户定义的模板处理器

之前我们看到了模板处理器STR和FMT,这使得模板处理器看起来像是通过字段访问的对象。这是有用的简写,但更准确地说,模板处理器是一个对象,它是函数接口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] }

结果也许是令人惊讶的。10、20和30插入到文本” 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]
    

StringTemplate的fragments()在模板表达式的所有计算中都是恒定的,而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);

鉴于嵌入表达式的值通常是不可预测的,模板处理器通常不值得池化它生成的字符串。例如,STR不会池化其结果。但是,如果需要的话,创建一个池化和插值模板处理器是很简单的:

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

模板处理器API

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

相比之下,直接实现StringTemplate.Processor接口的模板处理器可以是完全通用的,它可以返回任何类型的对象,而不仅仅是字符串。如果处理失败(由于模板无效或某些其他原因(例如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()生成的字符串。但是,以这种方式使用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并缓存结果来记忆该处理器。如果处理器的下一次调用使用相同的片段,那么它可以将嵌入表达式的值注入到缓存对象的新深层副本中;任何地方都不会有中间字符串。

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

下面的模板处理器类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);
    

    首先指定模板处理器是更可取的,因为计算模板表达式的结果完全取决于模板处理器的操作。

  • 对于嵌入表达式的语法,我们考虑使用${…},但这需要在字符串模板上有一个标记(前缀或除”之外的分隔符)以避免与遗留代码发生冲突。我们还考虑了\[…]and\(…),但是[]和()很可能出现在嵌入表达式中;{}出现的可能性较小,因此直观地确定嵌入表达式的开始和结束会更容易。

  • 可以将格式说明符烘焙到字符串模板中,如C#中所做的那样:

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

    但这需要在引入新的格式说明符时随时更改Java语言规范。

风险和假设

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

Show Disqus Comments

Post Directory

扫码关注公众号:Taketoday
发送 290992
即可立即永久解锁本站全部文章