溫馨提示×

溫馨提示×

您好,登錄后才能下訂單哦!

密碼登錄×
登錄注冊×
其他方式登錄
點擊 登錄注冊 即表示同意《億速云用戶服務(wù)條款》

TDD兩小時實現(xiàn)自定義表達(dá)式模板解析器

發(fā)布時間:2020-08-31 18:14:05 來源:網(wǎng)絡(luò) 閱讀:287 作者:艾弗森哇 欄目:開發(fā)技術(shù)

為什么要重新造一個車輪?

很多情況下,用戶需要按其自定義模板動態(tài)生成郵件、PDF。開源組件中,有兩類較貼合需求的產(chǎn)品系列:

  1. 模板渲染引擎,如FreeMarker, Velocity雖然強大異常,但是過于靈活,不利于按需裁減出自己想要的少量語法;

  2. 純字符串模板引擎,要么取數(shù)據(jù)不夠動態(tài)(需要提前預(yù)知有哪些變量),或者是語法冗長(函數(shù)調(diào)用來實現(xiàn)動態(tài)擴展)不利于非IT人事編寫。

那么有沒有一款產(chǎn)品,既簡潔可控,又易于擴展呢?

其實自己實現(xiàn)一個夠用的模板解析器,也是很簡單的事情,下面分享一款我兩小時在融創(chuàng)地產(chǎn)HR項目中實現(xiàn)的模板解析器。

本實現(xiàn)沒有任何外部依賴,很容易移植到其它語言,比如用javascript實現(xiàn)甚至更簡單。

用戶場景

用戶的原始需求:
親愛的XXX先生/女士
??你好!歡迎加入XXX公司,你的部門是XXX,崗位職級XXX
??…
??人事部?HR?XXX先生/女士

模板設(shè)計:
親愛的${uid|userInfo|prop:name}${uid|userInfo|prop:gender|genderName}
??你好!歡迎加入?一天一個小目標(biāo)?公司,你的部門是${uid|department|prop:name},崗位職級${uid|position|prop:name}
??…
??人事部?HR?${my|prop:name}${my|prop:gender|genderName}

語法(靜態(tài)語法)

  • "${}":需要值替換的表達(dá)式,包含在"${"與"}"之間;

  • "|": 順序串聯(lián)單個表達(dá)式的多個函數(shù)調(diào)用,前一調(diào)用的值會作為后一調(diào)用的第一參數(shù);

  • ":": 若調(diào)用有額外參數(shù),則追加在":"之后;

  • ",": 若額外參數(shù)不止一個,參以","分隔。

函數(shù)說明(可擴展及控制部分)

  • uid: 獲取當(dāng)前調(diào)用的目標(biāo)用戶id

  • userInfo: 根據(jù)用戶id獲取用戶信息

  • prop:name: 獲取對象的"name"屬性

  • prop:gender: 獲取對象的"gender"屬性

  • genderName: 獲取性別的中文名

  • department: 根據(jù)用戶id獲取所在部門信息

  • position: 根據(jù)用戶id獲取其崗位信息

  • my: 獲取session中當(dāng)前登錄的用戶信息

Tips:?此處是為了可讀性使用了相對完整的單詞。實際為了簡潔,我們采用了單個到兩個字母表示每個函數(shù)(如:"P:name"="prop:name","GN"="genderName"),然后在前端文本編輯器下方給用戶一張函數(shù)表去定制模板,實踐證明在語法、函數(shù)不多的情況下,對非IT人士整個模板的簡潔比部分內(nèi)容的可讀性更重要

實現(xiàn)過程

代碼庫?https://gitee.com/chentao106/SimpleExpressionInterpreter?通過提交記錄完整展示了實現(xiàn)過程,整體只需要五步,即可實現(xiàn)一個面向非IT人士的自定義表達(dá)式模板解析器:

第一步 創(chuàng)建代碼框架及測試用例

先創(chuàng)建我們的解析器類,及其最重要的方法eval,即模板求值:

//SimpleExpressionInterpreter.javapublic?class?SimpleExpressionInterpreter?{??public?String?eval(String?template)?{????return?null;
??}
}

測試驅(qū)動開發(fā),當(dāng)然要先編寫測試用例:

//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"?+??????"??你好!歡迎加入不存在公司,你的部門是互聯(lián)網(wǎng)行銷部,崗位職級產(chǎn)品經(jīng)理T1…\n"?+??????"??人事部?HR?張三先生\n";??private?SimpleExpressionInterpreter?testObj?=?new?SimpleExpressionInterpreter();??@Test
??public?void?testEval()?{
????Assert.assertEquals(value,?testObj.eval(template));
??}
}

此時,測試用例當(dāng)然是執(zhí)行不通過的,我們想辦法讓測試先通過,才好進(jìn)行下一步,同時定義一下我們的語法關(guān)鍵字:

//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"?+????????"??你好!歡迎加入不存在公司,你的部門是互聯(lián)網(wǎng)行銷部,崗位職級產(chǎn)品經(jīng)理T1…\n"?+????????"??人事部?HR?張三先生\n";
??}
}

運行測試用例,保證通過

第二步 提取字符器模板中的表達(dá)式

修改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}",?"$"),?testObj.findExpressions("${a}$"));
????Assert.assertEquals(Arrays.asList("${a}",?"$"),?testObj.findExpressions("Hello?${a},?world$"));
????Assert.assertEquals(Collections.singletonList("${a\\}$"),?testObj.findExpressions("${a\\}$"));
????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;
??}

為了重用表達(dá)式前綴和后綴的查找代碼,我們提取了公共函數(shù)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}$",?testObj.expressionStart,?1));
????Assert.assertEquals(3,?testObj.nextDivider("${a}$",?testObj.expressionEnd,?1));
????Assert.assertEquals(8,?testObj.nextDivider("${a\\}$",?testObj.expressionEnd,?1));
????Assert.assertEquals(-1,?testObj.nextDivider("${a\\}${b",?testObj.expressionEnd,?1));
??}

運行測試用例,保證通過

第三步 解析表達(dá)式調(diào)用鏈

調(diào)用必須先用一個實體來表示:

//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));
??}
}

增加解析調(diào)用鏈的函數(shù)聲明:

//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]);
??}

為了支持使用"|"串連表達(dá)式,我們重寫了String的split函數(shù)(split使用正則表達(dá)式拆分,而"|"是正則表達(dá)式的關(guān)鍵字,不考慮語法可替換、可跨語言移植的情況下,可以直接轉(zhuǎn)義"\|"+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",?","));
??}

運行測試用例,保證通過

第四步 實現(xiàn)表達(dá)式求值

到了最關(guān)鍵的表達(dá)式求值步驟,照舊我們還是先定義函數(shù)

//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}"));
??}

如何實現(xiàn)表達(dá)式求值呢?首先我想到了javascript可以通過函數(shù)名來調(diào)用對象的方法,如果是java就要用到反射了。也就是說,我們可以把函數(shù)調(diào)用全部委托給另一個對象,我稱作methodProvider,那么開始動手吧:

//SimpleExpressionInterpreter.java
??//增加成員變量,并通過注入一個methodProvider
??private?final?Object?methodProvider;??public?SimpleExpressionInterpreter(Object?methodProvider)?{????this.methodProvider?=?methodProvider;
??}??//實現(xiàn)回調(diào)邏輯
??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();
??}

為了測試,我們要實現(xiàn)一個DemoMethodProvider,實際應(yīng)用時,MethodProvider類就決定了你想向用戶提供哪些可用函數(shù):

//DemoMethodProvider.javaimport?java.util.Collections;import?java.util.HashMap;import?java.util.Map;public?class?DemoMethodProvider?{??private?Map<String,?Object>?callParameters;//模擬實時傳入的參數(shù)
??private?Map<String,?Map<String,??>>?demoDB;//模擬數(shù)據(jù)庫中的數(shù)據(jù)

??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",?"互聯(lián)網(wǎng)行銷部");
????demoDB.put(String.format("user:%d:department",?1),?yourDepartment);
????Map<String,?Object>?yourPosition?=?Collections.singletonMap("name",?"產(chǎn)品經(jīng)理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?"";
????}
??}
}

測試用例中創(chuàng)建SimpleExpressionInterpreter時注入DemoMethodProvider

//SimpleExpressionInterpreterTester.javaprivate?SimpleExpressionInterpreter?testObj?=?new?SimpleExpressionInterpreter(new?DemoFunctionProvider());

運行測試用例,保證通過

第五步 組裝代碼實現(xiàn)模板求值

第一步我們通過寫死返回值,已經(jīng)“實現(xiàn)”了固定模板的解析,當(dāng)然這個“實現(xiàn)”是靜態(tài)的,我們首先修改測試用例,暴露代碼問題(當(dāng)然更建議的是增加更多完整 的模板->結(jié)果測試用例):

//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));
??}

修改實現(xiàn)以保證測試通過:

//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;
??}

上面的自定義表達(dá)式模板解析器雖然還有改進(jìn)空間,但是在大部分情況下都已經(jīng)夠用了,這不就是測試驅(qū)動的高效之處嗎? 到此,我們可以非常自信地說,我們快速實現(xiàn)了一個高質(zhì)量、簡潔夠用的自定義表達(dá)式模板解析器,可以放心的使用到業(yè)務(wù)代碼中去。

后續(xù)工作

作為一款組件,上面的自定義表達(dá)式模板解析器,還有一定的改良空間:

  1. 模板求值采用replace會涉及內(nèi)存分配,可以在解析表達(dá)式的同時,把模板片段也解析出來,在求值后整體進(jìn)行一次拼字符串操作;

  2. 解析器的創(chuàng)建可以引入Builder生成器,從而語法關(guān)鍵字可以實現(xiàn)運行時的動態(tài)指定;

  3. 對轉(zhuǎn)義字符的支持——上面的實現(xiàn)實際已經(jīng)支持了表達(dá)式之外的轉(zhuǎn)義,即用戶內(nèi)容中有${關(guān)鍵字,但是沒有處理表達(dá)式內(nèi)的轉(zhuǎn)義,即表達(dá)式包含},但是基于這個表達(dá)式的初衷,大家自行決斷吧!!


向AI問一下細(xì)節(jié)

免責(zé)聲明:本站發(fā)布的內(nèi)容(圖片、視頻和文字)以原創(chuàng)、轉(zhuǎn)載和分享為主,文章觀點不代表本網(wǎng)站立場,如果涉及侵權(quán)請聯(lián)系站長郵箱:is@yisu.com進(jìn)行舉報,并提供相關(guān)證據(jù),一經(jīng)查實,將立刻刪除涉嫌侵權(quán)內(nèi)容。

AI