美文网首页系统设计与开发
从零开始实现一个模板引擎

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

作者: 多喝水JS | 来源:发表于2020-04-02 17:39 被阅读0次

    最近有个需求,实现一个短信模板解析功能,图方便采用了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();
            }
    

    相关文章

      网友评论

        本文标题:从零开始实现一个模板引擎

        本文链接:https://www.haomeiwen.com/subject/ykuxphtx.html