JDK14 文本块Text Blocks:JSON/SQL/HTML终于不用转义了
大约 10 分钟
JDK14 文本块Text Blocks:JSON/SQL/HTML终于不用转义了
适读人群:经常在Java代码里写SQL、JSON、HTML字符串的后端开发者 | 阅读时长:约13分钟
开篇故事
2019年,我接手维护一段老代码,里面有这样一段SQL:
String sql = "SELECT u.id, u.name, u.email, " +
"o.order_id, o.total_amount, o.status " +
"FROM users u " +
"LEFT JOIN orders o ON u.id = o.user_id " +
"WHERE u.active = 1 " +
"AND o.created_at > ? " +
"AND (u.level = 'VIP' OR o.total_amount > 1000) " +
"ORDER BY o.created_at DESC " +
"LIMIT ?, ?";这段代码要验证SQL是否正确,我得把所有字符串片段拼起来,复制到数据库客户端里才能看。修改时极易在行末漏掉空格,导致SQL拼接报错。
另一段JSON构造代码更离谱:
String json = "{" +
" \"userId\": " + userId + "," +
" \"action\": \"" + action + "\"," +
" \"data\": {" +
" \"key\": \"" + key.replace("\"", "\\\"") + "\"" +
" }" +
"}";满眼的反斜杠和双引号,看一眼眼睛就花了。
JDK13引入Text Blocks预览,JDK15正式GA,彻底解决了这个问题。
一、Text Blocks的设计背景
1.1 多行字符串的痛点
在JDK15之前,Java处理多行字符串有三种糟糕方案:
- 字符串拼接:
"line1\n" + "line2\n"——维护噩梦 - 转义字符:
"\"""\n""\t"——可读性极差 - StringBuilder:代码量翻倍,可读性更差
其他语言早就解决了:Python有三引号""",JavaScript有模板字符串`...`,Kotlin有""" """,Java落后了十年。
1.2 Text Blocks的核心价值
引入版本:JDK13(Preview),JDK14(第二次Preview)
正式GA:JDK15(2020年9月,JEP 378)
核心价值:
1. 不需要手动转义双引号(")
2. 不需要手动写\n换行
3. 自动处理缩进(去除公共前缀空白)
4. 就是普通的String类型,没有新类型二、Text Blocks深度解析
2.1 基本语法
// 文本块语法:三引号开始,内容,三引号结束
String text = """
content here
more content
""";
// 开始的三引号后面必须换行,不能:
// String wrong = """content"""; // 编译错误2.2 缩进处理机制
这是Text Blocks最精妙也最容易搞错的地方:
缩进处理规则:
1. 找到最小公共前缀空白
2. 从每一行去除该前缀空白
3. 结尾的三引号位置决定了缩进基准
示例:
代码缩进了8格,文本块内容缩进了8格
结尾三引号在8格处 → 去除8格前缀 → 内容无额外缩进
代码缩进了8格,文本块内容缩进了12格
结尾三引号在8格处 → 去除8格前缀 → 内容有4格缩进ASCII示意图:
列: 0 4 8 12 16
| | | | |
String s = """
line1 ← 内容在列12
line2
"""; ← 结束三引号在列13(包含在内)
最小缩进:12格(因为内容最左12格,结束引号在13格)
→ 每行去除12格 → 结果:"line1\nline2\n"Mermaid图(缩进处理流程):
2.3 三个新的转义序列(JDK15+)
// 1. \s(空格):防止行尾空格被去除
String aligned = """
line1 \s
line2 \s
line3 \s
""";
// \s确保每行末尾的空格被保留(否则编译器会去除)
// 2. \(行连接符):将两行合并为一行(消除换行)
String joined = """
hello \
world
""";
// 结果是:"hello world\n"(不是"hello \\\nworld\n")
// 3. 标准转义\n, \t等仍然可用
String withTab = """
col1\tcol2\tcol3
val1\tval2\tval3
""";三、完整代码示例
3.1 SQL示例:旧写法vs新写法
import java.sql.*;
import java.util.*;
/**
* Text Blocks在SQL场景的应用
* 引入版本:JDK13 Preview;GA版本:JDK15(2020年9月)
*/
public class TextBlocksSQL {
// ===== 旧写法:字符串拼接 =====
public static PreparedStatement buildQueryOld(Connection conn,
String status, int minAmount) throws SQLException {
// 可读性极差,容易出错(行末忘记空格,双引号需要转义)
String sql = "SELECT u.id AS user_id, " +
" u.name AS user_name, " +
" u.email AS user_email, " +
" COUNT(o.id) AS order_count, " +
" SUM(o.total_amount) AS total_spent " +
"FROM users u " +
"LEFT JOIN orders o ON u.id = o.user_id " +
" AND o.status = ? " +
"WHERE u.active = 1 " +
" AND u.created_at > '2023-01-01' " +
"GROUP BY u.id, u.name, u.email " +
"HAVING SUM(o.total_amount) > ? " +
"ORDER BY total_spent DESC " +
"LIMIT 100";
PreparedStatement stmt = conn.prepareStatement(sql);
stmt.setString(1, status);
stmt.setInt(2, minAmount);
return stmt;
}
// ===== 新写法:Text Blocks =====
public static PreparedStatement buildQueryNew(Connection conn,
String status, int minAmount) throws SQLException {
// 所见即所得,可以直接复制到SQL客户端验证
String sql = """
SELECT u.id AS user_id,
u.name AS user_name,
u.email AS user_email,
COUNT(o.id) AS order_count,
SUM(o.total_amount) AS total_spent
FROM users u
LEFT JOIN orders o
ON u.id = o.user_id
AND o.status = ?
WHERE u.active = 1
AND u.created_at > '2023-01-01'
GROUP BY u.id, u.name, u.email
HAVING SUM(o.total_amount) > ?
ORDER BY total_spent DESC
LIMIT 100
""";
PreparedStatement stmt = conn.prepareStatement(sql);
stmt.setString(1, status);
stmt.setInt(2, minAmount);
return stmt;
}
// 带条件动态SQL(Text Blocks + String.format)
public static String buildDynamicSQL(boolean includeInactive) {
String activeFilter = includeInactive ? "" : "AND u.active = 1";
return """
SELECT u.id, u.name, u.email
FROM users u
WHERE 1=1
%s
ORDER BY u.name
""".formatted(activeFilter);
// String.formatted()是JDK15新增的实例方法,等同于String.format(this, ...)
}
}3.2 JSON示例:旧写法vs新写法
/**
* Text Blocks在JSON场景的应用
*/
public class TextBlocksJSON {
// ===== 旧写法:手动转义双引号,噩梦 =====
public static String buildJsonOld(String name, int age, String[] hobbies) {
StringBuilder sb = new StringBuilder();
sb.append("{\n");
sb.append(" \"name\": \"").append(name.replace("\"", "\\\"")).append("\",\n");
sb.append(" \"age\": ").append(age).append(",\n");
sb.append(" \"active\": true,\n");
sb.append(" \"hobbies\": [\n");
for (int i = 0; i < hobbies.length; i++) {
sb.append(" \"").append(hobbies[i]).append("\"");
if (i < hobbies.length - 1) sb.append(",");
sb.append("\n");
}
sb.append(" ]\n");
sb.append("}");
return sb.toString();
}
// ===== 新写法:Text Blocks =====
public static String buildJsonNew(String name, int age) {
// 固定结构的JSON,直接用Text Blocks
return """
{
"name": "%s",
"age": %d,
"active": true,
"config": {
"theme": "dark",
"language": "zh-CN"
}
}
""".formatted(name, age);
}
// API测试用的固定JSON请求体
static final String CREATE_USER_REQUEST = """
{
"username": "testuser",
"email": "test@example.com",
"password": "Test123!",
"roles": ["user", "viewer"],
"metadata": {
"source": "api_test",
"version": "v2"
}
}
""";
// HTTP Mock响应
static final String MOCK_USER_RESPONSE = """
{
"code": 200,
"message": "success",
"data": {
"id": 1001,
"username": "testuser",
"email": "test@example.com",
"createdAt": "2024-01-15T10:30:00Z"
}
}
""";
public static void main(String[] args) {
System.out.println("=== 新写法 ===");
System.out.println(buildJsonNew("Alice", 30));
System.out.println("=== 固定JSON ===");
System.out.println(CREATE_USER_REQUEST);
// 验证:Text Blocks就是普通String,可以用所有String方法
System.out.println("Contains id: " + MOCK_USER_RESPONSE.contains("\"id\""));
System.out.println("Length: " + MOCK_USER_RESPONSE.length());
}
}3.3 HTML、XML、代码模板的应用
/**
* Text Blocks在HTML/XML/代码生成场景的应用
*/
public class TextBlocksTemplates {
// ===== HTML模板(邮件模板、报告等)=====
public static String renderEmailTemplate(String userName, String confirmUrl) {
return """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>确认注册</title>
<style>
body { font-family: Arial, sans-serif; }
.btn {
background-color: #007bff;
color: white;
padding: 10px 20px;
text-decoration: none;
border-radius: 4px;
}
</style>
</head>
<body>
<h2>欢迎,%s!</h2>
<p>请点击下面的链接确认您的注册:</p>
<a class="btn" href="%s">确认注册</a>
<p>如果您没有注册账号,请忽略此邮件。</p>
</body>
</html>
""".formatted(userName, confirmUrl);
}
// ===== XML配置生成 =====
public static String generateSpringBeanXml(String beanId, String className) {
return """
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="...">
<bean id="%s" class="%s">
<property name="dataSource" ref="dataSource"/>
<property name="timeout" value="30000"/>
</bean>
</beans>
""".formatted(beanId, className);
}
// ===== 代码生成(用于代码生成器)=====
public static String generateRecordClass(String packageName, String recordName,
String... fields) {
var fieldsStr = String.join(", ", fields);
return """
package %s;
import java.util.Objects;
/**
* Auto-generated Record class.
* DO NOT EDIT MANUALLY.
*/
public record %s(%s) {
public %s {
// Validate fields
%s
}
}
""".formatted(
packageName,
recordName,
fieldsStr,
recordName,
generateValidations(fields)
);
}
static String generateValidations(String[] fields) {
StringBuilder sb = new StringBuilder();
for (String field : fields) {
// 简单示例:对String类型字段生成null检查
if (field.startsWith("String ")) {
String fieldName = field.substring(7);
sb.append("Objects.requireNonNull(").append(fieldName)
.append(", \"").append(fieldName).append(" cannot be null\");\n ");
}
}
return sb.toString().trim();
}
// ===== Markdown生成 =====
public static String generateApiDoc(String endpoint, String method, String description) {
return """
## %s %s
**描述**:%s
**请求示例**:
```json
{
"key": "value"
}
```
**响应示例**:
```json
{
"code": 200,
"message": "success"
}
```
""".formatted(method, endpoint, description);
}
public static void main(String[] args) {
System.out.println("=== HTML邮件 ===");
System.out.println(renderEmailTemplate("Alice", "https://example.com/confirm?token=abc123"));
System.out.println("=== Record类生成 ===");
System.out.println(generateRecordClass(
"com.example.dto",
"UserDTO",
"Long id", "String name", "String email"
));
System.out.println("=== API文档 ===");
System.out.println(generateApiDoc("/api/users", "POST", "创建新用户"));
}
}四、踩坑实录
坑1:结尾三引号位置影响最终字符串
public class TextBlockIndentTrap {
public static void main(String[] args) {
// 情况1:结尾三引号在与内容相同的缩进位置
String s1 = """
hello
world
"""; // 三引号在16列(与内容对齐)
System.out.println("|" + s1.replace("\n", "|\n|")); // 无额外缩进
// 输出:|hello|
// |world|
// |(末尾有换行)
// 情况2:结尾三引号在行首(列0)
String s2 = """
hello
world
"""; // 三引号在列0
// 内容有16格缩进,结尾三引号是列0,最小缩进是0
// 所以内容的缩进被保留了!
System.out.println("|" + s2.replace("\n", "|\n|"));
// 输出:| hello|
// | world|
// 内容的16格缩进被保留了!通常这不是你想要的
// 情况3:不要末尾换行(结尾三引号紧跟最后一行内容)
String s3 = """
hello
world"""; // 三引号紧跟在world后面(同一行)
System.out.println("s3末尾有换行: " + s3.endsWith("\n")); // false
}
}坑2:formatted()方法的百分号转义
public class TextBlockFormattedTrap {
public static void main(String[] args) {
// 问题:SQL里的LIKE %xxx% 中的%是format的特殊字符
// 错误:
try {
String wrong = """
SELECT * FROM users WHERE name LIKE '%Alice%'
""".formatted(); // 空参数列表,但%A被当成格式符了!
} catch (Exception e) {
System.out.println("格式化异常: " + e.getMessage());
}
// 正确1:转义%
String right1 = """
SELECT * FROM users WHERE name LIKE '%%Alice%%'
""".formatted(); // %%转义为%
System.out.println(right1);
// 正确2:不用formatted,用StringBuilder或String.replace
String keyword = "Alice";
String right2 = """
SELECT * FROM users WHERE name LIKE ?
"""; // 用?占位符,避免格式化问题
// 正确3:如果确实需要格式化SQL模板
String template = """
SELECT * FROM users WHERE name LIKE '%%%s%%'
""";
String sql = template.formatted(keyword);
System.out.println(sql);
}
}坑3:Text Blocks中的行结尾(CRLF vs LF)
public class TextBlockLineEndingTrap {
public static void main(String[] args) {
// Text Blocks使用\n(LF)作为行分隔符,不管源文件是CRLF还是LF
// 这是Java规范保证的,无需担心平台差异
String text = """
line1
line2
""";
// 总是\n,不会是\r\n
System.out.println(text.contains("\r\n")); // false,即使在Windows上
System.out.println(text.contains("\n")); // true
// 如果需要平台相关的行分隔符(通常不推荐):
String withSystemLineSep = text.replace("\n", System.lineSeparator());
}
}坑4:Text Blocks与字符串拼接的混用
public class TextBlockConcatTrap {
public static void main(String[] args) {
// 注意:Text Blocks不是模板字符串,不能内嵌表达式
// 如果需要动态内容,只能用formatted()或+拼接
String name = "Alice";
int age = 30;
// 选项1:formatted()(推荐,最简洁)
String json1 = """
{"name": "%s", "age": %d}
""".formatted(name, age);
// 选项2:String.format()(兼容性好)
String json2 = String.format("""
{"name": "%s", "age": %d}
""", name, age);
// 选项3:+拼接(不推荐,破坏了Text Blocks的可读性)
String json3 = """
{"name": \"""" + name + "\", \"age\": " + age + "}";
// 上面这种写法把Text Blocks的优点全部抵消了
// 选项4:使用StringBuilder(复杂动态内容)
// 或者使用模板引擎(Freemarker, Thymeleaf等)
System.out.println(json1);
System.out.println(json2);
}
}坑5:三引号中包含三引号的转义
public class TextBlockTripleQuoteTrap {
public static void main(String[] args) {
// 如果内容本身包含三引号,需要转义至少一个
// 错误:
// String s = """
// He said """hello"""
// """; // 编译错误!内部的"""会终止文本块
// 正确1:转义最后一个引号
String s1 = """
He said ""\\"hello\\""\""
""";
// 正确2:用\\"""转义
String s2 = """
He said \"""hello\"""
""";
// 实际上,包含三引号的情况很罕见
// 通常出现在:生成Java源代码时(需要生成Text Blocks)
// 或者描述JSON Schema时
System.out.println(s1);
System.out.println(s2);
}
}五、总结与延伸
5.1 Text Blocks使用场景总结
| 场景 | 推荐程度 | 说明 |
|---|---|---|
| SQL语句 | 强烈推荐 | 多行SQL的可读性大幅提升 |
| JSON字符串(测试/Mock) | 强烈推荐 | 省去大量转义 |
| HTML模板 | 推荐 | 比字符串拼接可读性好 |
| 单元测试的期望值 | 推荐 | 多行预期结果更清晰 |
| 代码生成模板 | 推荐 | 生成Java/SQL/XML代码 |
| 日志消息 | 不需要 | 单行消息用普通字符串 |
| 简单字符串 | 不需要 | 普通字符串即可 |
5.2 版本兼容建议
| 版本 | 状态 | 建议 |
|---|---|---|
| JDK13 | Preview(--enable-preview) | 不用于生产 |
| JDK14 | Preview 第二版 | 不用于生产 |
| JDK15 | GA | 可以生产使用 |
| JDK17(LTS) | 稳定 | 推荐使用 |
5.3 和模板字符串的关系
JDK21的JEP 430(String Templates)是更进一步的特性,允许在字符串里内嵌表达式:
// JDK21 Preview(String Templates,不同于Text Blocks)
// String msg = STR."Hello \{name}, you are \{age} years old";
// 这是另一个特性,Text Blocks只解决多行和转义问题