漫谈 Java 轻量级的模板技术:从字符串替换到复杂模板
提起模板技术相信每个 Java 开发者都不会陌生。虽然目前已经很少在 Java 后端开发前端页面了,但是在不同场合还是会用到模板或者应用模板的相关概念,例如把 SQL 写在 XML 文件中(MyBatis 的做法),里面使用了大量表达式和<if>/<foreach>流程判断,也属于模板的一种变种。更不用说早期 MVC 时代,各种 Java 模板技术百花齐放(如 JSP、FreeMarker、Velocity 等等)。本质上讲,模板就是把不变的内容固定好,然后预留特定的位置给变化的内容,这个位置相当于一种插值(有时候称为“占位符”,本质也是引入第三方变量的概念),等到待确定的时候替换为真实的值,形成最终的字符串内容。
无索引的替换
上述的这一原理一点都不复杂,可以说在日常字符串中都经常使用,例如每个初学者都是接触的String.format()方法:
System.out.println(String.format("你好 %s", "张三"));
又或者使用 System.out.println 已为我们考虑了的方法:
System.out.printf("你好 %s%n", "张三"); // 功能等价
本质上仍是字符串的替换:
System.out.println("你好 %s".replace("%s", "张三"));
类似地,SLF4J 日志框架也采用了占位符机制:
log.info("你好,{}。", "李四");
slf4j 的方式怎么实现的呢?也不复杂,下面是一个简化版的实现:
private static final String DELIM_STR = "{}";/*** 格式化字符串模板,将模板中的占位符替换为对应的参数值** @param tpl 字符串模板,其中包含占位符* @param args 可变参数列表,用于替换模板中的占位符* @return 格式化后的字符串,占位符被对应的参数值替换*/
public static String print(String tpl, Object... args) {StringBuilder buffer = new StringBuilder(tpl.length() + 64);int beginIndex = 0, endIndex, count = 0;while ((endIndex = tpl.indexOf(DELIM_STR, beginIndex)) >= 0) {buffer.append(tpl, beginIndex, endIndex);try {buffer.append(args[count++]);} catch (IndexOutOfBoundsException e) {buffer.append("null"); // 数组越界时对应占位符填 null}beginIndex = endIndex + DELIM_STR.length();}buffer.append(tpl.substring(beginIndex));return buffer.toString();
}// 测试
System.out.println(print("{} {} {}", "a", "b", "c"));// 输出: a b c
带数字索引的替换
使用%s或者{}由于没有次序,参数顺序完全依赖位置,参数多了话容易混乱,为此,可以使用MessageFormat,支持通过索引引用参数:
import java.text.MessageFormat;String template = """Hello {0},You have {1} new messages.""";String result = MessageFormat.format(template, "Alice", 5);
注意:这里应用到 Java 11 开始支持的多行文本,但还不支持类似 js/ts 那种字符串模板,否则爽多了。
可进一步封装为通用方法:
public static String render(String template, Object... args) {return MessageFormat.format(template, args);
}// 测试
String result = MessageFormat.format("您好{0},晚上好!您目前余额:{1,number,#.##}元,积分:{2}", "张三", 10.155, 10);
System.out.println(result);
// 输出: 您好张三,晚上好!余额:10.16元,积分:10
基于命名占位符的替换
有了数字 index 好了一点,但还是不够直观清晰,应该是可以把字符串作为索引来插值的,例如你好 ${who}这样的。这个通常被认为是“表达式 Expression”,不仅仅是取值的占位符,还能够和编程语言那样参与运算,功能强大。这样的话就与正式的表达式概念无异。不过,如果只是为了作为模板里面的取值,我们用下面的一个函数就可以实现。
private static final Pattern TPL_PATTERN = Pattern.compile("\\$\\{\\w+}");/*** 简单模板替换方法。根据 Map 中的数据进行替换** @param template 待替换的字符串模板* @param params 存放替换数据的 Map* @return 替换后的字符串*/
public static String simpleTpl(String template, Map<String, Object> params) {StringBuffer sb = new StringBuffer();Matcher m = TPL_PATTERN.matcher(template);while (m.find()) {String param = m.group();// 获取要替换的键名,即去除 '${' 和 '}' 后的部分Object value = params.get(param.substring(2, param.length() - 1));m.appendReplacement(sb, value == null ? CommonConstant.EMPTY_STRING : value.toString());// 替换键值对应的值,若值为 null,则置为空字符串}m.appendTail(sb);return sb.toString();
}
这里约定占位符的格式为${xxx},与我们常见的一致。这里使用了正则来实现,而下面的一个例子则没有使用正则去匹配,而是反过来用值加上标签去替换值,思路又不一样了。
/*** 简单模板替换方法。根据 Map 中的数据进行替换。* 与 simpleTpl 方法的区别在于这里将 null 值替换为字符串 "null"。** @param template 待替换的字符串模板* @param data 存放替换数据的 Map* @return 替换后的字符串*/
public static String simpleTpl2(String template, Map<String, Object> data) {String result = template;for (Map.Entry<String, Object> entry : data.entrySet()) {String key = entry.getKey();Object value = entry.getValue();if (value == null)value = "null";String placeholder = "#{" + key + "}";result = result.replace(placeholder, value.toString());}return result;
}
当前函数第二个入参为 Map,如果要改为 Java Bean 呢?也可以,通过反射获取属性值,请看看下面:
/*** 简单模板替换方法。根据 JavaBean 中的数据进行替换。** @param template 待替换的字符串模板* @param data 存放替换数据的 JavaBean 对象* @return 替换后的字符串*/
public static String simpleTpl(String template, Object data) {String result = template;try {for (PropertyDescriptor descriptor : Introspector.getBeanInfo(data.getClass()).getPropertyDescriptors()) {String name = descriptor.getName();Object value = descriptor.getReadMethod().invoke(data);if (value == null)value = "null";String placeholder = "#{" + name + "}";result = result.replace(placeholder, value.toString());}} catch (InvocationTargetException | IllegalAccessException | IntrospectionException e) {throw new RuntimeException(e);}return result;
}
Spring 中借助 PropertyPlaceholderHelper 亦可达成,几行代码就可以了,非常简练。话说 Spring 自带许多工具类的,直接拿去用就可以了。
private static final PropertyPlaceholderHelper HELPER = new PropertyPlaceholderHelper("${", "}");public static String render(String template, Map<String, ?> model) {return HELPER.replacePlaceholders(template, key -> {Object obj = model.get(key);return obj == null ? "" : obj.toString();});
}
带表达式的替换
前面我们提到的 ${xxx}里面为表达式的处理,如${score > 60 ? '及格' : '不及格'},这个可不是简单替换内容那么简单,而是涉及表达式多种情况的处理,需要利用编译器的知识来解决,远非简单的字符串替换所能处理。通常是用一个库去解决,例如 JSP 时代的 EL(Expression Language)表达式。
关于表达式库的选型,推荐京东这篇文章。
Spring MVC 许多场合都依赖表达式,于是也内置了一个表达式引擎:Spring Expression Language (SpEL) 。我们调用它,实现一个简单的模板功能:
/*** 编译模板,支持复杂的逻辑* Spring Expression Language (SpEL) 来实现模板替换** @param tpl 模板* @param values 值*/
public static String simpleTemplate(String tpl, Map<String, String> values) {EvaluationContext context = new StandardEvaluationContext(); // 通过 evaluationContext.setVariable 可以在上下文中设定变量。for (String key : values.keySet())context.setVariable(key, values.get(key));// 解析表达式,如果表达式是一个模板表达式,需要为解析传入模板解析器上下文。Expression expression = new SpelExpressionParser().parseExpression(tpl, new TemplateParserContext("${", "}"));// 使用 Expression.getValue() 获取表达式的值,这里传入了 Evaluation 上下文,第二个参数是类型参数,表示返回值的类型。return expression.getValue(context, String.class);
}// 示例
Map<String, Object> ctx = Map.of("name", "Alice", "score", 85);
String output = renderTemplate("Hello ${name}, your score is ${score}.", ctx);
// 结果: Hello Alice, your score is 85.
关于表达式本身的运算,无论其内部运行如何的逻辑,其实最终都返回一个boolean结果,我们看看 SpEL 如何单独解析表达式:
/*** 计算表达式** @param express EL 表达式* @param map EL 表达式动态参数* @return 表达式结果*/
public static boolean parse(String express, Map<String, Object> map) {// 设置动态参数StandardEvaluationContext cxt = new StandardEvaluationContext();cxt.setVariables(map);cxt.setPropertyAccessors(Collections.singletonList(new MapAccessor()));// 创建一个 EL 解析器ExpressionParser parser = new SpelExpressionParser();SpelExpression expr = (SpelExpression) parser.parseExpression(express, new TemplateParserContext("${", "}"));expr.setEvaluationContext(cxt);return Boolean.TRUE.equals(expr.getValue(map, Boolean.class));
}// 例子
Map<String, Object> map = new HashMap<>(16);
map.put("exp", 4);String result = parse("jjjj${exp>2}jkj", map);
System.out.println("result:" + result);
更强大的模板
毫无疑问就是那些专业的模板系统,如 Thymeleaf、FreeMarker、Velocity、JSP。本文讨论的是轻量级议题,所以那些就不展开讨论了。
小结
总结了这么多种轻量级的模板技术,有没有推荐一种方式呢?其实笔者感觉也不好说,它们都不相伯仲,难说哪一种最好。当然从功能来说 SpELl 是最强大的,但是相应消耗的资源肯定也多。至于其他几种方式,功能差别不大,有条件的可以跑跑它们的性能比较,这样就比较容易有结论了。
