文档章节

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

c
 chentao106
发布于 11/19 12:46
字数 2966
阅读 2056
收藏 42

【推荐】2019 Java 开发者跳槽指南.pdf(吐血整理) >>>

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

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

  1. 模板渲染引擎,如FreeMarker, Velocity虽然强大异常,但是过于灵活,不利于按需裁减出自己想要的少量语法;
  2. 纯字符串模板引擎,要么取数据不够动态(需要提前预知有哪些变量),或者是语法冗长(函数调用来实现动态扩展)不利于非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.java
public class SimpleExpressionInterpreter {
  public String eval(String template) {
    return null;
  }
}

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

//SimpleExpressionInterpreterTester.java
import 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.java
public 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.java
import 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.java
import 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.java
private SimpleExpressionInterpreter testObj = new SimpleExpressionInterpreter(new DemoMethodProvider());

运行测试用例,保证通过

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

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

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

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

后续工作

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

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

 

© 著作权归作者所有

c
粉丝 3
博文 1
码字总数 2966
作品 0
深圳
私信 提问
加载中

评论(10)

爆炸的榴莲
爆炸的榴莲
TDD不是这样做的啊,第一个T Task Driven Development, 你都没做,你这就是Test First
c
chentao106 博主
你说的TDD是Task Driven Development吧?我这里单指Test-Driven Development。Task Driven还真是第一次接触,望指教
剑神卓不凡
剑神卓不凡
jfinal enjoy模板引擎生不好用吗
c
chentao106 博主
定位不一样,jfinal enjoy是面向开发人人员的,本引擎是针对非IT人士的
0--_--0
0--_--0
能实现${data.resp.key}这种取值吗
c
chentao106 博主
改造下evalInvocation的实现是可以的
小翔
小翔
用正则表达示,捕获组应该能简化很多代码
c
chentao106 博主
是的,不过静态关键字的可替代要打折扣,是值一个得思考的权衡
18898359289
18898359289
好文,赞一个
说说 Spring 表达式语言(SpEL)的核心类与用法

Spring 表达式语言 Spring Expression Language(简称 SpEL )是一个支持运行时查询和操作对象图的表达式语言 。 语法类似于 EL 表达式 ,但提供了显式方法调用和基本字符串模板函数等额外特...

deniro
2018/09/08
0
0
模板引擎--CommonTemplate

一、什么是CommonTemplate? CommonTemplate是一个开源的模板引擎,用于编译运行CTL模板语言,并且模板可以在Java,.Net,JS等中通用; 其主要目标是作为JSP,ASP.Net等页面技术的另一种选择方...

匿名
2008/09/07
6.4K
0
如何实现一个基于 DOM 的模板引擎

题图:Vincent Guth 注:本文所有代码均可在本人的个人项目colon中找到,本文也同步到了知乎专栏 可能你已经体会到了 所带来的便捷了,相信有一部分原因也是因为其基于 DOM 的语法简洁的模板...

大灰狼的小绵羊哥哥
03/30
0
0
掌握了AST,再也不怕被问babel,vue编译,Prettier等原理

概要 本文将通过以下几个方面对AST进行学习 为什么要了解AST,简要说明AST在开发中的重要性 什么是AST,对AST有一个直观的认识 AST是如何生成的,分析将代码解析成AST的原理 AST的具体应用,...

于是乎_
12/12
0
0
Vue 源码剖析 —— 模板编译原理

Vue 源码剖析 —— 模板编译原理 什么是模板编译? 日常工作中可能大家或多或少的在 JS 中使用 HTML 渲染模板,特别是在 jQuery 时代,我们可以在模板中方便的使用 JS 表达式甚至是一些指令。...

imyjay
09/14
0
0

没有更多内容

加载失败,请刷新页面

加载更多

CrashReport

CrashReport.initCrashReport(getApplicationContext(), buglyID, false); https://bugly.qq.com/v2/ https://blog.csdn.net/Crystal_xing/article/details/86249373......

shzwork
10分钟前
2
0
OSChina 周日乱弹 —— 吃这个吮指原味小松鼠

Osc乱弹歌单(2019)请戳(这里) 【今日歌曲】 @这次装个文艺青年吧 :#今日歌曲推荐# 分享Sam Jonsson/Mattafix的单曲《Big City Life (Sam Jonsson Remix)》: 《Big City Life (Sam Jons...

小小编辑
今天
8
0
使用Docker部署第一个Springboot项目

创建springboot项目后pom文件添加 <packaging>jar</packaging> 双击package打包。 双击package即可,最后只要等待控制台输出SUCCESS即可。 我们会在项目中的target文件夹中到自己打包的jar。...

Ryub
今天
7
0
Spring Boot 中使用@DateTimeFormat和@JsonFormat注解

import com.fasterxml.jackson.annotation.JsonFormat;import lombok.Data;import lombok.experimental.Accessors;import org.springframework.format.annotation.DateTimeFormat;......

不再熬夜
昨天
6
0
Qt编写图片及视频TCP/UDP网络传输

一、前言 很多年前就做过类似的项目,无非就是将本地的图片上传到服务器,就这么简单,其实用http的post上传比较简单容易,无需自定义协议,直接设置好二进制数据即可,而采用TCP或者UDP通信...

飞扬青云
昨天
5
0

没有更多内容

加载失败,请刷新页面

加载更多

返回顶部
顶部