文本块和新的 String API (Java 15+)

1. 概述

在 Java 15 之前,在 Java 代码中编写多行的 JSON、SQL 或 HTML 相当麻烦。你必须用 + 拼接字符串、添加 \n 并对每个双引号进行转义。文本块(从 Java 15 正式推出,JEP 378)解决了这个问题。随之而来的是 String 中的几个新方法。

读完本文,你将能正确编写文本块的缩进,并使用 \ 和 \s 转义序列。你还将了解何时使用 formatted()、stripIndent()、translateEscapes() 以及一些补充 API 直到 Java 21。

2. 准备

所有示例运行在 Java 21 上。测试部分使用 JUnit 5 和 AssertJ:

<properties>
    <maven.compiler.release>21</maven.compiler.release>
</properties>

<dependencies>
    <dependency>
        <groupId>org.junit.jupiter</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>5.10.2</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.assertj</groupId>
        <artifactId>assertj-core</artifactId>
        <version>3.25.3</version>
        <scope>test</scope>
    </dependency>
</dependencies>

如果你使用 Spring Boot 3.x,spring-boot-starter-test 已经包含了这两个库。

3. 文本块基础

3.1. 语法

文本块以三个双引号 """ 开始,后面紧接着换行。它以 """ 结束:

public class TextBlockDemo {
    public static void main(String[] args) {
        String json = """
                {
                  "id": 42,
                  "customer": "Lan",
                  "status": "PAID"
                }
                """;
        System.out.print(json);
    }
}

输出结果:

{
  "id": 42,
  "customer": "Lan",
  "status": "PAID"
}

上面的代码有几个值得注意的地方:

  • 内部的 " 不需要转义。
  • 每行开头的共同缩进部分被移除。这称为附属空白。相对缩进,如 "id" 前的 2 个空格,仍然被保留。
  • 因为 """ 关闭符在单独的一行,字符串以 \n 结尾。

文本块就是一个普通的 String。它在编译时完全处理,所以运行时没有额外开销。

注意: 不能在 """ 开始后立即写内容。String s = """Hello"""; 会报编译错误:illegal text block open delimiter sequence。

3.2. 关闭 """ 的位置决定缩进

编译器找出开头空白最少的一行,包括含有 """ 关闭符的那一行。然后它从所有行中移除恰好那么多的空白。因此,你可以通过将 """ 关闭符向左移动来主动增加缩进:

String html = """
            <ul>
              <li>Order #1</li>
            </ul>
        """;
System.out.print(html);
    <ul>
      <li>Order #1</li>
    </ul>

""" 关闭符比内容少缩进 4 列,所以每行保留 4 个空格在开头。

但是如果你不想要字符串末尾的 \n 字符,把 """ 放在内容后面紧接着:

String greeting = """
        Xin chào Lan""";
System.out.println("[" + greeting + "]");
[Xin chào Lan]

3.3. 行尾空白和换行符

还有两条你需要了解的规则:

  1. 行尾的空白始终被移除。
  2. 所有类型的换行符(\r\n、\r)都被规范化为 \n。即使源文件使用 Windows 行尾也是如此。

规则 2 有助于在操作系统间获得一致的结果。但如果你的协议要求使用 \r\n,例如 HTTP 头或遵循 RFC 4180 的 CSV 文件,你需要自己替换换行符。

4. 两个新的转义序列:\<换行> 和 \s

4.1. 用 \ 连接行

长的 SQL 语句通常为了易读性被写成多行。但有时你又需要它在一行上,比如在记录日志时。行尾的 \ 会阻止换行符:

String sql = """
        SELECT id, total \
        FROM orders \
        WHERE status = 'PAID'""";
System.out.println(sql);
SELECT id, total FROM orders WHERE status = 'PAID'

\ 之前的空格被保留了。原因是它不再位于行尾:\ 字符在它后面。

4.2. 用 \s 保留空白

\s 相当于一个空格(U+0020)。你用它来保留行尾的空白,这些空白通常会被删除,例如当创建固定宽度的数据时:

String report = """
        Lan    \s
        Minh   \s
        """;
report.lines().forEach(line -> System.out.println("[" + line + "]"));
[Lan     ]
[Minh    ]

每行恰好 8 个字符长。\s 之前的四个空格被保留,因为它们不再位于行尾。

4.3. 当内容中有 """ 时

连续两个 " 没问题。三个连续的 " 会被理解为文本块的结束符,所以你需要转义至少一个:

String doc = """
        Python docstring dùng \""" để mở và đóng.
        """;

5. 配套的新 String API

Java 15 为 String 添加了三个方法来支持文本块。这些也是编译器处理文本块时执行的步骤。

5.1. formatted()

formatted(Object... args) 的工作方式类似 String.format(this, args),但你可以直接在字符串上调用。这种写法特别适合文本块:

record Order(long id, String customer, double total) {}

Order order = new Order(42, "Lan", 199_000);

String message = """
        订单 #%d
        客户: %s
        总价: %,.0f VND
        """.formatted(order.id(), order.customer(), order.total());

System.out.print(message);
订单 #42
客户: Lan
总价: 199,000 VND

千位分隔符取决于 JVM 的默认 Locale。如果需要固定结果,使用 String.format(Locale.US, ...),因为 formatted() 不接受 Locale 参数。

5.2. stripIndent()

此方法将文本块的去附属空白算法应用于任意字符串。当多行字符串来自文件或数据库而不是源代码时,它很有用:

String raw = "    SELECT *\n      FROM customers\n    ";
System.out.print(raw.stripIndent());
SELECT *
  FROM customers

最后一行只包含空白,所以它被计入缩进确定中,然后变成空字符串。这正是文本块中 """ 关闭符的作用。

5.3. translateEscapes()

此方法将字面形式的转义序列转换为实际字符。当从包含 \n 或 \t 字符串的配置文件读取模板时,你会需要它:

String fromConfig = "你好\\tLan\\n谢谢你的订单";
System.out.println(fromConfig.translateEscapes());
你好	Lan
谢谢你的订单

语法错误的转义,如 \q,会导致该方法抛出 IllegalArgumentException。不要在用户输入的数据上调用它而不捕获异常。另外注意:此方法不处理 \u,因为 unicode 转义已经由编译器提前翻译了。

6. 其他有用的方法(Java 11–21)

许多小方法在各个版本中被添加。它们通常与文本块一起使用:

String text = """
        Lan
           \s
        Minh
        """;

// Java 11
System.out.println("  ".isBlank());                      // true
System.out.println(text.lines().filter(l -> !l.isBlank()).count()); // 2
System.out.println("\u2003Lan\u2003".strip());           // "Lan" (strip 理解 Unicode,trim 则不)
System.out.println("-".repeat(10));                      // ----------

// Java 12
System.out.print("Lan\nMinh".indent(2));                 // 每行增加 2 个空格,以 \n 结尾
int length = "Lan".transform(String::length);            // 3

// Java 21
System.out.println("order-42-paid".indexOf('-', 6, 13)); // 8,仅在 [6, 13) 中查找
System.out.println(Arrays.toString("a,b;c".splitWithDelimiters("[,;]", 0)));
// [a, , , b, ;, c]

几个需要澄清的细节:

  • indent(n) 如果 n > 0 则在每行开头添加 n 个空格,如果 n < 0 则移除最多 n 个空白。它总是规范化换行符并在末尾添加 \n。
  • transform(Function) 允许你自然地链接字符串转换步骤,例如 template.transform(this::render).transform(String::strip)。
  • 从 Java 21 开始,StringBuilder 也有 repeat(...):new StringBuilder().repeat("=", 5) 生成 =====。

7. 在 Spring Boot 中使用文本块

在 Spring Boot 3.x 应用中,文本块非常适合 Spring Data JPA 的 @Query。你可以保持多行查询而不需要字符串拼接:

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("""
            SELECT o FROM Order o
            WHERE o.customer.email = :email
              AND o.status = 'PAID'
            ORDER BY o.createdAt DESC
            """)
    List<Order> findPaidOrders(@Param("email") String email);
}

文本块是编译时常数,所以可以用作注解值。参数仍通过 :email 绑定,对 SQL 注入是安全的。

8. 编写测试以验证文本块行为

关于缩进和换行符的规则很容易混淆。因此,你应该编写测试来确认你期望的行为:

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class TextBlockTest {

    @Test
    void givenClosingDelimiterOnOwnLine_thenEndsWithNewline() {
        String json = """
                {
                  "id": 42
                }
                """;

        assertThat(json).isEqualTo("{\n  \"id\": 42\n}\n");
    }

    @Test
    void givenBackslashAtLineEnd_thenLinesAreJoined() {
        String sql = """
                SELECT id \
                FROM orders""";

        assertThat(sql).isEqualTo("SELECT id FROM orders");
    }

    @Test
    void givenSpaceEscape_thenTrailingSpacesArePreserved() {
        String padded = """
                Lan  \s""";

        assertThat(padded).hasSize(6).endsWith("   ");
    }

    @Test
    void givenTemplate_whenFormatted_thenPlaceholdersAreReplaced() {
        String message = """
                你好 %s,订单 #%d 已支付。""".formatted("Lan", 42);

        assertThat(message).isEqualTo("你好 Lan,订单 #42 已支付。");
    }
}

四个测试都通过了。第一个测试最值得保留:它提醒你 """ 关闭符在单独的一行会在字符串末尾生成 \n。

9. 常见错误和何时不应使用

混合制表符和空格。 计算附属空白时,编译器将一个制表符视为一个空白字符,与一个空格相同。如果各行使用两种不同的缩进方式,结果会出错,尽管在 IDE 中看起来仍然对齐。配置编辑器只使用空格。

忘记末尾的 \n。 当比较字符串或将文本块连接到另一个字符串时,多余的 \n 字符很容易导致“看起来相同但 equals 返回 false”的错误。如果不需要它,将 """ 放在内容后面,或调用 strip()。

用 formatted() 将 SQL 与用户数据连接。 文本块只是使代码更易读,不会使其更安全。

// 错误:SQL 注入漏洞
String sql = """
        SELECT * FROM customers WHERE email = '%s'
        """.formatted(email);

使用 PreparedStatement、带 ? 参数的 JdbcTemplate,或带命名参数的 @Query。

注意: 对于 JSON,文本块适合测试数据或固定有效负载。如果有效负载取决于动态数据,使用 Jackson 来序列化对象。自行拼接字符串不会转义特殊字符,如客户名称中的 " 或 \。

短的单行字符串。 "Lan" 仍然比三行文本块更好。只有当内容真正有多行或包含多个 " 时才使用文本块。

模板过大。 几百行的 HTML 电子邮件在 Java 类中会使代码难以维护。在这种情况下,应该将其移到模板文件,例如使用 Thymeleaf 或 src/main/resources 中的资源。

10. 结论

文本块帮助你在 Java 中编写 JSON、SQL、HTML,使其简洁易读,就像原始形式一样。你只需要记住几条规则:

  • """ 开始必须紧跟换行。
  • """ 关闭符的位置决定缩进以及字符串是否以 \n 结尾。
  • 行尾空白被移除。换行符始终被规范化为 \n。
  • 使用 \ 连接行,使用 \s 保留空白。

关于 API,formatted() 非常适合向文本块填充值。stripIndent() 和 translateEscapes() 允许你对从外部读取的字符串应用相同的逻辑。Java 21 的 lines()、strip()、indent()、transform() 和 splitWithDelimiters() 等方法帮助你处理多行字符串而无需额外的库。最后,编写测试来确认期望的行为,不要用文本块来自己拼接 SQL 和用户数据。

javatext-blockstringjava-21