2. 问题背景

Java标准库提供的String.format()方法虽然简单易用,但当参数较多时容易出错。例如:

Employee e = ...; // 获取员工实例
String template = "Firstname: %s, Lastname: %s, Id: %s, Company: %s, Role: %s, Department: %s, Address: %s ...";
String.format(template, e.firstName, e.lastName, e.Id, e.company, e.department, e.role ... )

这种写法存在两个明显问题:

  1. 可读性差:参数顺序与占位符的对应关系不直观
  2. 易出错:参数顺序调整时容易错位(如示例中e.departmente.role顺序错误)

理想情况下,我们希望使用命名参数的方式:

String template = "Firstname: ${firstname}, Lastname: ${lastname}, Id: ${id} ...";
ourFormatMethod.format(template, parameterMap);

本文将探讨实现方案,并特别关注值中包含占位符的边界情况。需要说明:我们只讨论简单的%s格式,日期/数字等复杂格式不在讨论范围。

3. 使用Apache Commons Text的StringSubstitutor

Apache Commons Text库提供了StringSubstitutor工具类,能优雅解决命名参数替换问题。首先添加Maven依赖:

<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-text</artifactId>
    <version>1.10.0</version>
</dependency>

使用示例:

String TEMPLATE = "Text: [${text}] Number: [${number}] Text again: [${text}]";

Map<String, Object> params = new HashMap<>();
params.put("text", "It's awesome!");
params.put("number", 42);
String result = StringSubstitutor.replace(TEMPLATE, params, "${", "}");
assertThat(result).isEqualTo("Text: [It's awesome!] Number: [42] Text again: [It's awesome!]");

关键点说明:

  • 通过replace()方法指定模板、参数映射和占位符边界(${}
  • 占位符格式为前缀+参数名+后缀
  • 支持同名参数多次替换(如${text}出现两次)

4. 踩坑:当替换值包含占位符时

StringSubstitutor存在一个隐蔽陷阱:当参数值本身包含占位符模式时。测试用例:

Map<String, Object> params = new HashMap<>();
params.put("text", "'${number}' is a placeholder.");
params.put("number", 42);
String result = StringSubstitutor.replace(TEMPLATE, params, "${", "}");

assertThat(result).isEqualTo("Text: ['${number}' is a placeholder.] Number: [42] Text again: ['${number}' is a placeholder.]");

实际输出与预期不符:

org.opentest4j.AssertionFailedError: 
expected: "Text: ['${number}' is a placeholder.] Number: [42] Text again: ['${number}' is a placeholder.]"
 but was: "Text: ['42' is a placeholder.] Number: [42] Text again: ['42' is a placeholder.]"

根本原因在于StringSubstitutor递归替换机制(Javadoc已说明):

默认启用递归替换,若变量值包含其他变量,会继续替换

这导致${text}的值'${number}'被二次解析,最终替换为'42'

应对方案

临时方案是更换占位符符号(如用%{替代${):

String TEMPLATE = "Text: [%{text}] Number: [%{number}] Text again: [%{text}]";
String result = StringSubstitutor.replace(TEMPLATE, params, "%{", "}");

但治标不治本——无法预知参数值是否会意外匹配占位符模式。接下来我们实现更健壮的方案。

5. 自实现格式化工具

核心思路:避免递归替换,将命名参数模板转换为标准String.format()可用的形式。

5.1 解决方案设计

将模板:

String TEMPLATE = "Text: [${text}] Number: [${number}] Text again: [${text}]";
Map<String, Object> params ...

转换为:

String NEW_TEMPLATE = "Text: [%s] Number: [%s] Text again: [%s]";
List<Object> valueList = List.of("'${number}' is a placeholder.", 42, "'${number}' is a placeholder.");

最后调用String.format(NEW_TEMPLATE, valueList.toArray())完成格式化。

5.2 实现代码

public static String format(String template, Map<String, Object> parameters) {
    StringBuilder newTemplate = new StringBuilder(template);
    List<Object> valueList = new ArrayList<>();

    Matcher matcher = Pattern.compile("[$][{](\\w+)}").matcher(template);

    while (matcher.find()) {
        String key = matcher.group(1);

        String paramName = "${" + key + "}";
        int index = newTemplate.indexOf(paramName);
        if (index != -1) {
            newTemplate.replace(index, index + paramName.length(), "%s");
            valueList.add(parameters.get(key));
        }
    }

    return String.format(newTemplate.toString(), valueList.toArray());
}

实现要点:

  1. 使用正则[$][{](\\w+)}匹配${param}格式
  2. 遍历所有匹配项,替换为%s并收集参数值
  3. 最终调用String.format()完成格式化
  4. 未提供值的参数直接替换为null

5.3 测试验证

常规场景测试:

Map<String, Object> params = new HashMap<>();
params.put("text", "It's awesome!");
params.put("number", 42);
String result = NamedFormatter.format(TEMPLATE, params);
assertThat(result).isEqualTo("Text: [It's awesome!] Number: [42] Text again: [It's awesome!]");

边界场景测试(值包含占位符):

params.put("text", "'${number}' is a placeholder.");
result = NamedFormatter.format(TEMPLATE, params);
assertThat(result).isEqualTo("Text: ['${number}' is a placeholder.] Number: [42] Text again: ['${number}' is a placeholder.]");

全部测试通过,完美解决递归替换问题。

6. 总结

在Java中实现命名参数字符串格式化时:

  • 首选方案:Apache Commons Text的StringSubstitutor适用于大多数场景
  • 注意陷阱:当参数值包含占位符模式时会产生意外递归替换
  • 终极方案:通过模板转换技巧自实现格式化方法,可彻底避免递归问题

核心原则是:不要将上一步的输出作为下一步的输入,这是避免递归陷阱的关键。对于需要严格控制的字符串格式化场景,自实现方案更为可靠。


原始标题:Named Placeholders in String Formatting | Baeldung