Java 字符串模板尝鲜:丑?快点端上来吧

3,082 阅读10分钟

我正在参加「掘金·启航计划」

本文以 CC-BY-SA 4.0 发布。

Java 这几年其实一直在加入各种大众期待已久的功能,例如多行字符串、var 语法、模式匹配, 更不用说直接操刀 JVM 的各种 Project Loom, Panama, Lilliput, Valhalla 了。 说是抄其它语言也好,说是终于开始追赶时代潮流也好,总之作为业余开发者的我当然是非常乐于见到这些改动的。

今天要介绍的是字符串模板(String Templates)。本篇文章将从模板语法开始,在 JDK 21 (early access) 上对常用用法、其内部原理以及自定义模板进行介绍。

馋!其它语言的字符串模板

支持字符串模板的编程语言现在数都数不完。大家耳熟能详的 JavaScript 或是竞争对手的 Kotlin 当然不用说:

`${x}${y} 等于 ${x + y}`
"$x$y 等于 ${x + y}"

就连和 Java 在同年代出生的 Visual Basic 也在 Visual Basic 14 里加入了字符串插值(String Interpolation):

$"{x} 加 {y} 等于 {x + y}"

而我们的 Java 就只能:

x + " 加 " + y + " 等于 " + (x + y)

可以说是平时写一次这种代码就羡慕一次其它语言了🍋🍋🍋。 JEP 430 就是在 Java 中引入字符串模板的提案,而在 JDK 21 这个提案终于进入了预览。

在 JDK 21 下尝鲜字符串模板

安装

不同系统下安装 JDK 的方法也不同。因为 JDK 21 现在还没有正式发布,现在要尝试的话可以从 OpenJDK JDK 21 Early-Access Builds 这里下载自己系统对应的包,如果用的是 Arch Linux 系统的话倒是可以直接从 AUR 安装 java-openjdk-ea-bin

$ java -version
openjdk version "21-ea" 2023-09-19
OpenJDK Runtime Environment (build 21-ea+27-2343)
OpenJDK 64-Bit Server VM (build 21-ea+27-2343, mixed mode, sharing)

在编译和运行时启用预览特性

因为字符串模板只是预览特性,我们必须手动启用才能让我们的代码跑起来。

让我们先从最简单的 Hello World 例子开始:

class Main {
    public static void main(String[] args) {
        String world = "World";
        System.out.println(STR."Hello \{world}!");
    }
}

将上面的代码保存为 Main.java 后,我们用下面的命令编译:

$ javac --enable-preview -source 21 Main.java
Note: Main.java uses preview features of Java SE 21.
Note: Recompile with -Xlint:preview for details.
$ java --enable-preview Main
Hello World!

可以看到上面的 STR."Hello \{world}!" 拼接成了 "Hello World!"

语法

新的字符串模板的语法挺……别致的…… 抛去审美不说,我们先来简单介绍一下它的语法,然后再来看看好好的字符串模板为什么长成了这个样子。

String s = STR."\{x} + \{y} = \{x + y}";
String f = FMT."1.0 + 1.0 = %.9f\{1.0 + 1.0}";

大体上,新的字符串模板由三部分构成:

  • STR 或是 FMT 等的字符串处理器(StringTemplate.Processor
  • 一个点 .
  • 看起来普通的字符串,但嵌入了诸如 \{x + y} 等的表达式

字符串处理器

JavaScript 等语言里的字符串模板基本上只是字符串拼接的语法糖, 但是在例如 C# 或是 Scala 等语言里我们可以看到更加强大的字符串模板:

var blogs = context.Blogs
    .FromSql($"SELECT * FROM [Blogs] WHERE {propertyName} = {propertyValue}")
    .ToList();
val table = "coffees"
sql"select * from #$table where name = $name".as[Coffee].headOption
flowchart LR
    subgraph Params[" "]
        Template[字符串模板]
        Parameters[模板参数]
    end
    subgraph SQL 拼接
        direction LR
        Template --> Processor2{SQL 处理器}
        Parameters --> Processor2 --> Result2[安全 SQL 命令]
    end
    subgraph 普通字符串拼接
        direction LR
        Template --> Processor{直接拼接}
        Parameters --> Processor --> Result[完整字符串]
    end

和普通的字符串拼接不同,上面的两种语言的代码都无需担心 SQL 注入。 例如,实际上,C# 里的 FromSql 的参数并不是字符串,而是代表了字符串拼接操作本身的 FormattableString,包含了:

  • 字符串模板,如 SELECT * FROM Blogs WHERE {0} = {1}
  • 插入的参数组成的数组。

FromSql 把插入的参数里的特殊字符转义之后再插入到字符串模板里从而防止了 SQL 注入; 与其相比,普通的字符串模板则是不经任何处理就直接插入。 这两者的过程其实几乎一致,上面的语言和 Java 也就是把两种过程之间的差异提取到了“字符串处理器”里而已。

字符串模板

STR."Hello \{world}!" 后面的 "Hello \{world}!" 也与其它语言里的字符串模板语法类似—— 用某种成对的括号把需要插入的值直接括起来,直接写在字符串当中。 当然,这与 Shell 或是 JavaScript 里的 ${value} 或是 Python 里的 {value} 稍有不同,它用了一个乍一看是转义大括号的语法来标识插入值。

这种写法其实被挺多人嫌弃的,毕竟看起来直观理解就是转义大括号嘛, 再加上使用 $value 或是 ${value} 的语言实在是多,从熟悉度上看,大多数程序员都能够反应过来 $value 是字符串模板; 而不熟悉 Java 的人大概面对 \{value} 的第一印象也只能是什么正则里面的大括号转义了。

JEP 里面使用这种语法的理由主要是向后兼容性,因为 \{ 在普通 Java 字符串里是非法的,所以这样能够使用户迁移过来更方便一些。 但是,我个人找到的最能说服我自己的理由则是: ${ 都是合法字符,此时正常使用它们反而需要转义了, 而不合法的 \{ 则不仅不需要太多转义,而且非常适合各种嵌套:

class Array {
    public static void main(String[] args) {
        String world = "World";
        System.out.println(STR."Hello \{
            (new String[] { STR."\{world}!" })[0]
        }");
    }
}

而 Python 里面的 f"{"world"}" 则会报错,你必须使用 f"""{"world"}""" 或者 f"{'world'}"

另外,“转义”和其底层实现也是一致的。我们在下面的字节码原理分析部分会看到, STR."Hello \{world}! 编译器对应生成的模板其实是 Hello \u0001!。 这一点虽然可能和语法设计本身没有半毛钱关系,但可能可以帮助记忆这个语法。

顺便一提,这里也可以直接用 Java 15 里加入的多行字符串:

String s = STR."""
<html>
  <head><title>\{title}</title>
  <body><h1>\{heading}</h1></body>
</html>
""";

常见字符串处理器用法及原理

虽然说是“常见”但毕竟大多数人都还没开始用 Java 字符串模板呢,也谈不上什么常用。 这里列举的是三个 Java 自带的字符串处理器的用法,并且附带一个用户自行实现字符串处理器的示例。

STR: 普通的字符串拼接

我们在上面已经多次用到了 STR 这个字符串处理器,它实现的就是最普通的字符串拼接。例如 STR."Hello \{world}!" 就会把 world 变量的值给直接插进字符串里。

STR 字节码原理

看起来和普通的字符串拼接相比,STR."Hello \{world}!" 会经过 STR 的处理,而 "Hello" + world + "!" 则可以直接进行拼接。 那么,是不是用 STR 反而性能会更差? 我在实际尝试之前还是有点怀疑性能的,但是编译完一看,根本不用性能测试了——二者的字节码不能说完全一致,只能说一模一样。

Java 8 和 Java 9 下 "Hello" + world + "!" 的编译结果完全不同—— Java 8 就是普通的 StringBuilder,但Java 9 会用到 InvokeDynamic 这个特殊的字节码来把字符串的拼接完全交给 JVM。

对于 Java 9 中字符串拼接我已经另外写了一篇文章:还在无脑用 StringBuilder?来重温一下字符串拼接吧。 简单来说,Java 9 下 "Hello" + world + "!" 会被编译为 Hello \u0001! 的模板,在运行时自动生成最优的拼接方法, InvokeDynamic 调用该方法得到拼接结果 拼接结果 = 拼接方法(world);

也就是说,使用 STR 的字符串拼接与普通的字符串加加加完全等效——字节码完全相同,性能也不会有一丝一毫的差异。 这里的 STR 理论上说来其实是 java.lang.StringTemplate.STR(这个倒是不用手动 import), 但是编译器为了性能没有实际调用 STR 而是直接生成了字符串拼接对应的字节码。

RAW: 直接获取字符串模板

STR 把字符串处理器的调用优化掉了,那实际的字符串处理器到底是怎么工作的呢? 理论上,如果编译器不优化的话,使用 STR."Hello \{world}!" 等效于下面的调用:

// 需要 import static java.lang.StringTemplate.RAW;
STR.process(RAW."Hello \{world}!");

字符串的接口是 java.lang.StringTemplate.Processor,里面只有一个方法 R process(StringTemplate stringTemplate)。 也就是说,上面 RAW 的作用就是显式生成一个代表字符串模板的 Java 对象(java.lang.StringTemplate), 而所有的(除了特殊优化的 STR)字符串处理器的使用都基于这样的 StringTemplate 对象。

flowchart LR
    subgraph Params[" "]
        Template[字符串模板]
        Parameters[模板参数]
    end
    subgraph RAW["RAW"]
        StringTemplate
    end
    Template --> StringTemplate
    Parameters --> StringTemplate
    subgraph STR["普通字符串拼接(STR)"]
        direction LR
        StringTemplate --> Processor{直接拼接} --> Result[完整字符串]
    end

我们来观察一下这个过程:

import static java.lang.StringTemplate.RAW;

class Raw {
    public static void main(String[] args) {
        String world = "World";
        System.out.println(RAW."Hello \{world}!");
    }
}

输出结果是 StringTemplate{ fragments = [ "Hello ", "!" ], values = [World] }, 也就是把 "Hello " + world + "!" 里的 "Hello", "!" 给整理到 fragments 里,把依赖的变量值 world = "World" 给整理到了 values 里。

RAW 字节码原理

仔细想想这是个鸡生蛋的问题:RAW 是用来生成 StringTemplate 的, 但是 RAW 它其实是个字符串处理器(StringTemplate.Processor),那么它先需要一个 StringTemplate 的参数才能返回一个 StringTemplate 值。 那么 RAW.process(RAW.process(RAW.process(...))) 的尽头到底是什么?

那当然是编译器从中插的手。

实际上,虽然我们需要 import static java.lang.StringTemplate.RAW; 才能使用 RAW, 但是 RAW."Hello \{world}!" 的编译结果里连 RAW 的影子都看不见。 取而代之的是我们熟悉的 InvokeDynamic 字节码。

不熟悉?继续看吧。(或者也可以看看还在无脑用 StringBuilder?来重温一下字符串拼接吧。)

InvokeDynamic 说到底就分为两步:

  1. JVM 自动调用字节码指定的方法来生成一个包装有运算逻辑的 CallSite 对象
  2. 当前的以及之后的所有 InvokeDynamic 都会直接调用这个 CallSite 来进行相关的逻辑运算

RAW 这个例子里:

  1. JVM 调用 java.lang.runtime.TemplateRuntime::newStringTemplate 来生成一个 CallSite,可以看作是 StringTemplate 的工厂对象
  2. 之后所有的 RAW."Hello \{world}!" 都会自动使用这个工厂对象来创建,根据不同的 world 值生成不同的 StringTemplate 对象。

我们来手动使用一下:

import java.lang.invoke.CallSite;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;
import java.lang.runtime.TemplateRuntime;
import java.lang.StringTemplate;

class RawManual {
    public static void main(String[] args) throws Throwable {
        String world = "World";

        // RAW."Hello \{world}!"
        // 第一步,生成 CallSite
        CallSite factory = TemplateRuntime.newStringTemplate(
            MethodHandles.lookup(),
            "",
            MethodType.methodType(StringTemplate.class, String.class),
            "Hello ",  // <---
            "!"        // <---
        );
        // 第二步,传入不同的 world 值即可生成不同的 StringTemplate
        StringTemplate t = (StringTemplate) factory.dynamicInvoker().invokeExact(world);
        System.out.println(t);
    }
}

输出结果是 StringTemplate{ fragments = [ "Hello ", "!" ], values = [World] },和上面的 RAW."Hello \{world}!" 的例子一致。

从上面手动使用的代码可以看出,第一步生成的 CallSite 里面保存了字符串模板里面的不变值 "Hello ", "!", 而可变值通过后面的 invokeExact 传入。 但是,Java 参数传递存在参数数量上限(最多 255 个参数),此时 invokeExact 有可能没办法传递所有的插值 (例如 RAW."\{v1}\{v2}...\{v512}"),此时编译器会使用 TemplateRuntime::newLargeStringTemplate,用数组来传递参数。 个人认为这里还有点优化的空间,这里就不详细介绍了。

FMT: 格式化

FMT 其实就是支持类似 String.format 的字符串处理器。限于目前的语法,现在使用的形式大概是这个样子:

// 需要 import static java.util.FormatProcessor.FMT;
String f = FMT."1.0 + 1.0 = %.9f\{1.0 + 1.0}";

个人感觉有点别扭,但也不是不能用。因为用法和 String.format 一致,这里就不多加介绍了。

想要支持不同国家/语言下的格式的话可以用 FormatProcessor::create 来创建自己的其它字符串处理器。

FMT 字节码原理

从上面 RAW 来看的话,我们也许会猜想这里的字节码应该会是类似 FMT.process(RAW."%.9f\{x + y}") 的形式。 但是,如果你实际去反汇编的话,你会发现代码里一次 FMT.process 的调用都没有。

编译器:我还能变身三次我还能继续优化

还是我们熟悉的 InvokeDynamic 指令。当然,再继续解释就有点无趣了,直接看图吧。

flowchart LR
    subgraph Params[" "]
        Template[字符串模板]
        Parameters[模板参数]
    end
    subgraph InvokeDynamic["FMT 的 InvokeDynamic"]
        subgraph RAW["RAW 的 InvokeDynamic"]
            StringTemplate
        end
        Template --> StringTemplate
        Parameters --> StringTemplate
        subgraph STR["FMT"]
            direction LR
            StringTemplate --> Processor{拼接}
        end
    end
    Processor --> Result[字符串]

XML: 我们自己来实现一个

我们要实现的字符串处理器非常简单:

  • 默认把 "'<>& 这五个字符全部给转义:XML."<h1>\{title}</h1>"
  • 如果插入位置前面有 & 符号的话,将 & 去除,加入的字符串无需转义:XML."<html>&\{html}</html>"

太过简单,直接贴代码了:

import java.util.List;

class Xml implements StringTemplate.Processor<String, RuntimeException> {
    public static final Xml XML = new Xml();
    public static void main(String[] args) {
        String injection = "<script>alert(42)</script>";
        System.out.println(XML."<h1>\{injection}</h1>");
        System.out.println(XML."<html>&\{injection}</html>");
    }

    @Override
    public String process(StringTemplate template) {
        StringBuilder builder = new StringBuilder();
        // 字符串模板里的固定内容,注意是它和 values 都是只读的。
        List<String> fragments = template.fragments();
        // 字符串模板里需要插入的插值内容,长度一般比 fragments 小 1。
        List<Object> values = template.values();
        int i;
        for (i = 0; i < fragments.size() - 1; i++) {
            String fragment = fragments.get(i);
            String value = (String) values.get(i);
            if (fragment.endsWith("&")) {
                builder.append(fragment, 0, fragment.length() - 1);
                builder.append(value);
            } else {
                builder.append(fragment);
                builder.append(value
                               .replace("&", "&amp;")
                               .replace("\"", "&quot;")
                               .replace("'", "&apos;")
                               .replace("<", "&lt;")
                               .replace(">", "&gt;"));
            }
        }
        builder.append(fragments.get(i));
        // 返回 String 就行,泛型改一改就可以返回其它的类型。
        return builder.toString();
    }
}

总体上,我们实现字符串处理器时只需要实现 StringTemplate.Processor 接口, 利用 process 方法里的 StringTemplate 参数,把 StringTemplate::fragmentsStringTemplate::values 根据想要实现的字符串处理器功能拼接起来即可。

查看字节码我们又会发现它又用回了 process 方法。编译器真是奇妙呢。 不想找原因了,等 API 和编译器稳定下来再更新吧。

代码输出结果是 <h1>&lt;script&gt;alert(42)&lt;/script&gt;</h1><html><script>alert(42)</script></html>,符合预期。

总结

我们介绍了 JDK 21 里即将进入预览阶段的字符串模板。大体上其语法就是 字符串处理器."字符串模板", 例如 STR."Hello \{world}!"RAW."Hello \{world}!" 或是 FMT."%.9f{x + y}"。 语法抛开审美不谈看起来可能的确还行。实现上也通过 StringTemplateStringTemplate.Processor 留出了用户自定义的空间。 另外,编译器也对内置的 STR 等处理器进行了各种各样的优化,性能不比普通的字符串拼接差。

Java 天下苦字符串拼接久矣!我个人还是非常期待这个特性的正式上线的。 虽然之前还是对 \{ 的语法不太满意,但写到这里我已经被我自己说服了。 你怎么看?