介绍
写项目时遇到模板渲染字符串的问题,于是写了一个简单的文本模板工具
不同于专门的文本模板渲染引擎,这个小工具是单文件的,十分轻量
该工具支持自定义占位符,比内置的 String.format()
和 Message.format()
更灵活
用法示例
假如我要渲染的模板字符串是 HTML 格式的内容,我将其写在一个 my_html_template.html
文件中
我自定义参数占位符的格式为 <!--{ var }-->
,这样的定义可以使 IDE 在解析 HTML 代码时能将模板参数视为注释,顺利进行语法着色而不会报语法错误
<div>
<h1>天气报告</h1>
<p>日期: <!--{ date }--></p>
<p>时间: <!--{ time }--></p>
<p>天气: <!--{ weather }--></p>
<p>气温: <!--{ temperature }--></p>
</div>
然后我读入 my_html_template.html
文件,构造一个 TextTemplate
实例,便可在运行时根据参数列表渲染模板文本
public class Demo {
public static void main(String[] args) {
/* 读取模板文本 */
String tmplText = Files.readString(Path.of("path/to/my_html_template.html"), UTF_8);
/* 指定参数分界符 */
String paramBegin = "<!--{";
String paramEnd = "}-->";
/* 构造模板 */
TextTemplate tmpl = new TextTemplate(tmplText, paramBegin, paramEnd);
/* 查看模板中的占位参数 */
System.out.println("param list = " + tmpl.getParams());
System.out.println("---------");
/* 以键值对的形式提供参数 */
Map<String, Object> args = Map.of(
"date", "2024-05-21",
/* "time", "00:00", */
"weather", "晴天",
"temperature", "25°C"
);
String renderedText1 = tmpl.render(args);
System.out.println(renderedText1);
System.out.println("---------");
/* 以变长参数列表的形式向模板传递参数 */
String renderedText2 = tmpl.render("2024-05-21", "00:00", "阵雨" /*, "14°C" */);
System.out.println(renderedText2);
System.out.println("---------");
}
}
输出:
param list = [date, time, weather, temperature]
---------
<div>
<h1>天气报告</h1>
<p>日期: 2024-05-21</p>
<p>时间: </p>
<p>天气: 晴天</p>
<p>气温: 25°C</p>
</div>
---------
<div>
<h1>天气报告</h1>
<p>日期: 2024-05-21</p>
<p>时间: 00:00</p>
<p>天气: 阵雨</p>
<p>气温: </p>
</div>
---------
代码实现
/**
* <p> 简单的文本模板工具
* <p> 在模板文本中定义占位参数,运行时将这些占位参数替换为实际的值,动态地生成文本内容
* <p>
* <b> 占位参数的命名规则: </b>
* <ol>
* <li> 可以自定义占位参数的分界符,例如: #{ }、{{ }}、<-!- -->、甚至是 % 和空格
* <br> 接下来的介绍中,使用 #{ } 来作占位参数的分界符
* <br>
* <li> #{ } 所包裹的字符串截取前后空格后就是参数的名称
* <p> 模板:"Hello, #{ name }! Today is #{ day }."
* <br> 参数:[ "name", "day" ]
* <br>
* <li> 占位参数命名只要求前后不含空白字符,不必遵守标识符的起名规则,可以包含特殊字符
* <p> 模板:"#{ @ }, #{ ## }, #{ the answer }, #{ 1 } "
* <br> 参数:[ "@", "##", "the answer", "1" ]
* </ol>
* <b> 占位参数的识别规则: </b>
* <ol>
* <br> 如果 #{ } 内部为空,则忽略这个占位符
* <br> 模板:"#{} #{a}"
* <br> 参数:["a"]
* <br>
* <li> 如果 #{ } 内部还有 #{ },则将内部的 #{ } 识别为参数,外部的识别为文本
* <br> 模板:"#{ #{} 1! 5! }"
* <br> 参数:[]
* <br> 解释:"#{ #{} 1! 5! }" 内部还有 #{ },所以外部的 #{ } 识别为文本,
* <br> 内部的 #{ } 识别为占位参数,但由于内部的 #{ } 为空,则忽略这个占位参数,
* <br> 所以整个模板没有参数,只有一段文本,即 "#{ 1! 5! }"
* </ol>
* */
public class TextTemplate {
/**
* <p> 创建模板
* <p> 一旦创建模板,它就会解析模板字符串中的文本部分和参数部分
* 频繁创建模板会导致不必要的性能开销,建议尽可能地复用已创建的模板
* <br> 模板创建完毕后只会持有分析后的模板结构,而不会持有原始的模板文本
* @param templateText 模板文本
* @param paramBegin 占位参数的起始字符
* @param paramEnd 占位参数的结束字符
* */
public TextTemplate(String templateText, String paramBegin, String paramEnd) {
parts = new Analyzer(templateText).analyze(paramBegin, paramEnd);
}
/**
* <p> 渲染模板,根据提供的参数映射替换模板中的占位参数,并返回生成的文本
* <p> 以键值对的形式提供参数,键是占位参数的名称,值是要替换成的内容
* <br> 如果提供的参数有缺失,则该占位参数会被替换成空文本
* @param args 参数
* */
public String render(Map<String, Object> args) {
StringBuilder result = new StringBuilder(256);
for (Part part : parts) {
switch (part.type) {
case TEXT -> result.append(part.content);
case PARAM -> result.append(args.getOrDefault(part.content, ""));
}
}
return result.toString();
}
/**
* <p> 渲染模板,根据提供的参数映射替换模板中的占位参数,并返回生成的文本
* <p> 以变长参数列表的形式提供参数,列表中的元素会按位置依次替换模板中的占位参数
* <br> 如果提供的参数数量不足,则剩余占位参数会被替换成空文本
* <br> 如果提供的参数数量过多,则忽略多出的那部分参数
* @param args 参数
* */
public String render(Object ...args) {
int i = 0;
StringBuilder result = new StringBuilder(256);
for (Part part : parts) {
switch (part.type) {
case TEXT -> result.append(part.content);
case PARAM -> result.append( (i < args.length) ? args[i++] : "" );
}
}
return result.toString();
}
/**
* <p> 获取模板中所有占位的参数名
* <p> 注意:
* <br> 该操作为线性时间复杂度,不要频繁地调用该方法
* <br> 该方法只是为了方便在开发过程中验证模板参数是否符合预期
* */
public List<String> getParams() {
List<String> result = new ArrayList<>();
for (Part part : parts)
if (part.type == Type.PARAM)
result.add(part.content);
return result;
}
/* 构建模板对象时将文本模板解析成一个 Part 列表,
每个 Part 表示一个文本片段,或者一个占位参数
例如,如果以 #{ } 作为占位参数的分界符,则
"Hello, #{ name }! Today is #{ day }."
解析成
parts = [
(TEXT, "Hello, "),
(PARAM, "name"),
(TEXT, "! Today is "),
(PARAM, "day"),
(TEXT, ".")
];
可见,TEXT 和 PARAM 总是交替出现,所以只需交替提取模板文本中的 TEXT 和 PARAM 部分即可
*/
private enum Type { TEXT, PARAM }
private record Part (Type type, String content) { }
/* 存储分析后的模板结构 */
private final List<Part> parts;
private record Analyzer(String template) {
List<Part> analyze(String paramBegin, String paramEnd) {
if (template == null || template.isEmpty())
return List.of(new Part(Type.TEXT, ""));
if (paramBegin == null || paramBegin.isEmpty() || paramEnd == null || paramEnd.isEmpty())
return List.of(new Part(Type.TEXT, template));
List<Part> result = new ArrayList<>();
int idxCurrent = 0;
while (idxCurrent < template.length()) {
/* 寻找 PARAM */
int idxParamBegin = indexOf(paramBegin, idxCurrent);
int idxParamEnd = indexOf(paramEnd, idxParamBegin + paramBegin.length());
if (idxParamEnd == template.length()) {
idxParamBegin = template.length();
} else {
int idxNextParamBegin = indexOf(paramBegin, idxParamBegin + paramBegin.length());
while ( ! (idxParamBegin < idxParamEnd && idxParamEnd <= idxNextParamBegin)) {
idxParamBegin = idxNextParamBegin;
idxNextParamBegin = indexOf(paramBegin, idxParamBegin + paramBegin.length());
}
}
/* 提取 TEXT */
String text = template.substring(idxCurrent, idxParamBegin);
if (text.length() != 0) {
result.add(new Part(Type.TEXT, text));
idxCurrent = idxParamBegin;
}
/* 提取 PARAM */
if (idxParamBegin != template.length()) {
String param = template.substring(idxParamBegin + paramBegin.length(), idxParamEnd).trim();
if (param.length() != 0) {
result.add(new Part(Type.PARAM, param));
}
idxCurrent = idxParamEnd + paramEnd.length();
}
}
return result;
}
int indexOf(String str, int fromIndex) {
int result = template.indexOf(str, fromIndex);
return result != -1 ? result : template.length();
}
}
}