字符串拼接是Java日常开发的绝对高频操作,但传统方式既脆弱又易导致SQL注入,格式化代码冗长难读。Java 21正式引入的字符串模板(String Templates)预览特性在经过两轮孵化后终于转正,它允许我们将带占位符的字符串与模板处理器结合,在编译期保证类型安全的同时,生成任意目标对象。本文将逐步拆解其语法,并通过构建一个生产级SQL查询构建器和一个国际化消息系统,展现这一能力的真正威力。
一、传统方式的痛点与模板设计的初衷
回顾一个典型的SQL拼接场景:
String customerName = request.getParameter("name");
String query = "SELECT * FROM users WHERE name = '" + customerName + "'";
这种方式极易遭受SQL注入攻击。即使使用PreparedStatement和参数化查询,代码也常常分散在多个地方,业务逻辑与查询构造纠缠不清。而String.format或MessageFormat不仅类型不安全,且无法在编译期验证参数数量是否匹配。
字符串模板的核心设计是:模板表达式(STR."...")由一个模板处理器和一段嵌入了表达式的字符串组成。处理器在编译期检查表达式类型,并在运行时安全地构建最终对象——这个对象不一定是String,可以是PreparedStatement、JSON对象,甚至是国际化消息。
二、基本语法与内建处理器
字符串模板的格式为:PROCESSOR."包含 {表达式} 的文本"。其中{表达式}会被求值并传递给处理器。Java 21提供了三个内建处理器:
- STR — 简单字符串插值,返回
String。 - FMT — 类似
printf的格式化,在插值后应用格式说明符。 - RAW — 返回一个
StringTemplate对象,不处理,允许自定义处理逻辑。
int a = 10, b = 20;
String result = STR."{a} + {b} = {a + b}";
System.out.println(result); // 输出: 10 + 20 = 30
// FMT 格式化
String fmtResult = FMT."PI 约等于 %.2f{Math.PI}";
System.out.println(fmtResult); // 输出: PI 约等于 3.14
但最强大的地方在于我们可以实现自己的StringTemplate.Processor。
三、实战一:构建安全SQL查询处理器
目标:让开发者以自然的方式书写SQL模板,处理器自动将参数转换为PreparedStatement参数,彻底杜绝SQL注入,同时保留像书写普通字符串一样的流畅体验。
3.1 实现自定义处理器
// SafeSQL.java
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.sql.Connection;
import java.util.ArrayList;
import java.util.List;
public class SafeSQL {
/**
* 自定义处理器:将模板转换为包含SQL语句和参数列表的对象
*/
public static final StringTemplate.Processor<ParameterizedQuery, RuntimeException> QUERY =
(StringTemplate st) -> {
List<Object> params = new ArrayList<>();
StringBuilder sb = new StringBuilder();
// 片段个数总是比值多1
for (int i = 0; i < st.fragments().size(); i++) {
sb.append(st.fragments().get(i));
if (i < st.values().size()) {
// 使用 ? 占位符代替值本身
sb.append("?");
params.add(st.values().get(i));
}
}
return new ParameterizedQuery(sb.toString(), params);
};
/**
* 封装SQL语句与参数
*/
public record ParameterizedQuery(String sql, List<Object> params) {
public void applyTo(PreparedStatement ps) throws SQLException {
for (int i = 0; i < params.size(); i++) {
ps.setObject(i + 1, params.get(i));
}
}
}
}
3.2 使用示例
import static SafeSQL.QUERY;
String customerName = request.getParameter("name"); // 外部输入
int minAge = 18;
SafeSQL.ParameterizedQuery query = QUERY."SELECT * FROM users WHERE name = {customerName} AND age >= {minAge}";
System.out.println("SQL: " + query.sql()); // SELECT * FROM users WHERE name = ? AND age >= ?
System.out.println("Params: " + query.params()); // [customerName, 18]
// 实际执行(伪代码)
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(query.sql())) {
query.applyTo(ps);
ResultSet rs = ps.executeQuery();
// 处理结果...
}
即使customerName包含恶意片段如' OR '1'='1,最终SQL中它只是一个参数占位符?,值通过setObject安全传递,彻底消除了注入可能。而代码的可读性几乎与自己写SQL字符串相同。
四、实战二:国际化消息插值器
国际化系统经常需要将动态参数插入到消息模板中,例如“您好,{0},您有{1}条新消息”。使用字符串模板,我们可以让键名更具语义,且编译时检查。
4.1 自定义多语言处理器
// I18nProcessor.java
import java.text.MessageFormat;
import java.util.Locale;
import java.util.ResourceBundle;
public class I18nProcessor {
public static StringTemplate.Processor<String, RuntimeException> forLocale(Locale locale) {
ResourceBundle bundle = ResourceBundle.getBundle("messages", locale);
return (StringTemplate st) -> {
// st.fragments() 的第一段是键名,其余为空
String key = st.fragments().get(0).trim();
String pattern = bundle.getString(key);
Object[] params = st.values().toArray();
return MessageFormat.format(pattern, params);
};
}
}
4.2 创建资源文件
messages_en.properties:
greeting=Hello, {0}! You have {1} new messages.
welcome=Welcome back, {0}. Last login: {1}.
messages_zh_CN.properties:
greeting=您好,{0}!您有{1}条新消息。
welcome=欢迎回来,{0}。上次登录时间:{1}。
4.3 在代码中使用
import static I18nProcessor.forLocale;
Locale userLocale = Locale.SIMPLIFIED_CHINESE;
String username = "张伟";
int count = 5;
// 直接以模板方式引用消息键
String message = forLocale(userLocale)."greeting{username}{count}";
System.out.println(message); // 输出: 您好,张伟!您有5条新消息。
// 切换英文
String enMessage = forLocale(Locale.US)."greeting{username}{count}";
System.out.println(enMessage); // 输出: Hello, 张伟! You have 5 new messages.
这里"greeting{username}{count}"中的greeting被处理为消息键,后面的值自动映射为参数。类型安全且不再需要记住占位符顺序(虽然底层MessageFormat仍按索引,但可以在处理器中按名称绑定做更高级封装)。
五、模板处理器的进阶技巧
5.1 原始模板访问与校验
通过实现StringTemplate.Processor,你可以获得完整的片段和值列表,能在编译期进行更复杂的验证。例如构建JSON处理器时,可以检查键名是否合法、防止注入。
public class SafeJSON {
public static final StringTemplate.Processor<String, RuntimeException> JSON =
(StringTemplate st) -> {
StringBuilder json = new StringBuilder("{");
// 假设模板格式为 "key": {value}
for (int i = 0; i < st.values().size(); i++) {
String fragment = st.fragments().get(i).replaceAll(""|:", "");
json.append(""").append(fragment).append("": "")
.append(escape(st.values().get(i).toString())).append("",");
}
json.setLength(json.length() - 1); // 去除最后的逗号
json.append("}");
return json.toString();
};
private static String escape(String s) {
return s.replace("\", "\\").replace(""", "\"");
}
}
5.2 与文本块结合
模板处理器也可以处理多行文本块,对于构造复杂SQL或HTML模板极为方便:
String query = QUERY."""
SELECT u.id, u.name, o.order_id
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.status = {userStatus}
ORDER BY o.created_at DESC
""";
5.3 性能考量
模板处理器在每次调用时都会执行,因此处理器内部的逻辑应当轻量。对于高频调用的场景,可以将预编译的模板缓存起来,处理器仅负责参数注入。
六、与旧式方法的对比总结
| 场景 | 传统方式 | 字符串模板 |
|---|---|---|
| SQL拼接 | 易注入,参数与语句分离 | 自定义处理器,原生安全,代码内聚 |
| 国际化 | MessageFormat,键与值分开管理 |
键值同在模板中,语义清晰 |
| JSON生成 | 字符串拼接或用库 | 可定制处理器,声明式构建 |
| 可读性 | 混合拼接,阅读困难 | 表达式直接内嵌,直观 |
字符串模板不仅仅是语法糖,它通过处理器机制将“字符串构建”这个动作泛化为“从模板创建任何对象”的通用模式,极大地扩展了编译时安全性和领域特定语言的能力。
七、总结
Java 21的字符串模板终结了混乱的字符串拼接历史。通过本文实现的安全SQL处理器和国际化插值器,我们看到了它超越简单插值的真正潜力:将业务意图直接写入代码,同时保持类型安全和防护能力。随着生态对自定义处理器的探索,未来可能出现更多用于HTML模板、正则表达式构建、命令行参数生成等领域的处理器。
从今天开始,在你的Java 21项目中启用--enable-preview(若特性尚未最终默认开启)并尝试替换那些脆弱的字符串操作。字符串模板让Java与现代语言在字符串处理上站到了同一起跑线,并且凭借处理器扩展,甚至更具优势。

