TDD两小时实现自定义表达式模板解析器

为什么要重新造一个车轮?

很多情况下,用户需要按其自定义模板动态生成邮件、PDF。开源组件中,有两类较贴合需求的产品系列:

  1. 模板渲染引擎,如FreeMarker, Velocity虽然强大异常,但是过于灵活,不利于按需裁减出自己想要的少量语法;
  2. 纯字符串模板引擎,要么取数据不够动态(需要提前预知有哪些变量),或者是语法冗长(函数调用来实现动态扩展)不利于非IT人士编写。

那么有没有一款产品,既简洁可控,又易于扩展呢?

其实自己实现一个够用的模板解析器,也是很简单的事情,下面分享一款我两小时在融创地产HR项目中实现的模板解析器。

本实现没有任何外部依赖,很容易移植到其它语言,比如用javascript实现甚至更简单。

用户场景

用户的原始需求:

1亲爱的XXX先生/女士 2 你好!欢迎加入XXX公司,你的部门是XXX,岗位职级XXX 34 人事部 HR XXX先生/女士 5

模板设计:

1亲爱的${uid|userInfo|prop:name}${uid|userInfo|prop:gender|genderName} 2 你好!欢迎加入 一天一个小目标 公司,你的部门是${uid|department|prop:name},岗位职级${uid|position|prop:name} 34 人事部 HR ${my|prop:name}${my|prop:gender|genderName} 5

语法(静态语法)

  • "${}":需要值替换的表达式,包含在"${"与"}"之间;
  • "|": 顺序串联单个表达式的多个函数调用,前一调用的值会作为后一调用的第一参数;
  • ":": 若调用有额外参数,则追加在":"之后;
  • ",": 若额外参数不止一个,则以","分隔。

函数说明(可扩展及控制部分)

  • 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,即模板求值:

1//SimpleExpressionInterpreter.java 2public class SimpleExpressionInterpreter { 3 public String eval(String template) { 4 return null; 5 } 6} 7

测试驱动开发,当然要先编写测试用例:

1//SimpleExpressionInterpreterTester.java 2import org.junit.Assert; 3import org.junit.Test; 4 5public class SimpleExpressionInterpreterTester { 6 static final String template = "亲爱的${uid|userInfo|prop:name}${uid|userInfo|prop:gender|genderName}\n" + 7 " 你好!欢迎加入不存在公司,你的部门是${uid|department|prop:name},岗位职级${uid|position|prop:name}…\n" + 8 " 人事部 HR ${my|prop:name}${my|prop:gender|genderName}\n"; 9 static final String value = "亲爱的李四女士\n" + 10 " 你好!欢迎加入不存在公司,你的部门是互联网行销部,岗位职级产品经理T1…\n" + 11 " 人事部 HR 张三先生\n"; 12 13 private SimpleExpressionInterpreter testObj = new SimpleExpressionInterpreter(); 14 15 @Test 16 public void testEval() { 17 Assert.assertEquals(value, testObj.eval(template)); 18 } 19} 20

此时,测试用例当然是执行不通过的,我们想办法让测试先通过,才好进行下一步,同时定义一下我们的语法关键字:

1//SimpleExpressionInterpreter.java 2public class SimpleExpressionInterpreter { 3 protected String expressionStart = "${"; 4 protected String expressionEnd = "}"; 5 protected String invocationSplit = "|"; 6 protected String methodNameSplit = ":"; 7 protected String parameterSplit = ","; 8 protected char escape = '\\'; 9 10 public String eval(String template) { 11 return "亲爱的李四女士\n" + 12 " 你好!欢迎加入不存在公司,你的部门是互联网行销部,岗位职级产品经理T1…\n" + 13 " 人事部 HR 张三先生\n"; 14 } 15} 16

运行测试用例,保证通过

第二步 提取字符器模板中的表达式

修改SimpleExpressionInterpreter.java文件,在其中增加

1 //SimpleExpressionInterpreter.java 2 List<String> findExpressions(String template) { 3 return null; 4 } 5

增加测试用例

1 //SimpleExpressionInterpreterTester.java 2 @Test 3 public void testFindExpressions() { 4 Assert.assertEquals(Collections.EMPTY_LIST, testObj.findExpressions("{a}")); 5 Assert.assertEquals(Collections.singletonList("${a}"), testObj.findExpressions("${a}")); 6 Assert.assertEquals(Collections.singletonList("${a}"), testObj.findExpressions("\\$${a}")); 7 Assert.assertEquals(Arrays.asList("${a}", "${b}"), testObj.findExpressions("${a}${b}")); 8 Assert.assertEquals(Arrays.asList("${a}", "${b}"), testObj.findExpressions("Hello ${a}, world${b}")); 9 Assert.assertEquals(Collections.singletonList("${a\\}${b}"), testObj.findExpressions("${a\\}${b}")); 10 Assert.assertEquals(Collections.EMPTY_LIST, testObj.findExpressions("${a\\}${b")); 11 Assert.assertEquals(Arrays.asList("${uid|userInfo|prop:name}", "${uid|userInfo|prop:gender|genderName}", 12 "${uid|department|prop:name}", "${uid|position|prop:name}", 13 "${my|prop:name}", "${my|prop:gender|genderName}"), testObj.findExpressions(template)); 14 } 15

为确保测试通过,修改SimpleExpressionInterpreter.java:

1 //SimpleExpressionInterpreter.java 2 int nextDivider(String template, String divider, int fromIndex) { 3 int pos; 4 int from = fromIndex; 5 do { 6 pos = template.indexOf(divider, from); 7 if (pos == 0) return pos; 8 if (pos > 0 && template.charAt(pos - 1) != escape) return pos; 9 from = pos + 1; 10 } while (pos >= 0); 11 return -1; 12 } 13 14 List<String> findExpressions(String template) { 15 List<String> expressions = new LinkedList<>(); 16 int fromIndex = 0; 17 String expression; 18 do { 19 int beginIndex = nextDivider(template, expressionStart, fromIndex); 20 if (beginIndex < 0) break; 21 int endIndex = nextDivider(template, expressionEnd, beginIndex + expressionStart.length()); 22 if (endIndex < 0) break; 23 expression = template.substring(beginIndex, endIndex + expressionEnd.length()); 24 expressions.add(expression); 25 fromIndex = endIndex + expressionEnd.length(); 26 } while (true); 27 return expressions; 28 } 29

为了重用表达式前缀和后缀的查找代码,我们提取了公共函数nextDivider,我们也可以给它增加测试用例:

1 //SimpleExpressionInterpreterTester.java 2 @Test 3 public void testNextDivider() { 4 Assert.assertEquals(-1, testObj.nextDivider("{a}", testObj.expressionStart, 0)); 5 Assert.assertEquals(0, testObj.nextDivider("${a}", testObj.expressionStart, 0)); 6 Assert.assertEquals(2, testObj.nextDivider("\\$${a}", testObj.expressionStart, 0)); 7 Assert.assertEquals(4, testObj.nextDivider("${a}${b}", testObj.expressionStart, 1)); 8 Assert.assertEquals(3, testObj.nextDivider("${a}${b}", testObj.expressionEnd, 1)); 9 Assert.assertEquals(8, testObj.nextDivider("${a\\}${b}", testObj.expressionEnd, 1)); 10 Assert.assertEquals(-1, testObj.nextDivider("${a\\}${b", testObj.expressionEnd, 1)); 11 } 12

运行测试用例,保证通过

第三步 解析表达式调用链

调用必须先用一个实体来表示:

1//Invocation.java 2import java.util.Arrays; 3 4public class Invocation { 5 private String method; 6 private String[] extraParams; 7 8 public Invocation(String method, String... extraParams) { 9 this.method = method; 10 this.extraParams = extraParams; 11 } 12 13 public Invocation(String method) { 14 this(method, null); 15 } 16 17 public String getMethod() { 18 return method; 19 } 20 21 public String[] getExtraParams() { 22 return extraParams; 23 } 24 25 @Override 26 public int hashCode() { 27 return method.hashCode() + (extraParams == null ? 0 : Arrays.hashCode(extraParams)); 28 } 29 30 @Override 31 public boolean equals(Object obj) { 32 if (obj == null) return false; 33 if (!this.getClass().isInstance(obj)) return false; 34 if (this.hashCode() != obj.hashCode()) return false; 35 Invocation another = (Invocation) obj; 36 return this.method.equals(another.method) && Arrays.equals(this.extraParams, another.extraParams); 37 } 38 39 @Override 40 public String toString() { 41 return extraParams == null || extraParams.length == 0 ? method : String.format("%s:%s", method, String.join(",", extraParams)); 42 } 43} 44

增加解析调用链的函数声明:

1//SimpleExpressionInterpreter.java 2 List<Invocation> parseInvocations(String expression) { 3 return null; 4 } 5

增加测试用例:

1//SimpleExpressionInterpreterTester.java 2 @Test 3 public void testParseInvocations() { 4 Assert.assertEquals(Collections.EMPTY_LIST, testObj.parseInvocations("")); 5 Assert.assertEquals(Collections.singletonList(new Invocation("a")), testObj.parseInvocations("${a}")); 6 Assert.assertEquals(Arrays.asList(new Invocation("a"), new Invocation("b"), new Invocation("c")), testObj.parseInvocations("${a|b|c}")); 7 Assert.assertEquals(Arrays.asList(new Invocation("uid"), new Invocation("userInfo"), 8 new Invocation("prop", "name")), testObj.parseInvocations("${uid|userInfo|prop:name}")); 9 } 10

为了通过测试,修改SimpleExpressionInterpreter.java:

1//SimpleExpressionInterpreter.java 2 List<Invocation> parseInvocations(String expression) { 3 if (expression == null || expression.length() < expressionStart.length() + expressionEnd.length()) return Collections.emptyList(); 4 String statement = expression.substring(expressionStart.length(), expression.length() - expressionEnd.length()); 5 String[] phrases = split(statement, invocationSplit); 6 List<Invocation> invocations = new ArrayList<>(phrases.length); 7 for (String phrase : phrases) { 8 invocations.add(parseInvocation(phrase)); 9 } 10 return invocations; 11 } 12 13 private Invocation parseInvocation(String phrase) { 14 int methodNameEndIndex = phrase.indexOf(methodNameSplit); 15 if (methodNameEndIndex > 0) { 16 String method = phrase.substring(0, methodNameEndIndex); 17 String parameterStr = phrase.substring(methodNameEndIndex + methodNameSplit.length()); 18 String[] parameters = parameterStr.split(parameterSplit); 19 return new Invocation(method, parameters); 20 } else { 21 return new Invocation(phrase); 22 } 23 } 24 25 String[] split(String text, String delimiter) { 26 if (text == null) return null; 27 if (delimiter == null || delimiter.length() == 0) return new String[]{text}; 28 List<String> data = new ArrayList<>(); 29 int pos = 0; 30 for (int from = 0; from >= 0; from = pos + delimiter.length()) { 31 pos = text.indexOf(delimiter, from); 32 if (pos >= 0) { 33 data.add(text.substring(from, pos)); 34 } else { 35 data.add(text.substring(from)); 36 break; 37 } 38 } 39 return data.toArray(new String[0]); 40 } 41

为了支持使用"|"串连表达式,我们重写了String的split函数(split使用正则表达式拆分,而"|"是正则表达式的关键字,不考虑语法可替换、可跨语言移植的情况下,可以直接转义"\|"+String.split),我们也为它加上测试用例:

1//SimpleExpressionInterpreterTester.java 2 @Test 3 public void testSplit() { 4 Assert.assertNull(testObj.split(null, "|")); 5 Assert.assertArrayEquals(new String[]{"a|b"}, testObj.split("a|b", null)); 6 Assert.assertArrayEquals(new String[]{"a|b"}, testObj.split("a|b", "")); 7 Assert.assertArrayEquals(new String[]{"a", "b"}, testObj.split("a|b", "|")); 8 Assert.assertArrayEquals(new String[]{"ab", "cd:ef,gh"}, testObj.split("ab|cd:ef,gh", "|")); 9 Assert.assertArrayEquals(new String[]{"ab", "cd", "ef", "gh"}, testObj.split("ab,cd,ef,gh", ",")); 10 } 11

运行测试用例,保证通过

第四步 实现表达式求值

到了最关键的表达式求值步骤,照旧我们还是先定义函数

1//SimpleExpressionInterpreter.java 2 String evalExpression(String expression) { 3 return null; 4 } 5

编写测试

1//SimpleExpressionInterpreterTester.java 2 @Test 3 public void testEvalExpression() { 4 Assert.assertEquals("李四", testObj.evalExpression("${uid|userInfo|prop:name}")); 5 Assert.assertEquals("女士", testObj.evalExpression("${uid|userInfo|prop:gender|genderName}")); 6 Assert.assertEquals("先生", testObj.evalExpression("${my|prop:gender|genderName}")); 7 Assert.assertEquals("", testObj.evalExpression("${my1|prop:gender|genderName}")); 8 } 9

如何实现表达式求值呢?首先我想到了javascript可以通过函数名来调用对象的方法,如果是java就要用到反射了。也就是说,我们可以把函数调用全部委托给另一个对象,我称作methodProvider,那么开始动手吧:

1//SimpleExpressionInterpreter.java 2 //增加成员变量,并通过注入一个methodProvider 3 private final Object methodProvider; 4 public SimpleExpressionInterpreter(Object methodProvider) { 5 this.methodProvider = methodProvider; 6 } 7 8 //实现回调逻辑 9 private Method findMethod(Class<?> clazz, String methodName) { 10 for (Method m : clazz.getMethods()) { 11 if (m.getName().equals(methodName)) { 12 return m; 13 } 14 } 15 return null; 16 } 17 18 private Object evalInvocations(List<Invocation> invocations) { 19 boolean firstCall = true; 20 Object result = null; 21 try { 22 for (Invocation invocation : invocations) { 23 Method m = findMethod(methodProvider.getClass(), invocation.getMethod()); 24 if (m == null) return null; 25 Object[] args; 26 if (invocation.getExtraParams() != null) { 27 args = new Object[invocation.getExtraParams().length + (firstCall ? 0 : 1)]; 28 if (!firstCall) args[0] = result; 29 System.arraycopy(invocation.getExtraParams(), 0, args, firstCall ? 0 : 1, invocation.getExtraParams().length); 30 } else { 31 args = firstCall ? new Object[0] : new Object[]{result}; 32 } 33 result = m.invoke(methodProvider, args); 34 firstCall = false; 35 } 36 } catch (IllegalAccessException | InvocationTargetException e) { 37 return null; 38 } 39 return result; 40 } 41 42 String evalExpression(String expression) { 43 List<Invocation> invocations = parseInvocations(expression); 44 Object result = evalInvocations(invocations); 45 return result == null ? "" : result.toString(); 46 } 47

为了测试,我们要实现一个DemoMethodProvider,实际应用时,MethodProvider类就决定了你想向用户提供哪些可用函数:

1//DemoMethodProvider.java 2import java.util.Collections; 3import java.util.HashMap; 4import java.util.Map; 5 6public class DemoMethodProvider { 7 private Map<String, Object> callParameters;//模拟实时传入的参数 8 private Map<String, Map<String, ?>> demoDB;//模拟数据库中的数据 9 10 public DemoMethodProvider() { 11 callParameters = new HashMap<>(); 12 Map<String, Object> my = new HashMap<>(); 13 my.put("id", 0); 14 my.put("name", "张三"); 15 my.put("gender", 1); 16 callParameters.put("my", my); 17 callParameters.put("uid", 1); 18 19 demoDB = new HashMap<>(); 20 Map<String, Object> you = new HashMap<>(); 21 you.put("id", 1); 22 you.put("name", "李四"); 23 you.put("gender", 2); 24 demoDB.put(String.format("user:%d", 1), you); 25 Map<String, Object> yourDepartment = Collections.singletonMap("name", "互联网行销部"); 26 demoDB.put(String.format("user:%d:department", 1), yourDepartment); 27 Map<String, Object> yourPosition = Collections.singletonMap("name", "产品经理T1"); 28 demoDB.put(String.format("user:%d:position", 1), yourPosition); 29 } 30 31 public Object uid() { 32 return callParameters.get("uid"); 33 } 34 35 public Object my() { 36 return callParameters.get("my"); 37 } 38 39 public Map<String, ?> userInfo(int uid) { 40 return demoDB.get(String.format("user:%d", uid)); 41 } 42 43 public Map<String, ?> department(int uid) { 44 return demoDB.get(String.format("user:%d:department", uid)); 45 } 46 47 public Map<String, ?> position(int uid) { 48 return demoDB.get(String.format("user:%d:position", uid)); 49 } 50 51 public Object prop(Map<String, ?> map, String propName) { 52 return map.get(propName); 53 } 54 55 public String genderName(int gender) { 56 switch (gender) { 57 case 1: 58 return "先生"; 59 case 2: 60 return "女士"; 61 default: 62 return ""; 63 } 64 } 65} 66

测试用例中创建SimpleExpressionInterpreter时注入DemoMethodProvider

1//SimpleExpressionInterpreterTester.java 2private SimpleExpressionInterpreter testObj = new SimpleExpressionInterpreter(new DemoMethodProvider()); 3

运行测试用例,保证通过

第五步 组装代码实现模板求值

第一步我们通过写死返回值,已经“实现”了固定模板的解析,当然这个“实现”是静态的,我们首先修改测试用例,暴露代码问题(当然更建议的是增加更多完整 的模板->结果测试用例):

1//SimpleExpressionInterpreterTester.java 2 @Test 3 public void testEval() { 4 Assert.assertEquals(value, testObj.eval(template)); 5 Assert.assertEquals(value + "...", testObj.eval(template + "...")); 6 Assert.assertEquals("", testObj.eval("")); 7 Assert.assertNull(testObj.eval(null)); 8 } 9

修改实现以保证测试通过:

1//SimpleExpressionInterpreter.java 2 public String eval(String template) { 3 if (template == null || template.length() == 0) return template; 4 String result = template; 5 List<String> expressions = findExpressions(template); 6 for (String expression : expressions) { 7 String value = evalExpression(expression); 8 result = result.replace(expression, value); 9 } 10 return result; 11 } 12

上面的自定义表达式模板解析器虽然还有改进空间,但是在大部分情况下都已经够用了,这不就是测试驱动的高效之处吗? 到此,我们可以非常自信地说,我们快速实现了一个高质量、简洁够用的自定义表达式模板解析器,可以放心的使用到业务代码中去。

后续工作

作为一款组件,上面的自定义表达式模板解析器,还有一定的改良空间:

  1. 模板求值采用replace会涉及内存分配,可以在解析表达式的同时,把模板片段也解析出来,在求值后整体进行一次拼字符串操作;
  2. 解析器的创建可以引入Builder生成器,从而语法关键字可以实现运行时的动态指定;
  3. 对转义字符的支持——上面的实现实际已经支持了表达式之外的转义,即用户内容中有${关键字,但是没有处理表达式内的转义,即表达式包含},但是基于这个表达式的初衷,大家自行决断吧!!
点赞
收藏

评论区

加载中...

相关推荐

MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1

文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s

Oracle 分组与拼接字符串同时使用

SELECTT.,ROWNUMIDFROM(SELECTT.EMPLID,T.NAME,T.BU,T.REALDEPART,T.FORMATDATE,SUM(T.S0)S0,MAX(UPDATETIME)CREATETIME,LISTAGG(TOCHAR(

皕杰报表之UUID

​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为

手写Java HashMap源码

HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程22

JavaScript模板引擎原理,几行代码的事儿

一、前言什么是模板引擎,说的简单点,就是一个字符串中有几个变量待定。比如:vartpl'Hei,mynameis<%name%,andI\\'m<%age%yearsold.';通过模板引擎函数把数据塞进去,vardata{"name":"BarretLee","age":

thinkphp3.2.3模板渲染支持三元表达式

thinkphp3.2.3模板渲染支持三元表达式{$status?'正常':'错误'}{$info'status'?$info'msg':$info'error'}注意:三元运算符中暂时不支持点语法。如下:           <divclass"modalhidefade"id'myModa

TDD两小时实现自定义表达式模板解析器 - HelloWorld