从零开始实现一个模板引擎

最近有个需求,实现一个短信模板解析功能,图方便采用了String.format方式实现。但上线后随着用户越来越多,需求也越来越多样化,比如有的用户想自己线上编辑模板。这样String.format就不适合了,如果用户想修改模板就得重新修改后台代码,每次修改代码都需要发布上线,特别麻烦。
后来想过使用专业的模板解析引擎处理,比如Freemarker。但是这些专业模板引擎都有自己的一套语法,用户如果要编辑自己的模板就得学语法,用户肯定不干。所以就有这个模板解析器

模板解析器语法尽量跟另外一个系统所用的流程语法差不多,这样用户用起来没有学习成本。

1、模板解析器的组成

模板解析器一般由模板和数据组成。即在模板中,专注于如何展现数据, 而在模板之外可以专注于要展示什么数据。

根据上面的定义,可以确定模板解析器由模板和数据组成,下面将按照这两部分进行开发。用户需要提供需要的数据和模板,模板解析器解析后输出用户想要的信息

2、语法

使用#{...} 包裹动态内容,例如:当前登录用户:#{userId}。

3、演示效果

先来模拟下效果

    public static void main(String[] args) {
        StringBuilder sb = new StringBuilder();
        sb.append("欢迎访问:#{systemName}。当前用户是:");
        sb.append("#{userId},在线时长:#{time}");
        sb.append("===完成解析");
        
        String content = sb.toString();
        Map<String, Object> map = Maps.newHashMap();
        map.put("userId", "admin");
        map.put("systemName", "京东网后台系统");
        map.put("time", "30分钟");
        BeanCtx.setCtxData(Constant.PARENT_KEY, map);
        BaseTokenHandler handler = new BaseTokenHandler();
        System.out.println(handler.parser(content));

    }

//开始输出
欢迎访问:京东网后台系统。当前用户是:admin,在线时长:30分钟===完成解析

从上面的代码可以看出:当用户把模板和数据准备好,保存到map中,执行handler.parser(content),模板解析引擎后台自动解析,然后输出用户想要的结果。

4、实现

演示结果出来了,现在就按这个目标开始动手吧

(1)设计思路:
一门模板引擎其实是一个完整的语言,只不过它只具有单纯的输入/输出,不需要考虑其他的功能。
1、语法解析,转换为AST(抽象语法树)
2、语义分析,为AST附加上执行语义
3、上下文环境的注入
4、内置函数及外部函数支持
5、其他外围机制(与框架/工具的集成等)

  • 语法语义分析
    目前比较知名的模板引擎底层都是用JavaCC实现语法和语义分析,今天实现的模板解析器是否需要JavaCC实现?
    考虑到使用JavaCC实现比较复杂,所以借鉴了Mybatis源码中的的mapper文本解析方法,做一些改动,基本上满足要求了
  • 上下文环境
    使用k-v键值对注入
  • 内置函数
    提供if else条件语句

(2)实现思路
由两部分组成:
1、实现一个核心解析器,专门用于解析模板
2、Token Handler,用于把’核心解析器‘解析出来的token替换成用户在上下文注入的值。其中token既可以是文本,也可以foreach、if else函数。另Token Handler是个接口,可以通过它实现foreach循环的ForTokenHandler、if else 函数的ConditionTokenHandler等

(1)核心解析器

解析器分为两部分:
1、分析模板中的语法然后生成执行链表结构。这一步的目的是解析模板中语法是否正确以及模板中使用了哪些语法,并把这些语法按照出现的偏移量顺序生成一个链式结构。
2、第一点提到语法分析会生成一个链式结构,这一步则按照链表顺序执行这些语法,最后输出。

核心源码:

  • 1、语法分析
public String parse(final String content) {
        if (content == null) {
            return "";
        }

        List<OrderParser> parsers = Lists.newArrayList();
        TreeMap<Integer, Integer> endOffsets = Maps.newTreeMap();
        tokenMap.forEach((k, v) -> {
            GenericTokenParser parser = new GenericTokenParser(v.getOpen(), v.getClose(), v.getToken());
            //解析模板,然后把解析到的语法按照偏移量插入到链表中
            parser.parse(content, 0, (start, end) -> {
                if (overrideInterval(endOffsets, start, end)) {
                    endOffsets.put(start, end);
                    parsers.add(new OrderParser(new GenericTokenParser(v.getOpen(), v.getClose(), v.getToken()), start));
                }
            });
        });

        Map<Integer, OrderParser> tmpParsers = parsers.stream()
                .collect(Collectors.toMap(OrderParser::getWeight, o -> o));
        List<OrderParser> parserList = Lists.newArrayList();
        endOffsets.forEach((k, v) -> {
            if (tmpParsers.containsKey(k)) {
                parserList.add(tmpParsers.get(k));
            }
        });
        if (parserList.size() == 0) {
            return content;
        }
        parserList.sort(Comparator.comparing(OrderParser::getWeight));
        GenericTokenParser parser = parserList.remove(0).getParser();
        return parser.process(content, false, true, false, 0, parserList);
    }

例如:
有以下的模板

#{title}, 
<choose>
<if doc.get(\"sex\")=='男'/>
  <input value ='#{age}'/>
</if>
</choose>

语法解析完后的结果是:[{2,TextTokenHandler},{11,ChooseTokenHandler}].
2、11:即是该语法出现在文本中的位置

  • 2、解析完后,开始执行

参考Mybatis,改动了一些代码,代码有删减

    public String process(String text, boolean openBetweenClose, boolean retainText, boolean endClose, int offset,
            List<OrderParser> parsers) {
         ......//核心源码如下
        gotothis: while (start > -1) {
            if (start > 0 && src[start - 1] == '\\') {
                // this open token is escaped. remove the backslash and continue.
                builder.append(src, offset, start - offset - 1).append(openToken);
                offset = start + openToken.length();
            } else {
                // found open token. let's search close token.
                if (expression == null) {
                    expression = new StringBuilder();
                } else {
                    expression.setLength(0);
                }               
                if (parsers != null && parsers.size() > 0) {
                    OrderParser orderParser = parsers.get(0);
                    int tmpOffset = orderParser.getWeight();
                    if (tmpOffset < start) {
                        String tmpOpen = orderParser.getParser().getOpenToken();
                        parsers.remove(0);
                        if (!openToken.equals(tmpOpen)) {
                            openToken = tmpOpen;
                            closeToken = orderParser.getParser().getCloseToken();
                            handler = orderParser.getParser().getHandler();
                            start = tmpOffset - openToken.length();
                            continue gotothis;
                        }
                    }
                }
                builder.append(src, offset, start - offset);
                offset = start + openToken.length();
                int end;
                if (endClose) {
                    end = text.lastIndexOf(closeToken);
                } else {
                    end = text.indexOf(closeToken, offset);
                }
                while (end > -1) {
                    if (end > offset && src[end - 1] == '\\') {
                        // this close token is escaped. remove the backslash and continue.
                        expression.append(src, offset, end - offset - 1).append(closeToken);
                        offset = end + closeToken.length();
                        end = text.indexOf(closeToken, offset);
                    } else {
                        expression.append(src, offset, end - offset);
                        offset = end + closeToken.length();
                        break;
                    }
                }
                if (end == -1) {
                    // close token was not found.
                    builder.append(src, start, src.length - start);
                    offset = src.length;
                } else {
                    String expre = expression.toString();
                    offset = end + closeToken.length();
                    handler.setOffset(offset);
                    String val = handler.handleToken(expre);
                    if (openBetweenClose) {
                        return val;
                    }
                    builder.append(val);
                }
            }
            start = text.indexOf(openToken, offset);
            if (start == -1) {
                if (parsers != null && parsers.size() > 0) {
                    OrderParser parser = parsers.remove(0);
                    String tmpOpen = parser.getParser().getOpenToken();
                    while (true) {
                        if (openToken.equals(tmpOpen)) {
                            if (parsers.size() > 0) {
                                parser = parsers.remove(0);
                                tmpOpen = parser.getParser().getOpenToken();
                            } else {
                                continue gotothis;
                            }
                        } else {
                            break;
                        }
                    }
                    int tmpOffset = parser.getWeight();
                    closeToken = parser.getParser().getCloseToken();
                    openToken = tmpOpen;
                    handler = parser.getParser().getHandler();
                    start = tmpOffset - openToken.length();
                    continue gotothis;
                }
            }
        }
        if (offset < src.length) {
            builder.append(src, offset, src.length - offset);
        }
        return builder.toString();
    }

总体流程是:
查找token,然后交给Token Handler替换成用户注入的值

其中openToken是’#{‘ closeToken是‘}’

(2)Token Handler

用于把’核心解析器‘解析出来的token替换成用户在上下文注入的值

接口如下

public interface TokenHandler {
    /**
     * 设置偏移量,文本从该偏移量开始解析
     * 
     * @param content
     *            待解析的文本
     * @return
     */
    default void setOffset(int offset) {
    }
    /**
     * 解析文本,并把解析到的token替换成指定的值
     * 
     * @param content
     *            待解析的文本
     * @return
     */
    String handleToken(String content);
}

接口提供了两个方法,一个是解析文本方法,另一个是模板解析器根据提供的偏移量从该偏移量开始解析

(3)实现

接口定义好,那么开始实现一个专门用于解析出来的token替换成用户在上下文注入的值

public class TextTokenHandler extends BaseTokenHandler {
    
    @Override
    public String handleToken(String content) {
        if (content == null) {
            return "";
        }
        Map<String, Object> map = (Map) BeanCtx.getCtxData(Constant.PARENT_KEY);
        if (map == null) {
            return content;
        }
        if (!map.containsKey(content)) {
            return super.handleToken(content);
        }
        return (String) map.get(content);

    }

实现很简单,把解析出来的token替换成用户在上下文注入的值,用户注入的值通过k-v键值对方式提供

(4)用户注入的上下文(数据提供承载体)

用户注入的值通过k-v键值对方式提供。底层是ThreadLocal+HashMap
为啥要使用ThreadLocal,一个是为了方便,不需要专门通过Token Handler带过去,二是防止其他线程里的有冲突的key
用法很简单:

try{
BeanCtx.setCtxData(Constant.PARENT_KEY, map);
}finally{
  BeanCtx.clear();
}

为了防止内存泄漏,用完记得清理下内存

(5)最后来个全景演示

目前已经支持文本、if else、foreach函数等功能。麻雀虽小,五脏俱全,解析简单的模板绰绰有余的。像下面的多个token handler组合在一起的文本,一般4、5毫秒即可出结果,所以性能也不是差到极点。

    public static void main(String[] args) {
        StringBuilder sb = new StringBuilder();
        sb.append("#{title}");
        sb.append("<choose>");
        sb.append("<if doc.get(\"age\")>27 && doc.get(\"userId\")=='admin'/>#{&common&}:#{nextNode}</if>");
        sb.append("<elseif doc.get(\"age\")<25/>下放到工厂端盘子</elseif>");
        sb.append("<elseif doc.get(\"userId\")=='admin'/>");
        sb.append("<for item='node' list='nodeNames' open='(' split=',' close=')'/>{&node.userIdAndNames&}</for>");
        sb.append("</elseif><else>条件都不满足。</else>");
        sb.append("</choose>");
        String content = sb.toString();
        Map<String, Object> map = Maps.newHashMap();
        Map<String, List<String>> userMap = Maps.newHashMap();
        Map<String, Object> userInnerMap = Maps.newHashMap();
        map.put("common", "#{&otherRef&}");
        map.put("otherRef", "#{Subject}");
        map.put("subject", "如果符合要求,将进入");
        map.put("userId", "admin");
        map.put("age", "26");
        map.put("title", "这是个条件判断Token Handler,假如给你选总经理,你选择谁当:");
        map.put("nextNode", "部门经理面试");
        map.put(Constant.NODE_NAMES, Arrays.asList("产品经理"));
        BeanCtx.setCtxData(Constant.PARENT_KEY, map);
        userMap.put(Constant.USERID_AND_NAMES, Arrays.asList("\n1:川建国", "\n2:普京"));
        userInnerMap.put("产品经理", userMap);
        BeanCtx.setCtxData(Constant.CHILD_KEY, userInnerMap);
        BaseTokenHandler handler = new BaseTokenHandler();
        System.out.println(handler.parser(content));
    }

输出:


image.png

(6)总结

  • 通过简单实现的模板解析器,基本上满足用户的需求。
  • 通过实现TokenHandler接口,可以开发if else条件、foreach函数等功能
  • 目前不支持循环嵌套功能,本解析器目的是解析简单的模板,毕竟有专业的模板引擎做这种事情,所以目前不考虑加了
  • 后面工作是优化该模板解析器性能

(7)最近更新

  • 支持从文件,数据库、远程等多种途径加载模板
  • 用户注入的上下文支持对象注入
        Map<String, Object> map = Maps.newHashMap();
        map.put("common", "#{&otherRef&#}");
        map.put("otherRef", "#{Subject#}");
        map.put("Subject", "测试文件模板解析");
        User user = new User();
        user.setName("admin");
       //支持对象注入
        map.put("user", user);
        BeanCtx.setCtxData(Constant.PARENT_KEY, map);
        try {
            Configuration configuration = new Configuration();
              //配置从E盘加载模板文件
            configuration.setDirectoryForTemplateLoading(new File("E://"));
            TemplateProcessor processor = configuration.getProcessor("1.csv");
            //解析的文本写到file.html文件中
            Writer out = new BufferedWriter(
                    new OutputStreamWriter(new FileOutputStream(new File("E://file.html")), "UTF-8"));
            processor.process(out);
        } catch (IOException e) {
            e.printStackTrace();
        }
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,271评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,275评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,151评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,550评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,553评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,559评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,924评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,580评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,826评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,578评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,661评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,363评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,940评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,926评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,872评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,391评论 2 342

推荐阅读更多精彩内容