不少狀況下,用戶須要按其自定義模板動態生成郵件、PDF。開源組件中,有兩類較貼合需求的產品系列:javascript
模板渲染引擎,如FreeMarker, Velocity雖然強大異常,可是過於靈活,不利於按需裁減出本身想要的少許語法;前端
純字符串模板引擎,要麼取數據不夠動態(須要提早預知有哪些變量),或者是語法冗長(函數調用來實現動態擴展)不利於非IT人事編寫。java
那麼有沒有一款產品,既簡潔可控,又易於擴展呢?git
其實本身實現一個夠用的模板解析器,也是很簡單的事情,下面分享一款我兩小時在融創地產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}
語法(靜態語法)express
"${}":須要值替換的表達式,包含在"${"與"}"之間;session
"|": 順序串聯單個表達式的多個函數調用,前一調用的值會做爲後一調用的第一參數;框架
":": 若調用有額外參數,則追加在":"以後;編輯器
",": 若額外參數不止一個,參以","分隔。
函數說明(可擴展及控制部分)
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 List<String> findExpressions(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; } List<String> findExpressions(String template) { List<String> 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 List<Invocation> parseInvocations(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 List<Invocation> parseInvocations(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<Invocation> 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<String> 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(List<Invocation> invocations) { 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<Invocation> 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 Map<String, Object> callParameters;//模擬實時傳入的參數 private Map<String, Map<String, ?>> demoDB;//模擬數據庫中的數據 public DemoMethodProvider() { callParameters = new HashMap<>(); Map<String, Object> 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<String, Object> you = new HashMap<>(); you.put("id", 1); you.put("name", "李四"); you.put("gender", 2); demoDB.put(String.format("user:%d", 1), you); Map<String, Object> yourDepartment = Collections.singletonMap("name", "互聯網行銷部"); demoDB.put(String.format("user:%d:department", 1), yourDepartment); Map<String, Object> 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<String, ?> userInfo(int uid) { return demoDB.get(String.format("user:%d", uid)); } public Map<String, ?> department(int uid) { return demoDB.get(String.format("user:%d:department", uid)); } public Map<String, ?> position(int uid) { return demoDB.get(String.format("user:%d:position", uid)); } public Object prop(Map<String, ?> 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; List<String> expressions = findExpressions(template); for (String expression : expressions) { String value = evalExpression(expression); result = result.replace(expression, value); } return result; }
上面的自定義表達式模板解析器雖然還有改進空間,可是在大部分狀況下都已經夠用了,這不就是測試驅動的高效之處嗎? 到此,咱們能夠很是自信地說,咱們快速實現了一個高質量、簡潔夠用的自定義表達式模板解析器,能夠放心的使用到業務代碼中去。
做爲一款組件,上面的自定義表達式模板解析器,還有必定的改良空間:
模板求值採用replace會涉及內存分配,能夠在解析表達式的同時,把模板片斷也解析出來,在求值後總體進行一次拼字符串操做;
解析器的建立能夠引入Builder生成器,從而語法關鍵字能夠實現運行時的動態指定;
對轉義字符的支持——上面的實現實際已經支持了表達式以外的轉義,即用戶內容中有${關鍵字,可是沒有處理表達式內的轉義,即表達式包含},可是基於這個表達式的初衷,你們自行決斷吧!!