TDD两小时实现自定义表达式模板解析器-创新互联
为什么要重新造一个车轮?
很多情况下,用户需要按其自定义模板动态生成邮件、PDF。开源组件中,有两类较贴合需求的产品系列:
创新互联专注为客户提供全方位的互联网综合服务,包含不限于成都做网站、成都网站建设、樟树网络推广、小程序设计、樟树网络营销、樟树企业策划、樟树品牌公关、搜索引擎seo、人物专访、企业宣传片、企业代运营等,从售前售中售后,我们都将竭诚为您服务,您的肯定,是我们大的嘉奖;创新互联为所有大学生创业者提供樟树建站搭建服务,24小时服务热线:18980820575,官方网址:www.cdcxhl.com模板渲染引擎,如FreeMarker, Velocity虽然强大异常,但是过于灵活,不利于按需裁减出自己想要的少量语法;
纯字符串模板引擎,要么取数据不够动态(需要提前预知有哪些变量),或者是语法冗长(函数调用来实现动态扩展)不利于非IT人事编写。
那么有没有一款产品,既简洁可控,又易于扩展呢?
其实自己实现一个够用的模板解析器,也是很简单的事情,下面分享一款我两小时在融创地产HR项目中实现的模板解析器。
本实现没有任何外部依赖,很容易移植到其它语言,比如用javascript实现甚至更简单。
用户场景
用户的原始需求:
亲爱的XXX先生/女士 你好!欢迎加入XXX公司,你的部门是XXX,岗位职级XXX … 人事部 HR XXX先生/女士
模板设计:
亲爱的${uid|userInfo|prop:name}${uid|userInfo|prop:gender|genderName} 你好!欢迎加入 一天一个小目标 公司,你的部门是${uid|department|prop:name},岗位职级${uid|position|prop:name} … 人事部 HR ${my|prop:name}${my|prop:gender|genderName}
语法(静态语法)
"${}":需要值替换的表达式,包含在"${"与"}"之间;
"|": 顺序串联单个表达式的多个函数调用,前一调用的值会作为后一调用的第一参数;
":": 若调用有额外参数,则追加在":"之后;
",": 若额外参数不止一个,参以","分隔。
函数说明(可扩展及控制部分)
uid: 获取当前调用的目标用户id
userInfo: 根据用户id获取用户信息
prop:name: 获取对象的"name"属性
prop:gender: 获取对象的"gender"属性
genderName: 获取性别的中文名
department: 根据用户id获取所在部门信息
position: 根据用户id获取其岗位信息
my: 获取session中当前登录的用户信息
Tips: 此处是为了可读性使用了相对完整的单词。实际为了简洁,我们采用了单个到两个字母表示每个函数(如:"P:name"="prop:name","GN"="genderName"),然后在前端文本编辑器下方给用户一张函数表去定制模板,实践证明在语法、函数不多的情况下,对非IT人士整个模板的简洁比部分内容的可读性更重要
实现过程
代码库 https://gitee.com/chentao106/SimpleExpressionInterpreter 通过提交记录完整展示了实现过程,整体只需要五步,即可实现一个面向非IT人士的自定义表达式模板解析器:
第一步 创建代码框架及测试用例
先创建我们的解析器类,及其最重要的方法eval,即模板求值:
//SimpleExpressionInterpreter.javapublic class SimpleExpressionInterpreter { public String eval(String template) { return null; } }
测试驱动开发,当然要先编写测试用例:
//SimpleExpressionInterpreterTester.javaimport org.junit.Assert;import org.junit.Test;public class SimpleExpressionInterpreterTester { static final String template = "亲爱的${uid|userInfo|prop:name}${uid|userInfo|prop:gender|genderName}\n" + " 你好!欢迎加入不存在公司,你的部门是${uid|department|prop:name},岗位职级${uid|position|prop:name}…\n" + " 人事部 HR ${my|prop:name}${my|prop:gender|genderName}\n"; static final String value = "亲爱的李四女士\n" + " 你好!欢迎加入不存在公司,你的部门是互联网行销部,岗位职级产品经理T1…\n" + " 人事部 HR 张三先生\n"; private SimpleExpressionInterpreter testObj = new SimpleExpressionInterpreter(); @Test public void testEval() { Assert.assertEquals(value, testObj.eval(template)); } }
此时,测试用例当然是执行不通过的,我们想办法让测试先通过,才好进行下一步,同时定义一下我们的语法关键字:
//SimpleExpressionInterpreter.javapublic class SimpleExpressionInterpreter { protected String expressionStart = "${"; protected String expressionEnd = "}"; protected String invocationSplit = "|"; protected String methodNameSplit = ":"; protected String parameterSplit = ","; protected char escape = '\\'; public String eval(String template) { return "亲爱的李四女士\n" + " 你好!欢迎加入不存在公司,你的部门是互联网行销部,岗位职级产品经理T1…\n" + " 人事部 HR 张三先生\n"; } }
运行测试用例,保证通过
第二步 提取字符器模板中的表达式
修改SimpleExpressionInterpreter.java文件,在其中增加
//SimpleExpressionInterpreter.java ListfindExpressions(String template) { return null; }
增加测试用例
//SimpleExpressionInterpreterTester.java @Test public void testFindExpressions() { Assert.assertEquals(Collections.EMPTY_LIST, testObj.findExpressions("{a}")); Assert.assertEquals(Collections.singletonList("${a}"), testObj.findExpressions("${a}")); Assert.assertEquals(Collections.singletonList("${a}"), testObj.findExpressions("\\$${a}")); Assert.assertEquals(Arrays.asList("${a}", "${b}"), testObj.findExpressions("${a}${b}")); Assert.assertEquals(Arrays.asList("${a}", "${b}"), testObj.findExpressions("Hello ${a}, world${b}")); Assert.assertEquals(Collections.singletonList("${a\\}${b}"), testObj.findExpressions("${a\\}${b}")); Assert.assertEquals(Collections.EMPTY_LIST, testObj.findExpressions("${a\\}${b")); Assert.assertEquals(Arrays.asList("${uid|userInfo|prop:name}", "${uid|userInfo|prop:gender|genderName}", "${uid|department|prop:name}", "${uid|position|prop:name}", "${my|prop:name}", "${my|prop:gender|genderName}"), testObj.findExpressions(template)); }
为确保测试通过,修改SimpleExpressionInterpreter.java:
//SimpleExpressionInterpreter.java int nextDivider(String template, String divider, int fromIndex) { int pos; int from = fromIndex; do { pos = template.indexOf(divider, from); if (pos == 0) return pos; if (pos > 0 && template.charAt(pos - 1) != escape) return pos; from = pos + 1; } while (pos >= 0); return -1; } ListfindExpressions(String template) { List expressions = new LinkedList<>(); int fromIndex = 0; String expression; do { int beginIndex = nextDivider(template, expressionStart, fromIndex); if (beginIndex < 0) break; int endIndex = nextDivider(template, expressionEnd, beginIndex + expressionStart.length()); if (endIndex < 0) break; expression = template.substring(beginIndex, endIndex + expressionEnd.length()); expressions.add(expression); fromIndex = endIndex + expressionEnd.length(); } while (true); return expressions; }
为了重用表达式前缀和后缀的查找代码,我们提取了公共函数nextDivider,我们也可以给它增加测试用例:
//SimpleExpressionInterpreterTester.java @Test public void testNextDivider() { Assert.assertEquals(-1, testObj.nextDivider("{a}", testObj.expressionStart, 0)); Assert.assertEquals(0, testObj.nextDivider("${a}", testObj.expressionStart, 0)); Assert.assertEquals(2, testObj.nextDivider("\\$${a}", testObj.expressionStart, 0)); Assert.assertEquals(4, testObj.nextDivider("${a}${b}", testObj.expressionStart, 1)); Assert.assertEquals(3, testObj.nextDivider("${a}${b}", testObj.expressionEnd, 1)); Assert.assertEquals(8, testObj.nextDivider("${a\\}${b}", testObj.expressionEnd, 1)); Assert.assertEquals(-1, testObj.nextDivider("${a\\}${b", testObj.expressionEnd, 1)); }
运行测试用例,保证通过
第三步 解析表达式调用链
调用必须先用一个实体来表示:
//Invocation.javaimport java.util.Arrays;public class Invocation { private String method; private String[] extraParams; public Invocation(String method, String... extraParams) { this.method = method; this.extraParams = extraParams; } public Invocation(String method) { this(method, null); } public String getMethod() { return method; } public String[] getExtraParams() { return extraParams; } @Override public int hashCode() { return method.hashCode() + (extraParams == null ? 0 : Arrays.hashCode(extraParams)); } @Override public boolean equals(Object obj) { if (obj == null) return false; if (!this.getClass().isInstance(obj)) return false; if (this.hashCode() != obj.hashCode()) return false; Invocation another = (Invocation) obj; return this.method.equals(another.method) && Arrays.equals(this.extraParams, another.extraParams); } @Override public String toString() { return extraParams == null || extraParams.length == 0 ? method : String.format("%s:%s", method, String.join(",", extraParams)); } }
增加解析调用链的函数声明:
//SimpleExpressionInterpreter.java ListparseInvocations(String expression) { return null; }
增加测试用例:
//SimpleExpressionInterpreterTester.java @Test public void testParseInvocations() { Assert.assertEquals(Collections.EMPTY_LIST, testObj.parseInvocations("")); Assert.assertEquals(Collections.singletonList(new Invocation("a")), testObj.parseInvocations("${a}")); Assert.assertEquals(Arrays.asList(new Invocation("a"), new Invocation("b"), new Invocation("c")), testObj.parseInvocations("${a|b|c}")); Assert.assertEquals(Arrays.asList(new Invocation("uid"), new Invocation("userInfo"), new Invocation("prop", "name")), testObj.parseInvocations("${uid|userInfo|prop:name}")); }
为了通过测试,修改SimpleExpressionInterpreter.java:
//SimpleExpressionInterpreter.java ListparseInvocations(String expression) { if (expression == null || expression.length() < expressionStart.length() + expressionEnd.length()) return Collections.emptyList(); String statement = expression.substring(expressionStart.length(), expression.length() - expressionEnd.length()); String[] phrases = split(statement, invocationSplit); List invocations = new ArrayList<>(phrases.length); for (String phrase : phrases) { invocations.add(parseInvocation(phrase)); } return invocations; } private Invocation parseInvocation(String phrase) { int methodNameEndIndex = phrase.indexOf(methodNameSplit); if (methodNameEndIndex > 0) { String method = phrase.substring(0, methodNameEndIndex); String parameterStr = phrase.substring(methodNameEndIndex + methodNameSplit.length()); String[] parameters = parameterStr.split(parameterSplit); return new Invocation(method, parameters); } else { return new Invocation(phrase); } } String[] split(String text, String delimiter) { if (text == null) return null; if (delimiter == null || delimiter.length() == 0) return new String[]{text}; List data = new ArrayList<>(); int pos = 0; for (int from = 0; from >= 0; from = pos + delimiter.length()) { pos = text.indexOf(delimiter, from); if (pos >= 0) { data.add(text.substring(from, pos)); } else { data.add(text.substring(from)); break; } } return data.toArray(new String[0]); }
为了支持使用"|"串连表达式,我们重写了String的split函数(split使用正则表达式拆分,而"|"是正则表达式的关键字,不考虑语法可替换、可跨语言移植的情况下,可以直接转义"\|"+String.split),我们也为它加上测试用例:
//SimpleExpressionInterpreterTester.java @Test public void testSplit() { Assert.assertNull(testObj.split(null, "|")); Assert.assertArrayEquals(new String[]{"a|b"}, testObj.split("a|b", null)); Assert.assertArrayEquals(new String[]{"a|b"}, testObj.split("a|b", "")); Assert.assertArrayEquals(new String[]{"a", "b"}, testObj.split("a|b", "|")); Assert.assertArrayEquals(new String[]{"ab", "cd:ef,gh"}, testObj.split("ab|cd:ef,gh", "|")); Assert.assertArrayEquals(new String[]{"ab", "cd", "ef", "gh"}, testObj.split("ab,cd,ef,gh", ",")); }
运行测试用例,保证通过
第四步 实现表达式求值
到了最关键的表达式求值步骤,照旧我们还是先定义函数
//SimpleExpressionInterpreter.java String evalExpression(String expression) { return null; }
编写测试
//SimpleExpressionInterpreterTester.java @Test public void testEvalExpression() { Assert.assertEquals("李四", testObj.evalExpression("${uid|userInfo|prop:name}")); Assert.assertEquals("女士", testObj.evalExpression("${uid|userInfo|prop:gender|genderName}")); Assert.assertEquals("先生", testObj.evalExpression("${my|prop:gender|genderName}")); Assert.assertEquals("", testObj.evalExpression("${my1|prop:gender|genderName}")); }
如何实现表达式求值呢?首先我想到了javascript可以通过函数名来调用对象的方法,如果是java就要用到反射了。也就是说,我们可以把函数调用全部委托给另一个对象,我称作methodProvider,那么开始动手吧:
//SimpleExpressionInterpreter.java //增加成员变量,并通过注入一个methodProvider private final Object methodProvider; public SimpleExpressionInterpreter(Object methodProvider) { this.methodProvider = methodProvider; } //实现回调逻辑 private Method findMethod(Class> clazz, String methodName) { for (Method m : clazz.getMethods()) { if (m.getName().equals(methodName)) { return m; } } return null; } private Object evalInvocations(Listinvocations) { boolean firstCall = true; Object result = null; try { for (Invocation invocation : invocations) { Method m = findMethod(methodProvider.getClass(), invocation.getMethod()); if (m == null) return null; Object[] args; if (invocation.getExtraParams() != null) { args = new Object[invocation.getExtraParams().length + (firstCall ? 0 : 1)]; if (!firstCall) args[0] = result; System.arraycopy(invocation.getExtraParams(), 0, args, firstCall ? 0 : 1, invocation.getExtraParams().length); } else { args = firstCall ? new Object[0] : new Object[]{result}; } result = m.invoke(methodProvider, args); firstCall = false; } } catch (IllegalAccessException | InvocationTargetException e) { return null; } return result; } String evalExpression(String expression) { List invocations = parseInvocations(expression); Object result = evalInvocations(invocations); return result == null ? "" : result.toString(); }
为了测试,我们要实现一个DemoMethodProvider,实际应用时,MethodProvider类就决定了你想向用户提供哪些可用函数:
//DemoMethodProvider.javaimport java.util.Collections;import java.util.HashMap;import java.util.Map;public class DemoMethodProvider { private MapcallParameters;//模拟实时传入的参数 private Map > demoDB;//模拟数据库中的数据 public DemoMethodProvider() { callParameters = new HashMap<>(); Map my = new HashMap<>(); my.put("id", 0); my.put("name", "张三"); my.put("gender", 1); callParameters.put("my", my); callParameters.put("uid", 1); demoDB = new HashMap<>(); Map you = new HashMap<>(); you.put("id", 1); you.put("name", "李四"); you.put("gender", 2); demoDB.put(String.format("user:%d", 1), you); Map yourDepartment = Collections.singletonMap("name", "互联网行销部"); demoDB.put(String.format("user:%d:department", 1), yourDepartment); Map yourPosition = Collections.singletonMap("name", "产品经理T1"); demoDB.put(String.format("user:%d:position", 1), yourPosition); } public Object uid() { return callParameters.get("uid"); } public Object my() { return callParameters.get("my"); } public Map userInfo(int uid) { return demoDB.get(String.format("user:%d", uid)); } public Map department(int uid) { return demoDB.get(String.format("user:%d:department", uid)); } public Map position(int uid) { return demoDB.get(String.format("user:%d:position", uid)); } public Object prop(Map map, String propName) { return map.get(propName); } public String genderName(int gender) { switch (gender) { case 1: return "先生"; case 2: return "女士"; default: return ""; } } }
测试用例中创建SimpleExpressionInterpreter时注入DemoMethodProvider
//SimpleExpressionInterpreterTester.javaprivate SimpleExpressionInterpreter testObj = new SimpleExpressionInterpreter(new DemoFunctionProvider());
运行测试用例,保证通过
第五步 组装代码实现模板求值
第一步我们通过写死返回值,已经“实现”了固定模板的解析,当然这个“实现”是静态的,我们首先修改测试用例,暴露代码问题(当然更建议的是增加更多完整 的模板->结果测试用例):
//SimpleExpressionInterpreterTester.java @Test public void testEval() { Assert.assertEquals(value, testObj.eval(template)); Assert.assertEquals(value + "...", testObj.eval(template + "...")); Assert.assertEquals("", testObj.eval("")); Assert.assertNull(testObj.eval(null)); }
修改实现以保证测试通过:
//SimpleExpressionInterpreter.java public String eval(String template) { if (template == null || template.length() == 0) return template; String result = template; Listexpressions = findExpressions(template); for (String expression : expressions) { String value = evalExpression(expression); result = result.replace(expression, value); } return result; }
上面的自定义表达式模板解析器虽然还有改进空间,但是在大部分情况下都已经够用了,这不就是测试驱动的高效之处吗? 到此,我们可以非常自信地说,我们快速实现了一个高质量、简洁够用的自定义表达式模板解析器,可以放心的使用到业务代码中去。
后续工作
作为一款组件,上面的自定义表达式模板解析器,还有一定的改良空间:
模板求值采用replace会涉及内存分配,可以在解析表达式的同时,把模板片段也解析出来,在求值后整体进行一次拼字符串操作;
解析器的创建可以引入Builder生成器,从而语法关键字可以实现运行时的动态指定;
对转义字符的支持——上面的实现实际已经支持了表达式之外的转义,即用户内容中有${关键字,但是没有处理表达式内的转义,即表达式包含},但是基于这个表达式的初衷,大家自行决断吧!!
另外有需要云服务器可以了解下创新互联cdcxhl.cn,海内外云服务器15元起步,三天无理由+7*72小时售后在线,公司持有idc许可证,提供“云服务器、裸金属服务器、高防服务器、香港服务器、美国服务器、虚拟主机、免备案服务器”等云主机租用服务以及企业上云的综合解决方案,具有“安全稳定、简单易用、服务可用性高、性价比高”等特点与优势,专为企业上云打造定制,能够满足用户丰富、多元化的应用场景需求。
网页名称:TDD两小时实现自定义表达式模板解析器-创新互联
网站地址:http://ybzwz.com/article/degiod.html