前几天在伯乐在线上看到 介绍 mustache.js 的文章。Mustache 是一种模板语言,语法简单,功能强大,已经有各个语言下的实现。那么我们今天就用 python 来一步步实现它吧!
前言
What I cannot create I do not understand.
要理解一个事物最有效的方式就是动手创造一个,而真正动手创造的时候,你会发现,事情并没有相像中的困难。
首先要说说什么是编译器,它就像是一个翻译,将一种语言 X 翻译成另一种语言 Y。通常语言 X 对人类更加友好,而语言 Y 则是我们不想直接使用的。以 C 语言编译器为例,它的输出是汇编语言,汇编语言太琐碎了,通常我们不想直接用它来写程序。而相对而言,C 语言就容易理解、容易编写。
但是翻译后的语言 Y 也需要实际去执行,在 C 语言的例子中,它是直接由硬件去执行的,以此得到我们需要的结果。另一些情形下,我们需要做一台“虚拟机”来执行。例如 Java 的编译器将 Java 代码转换成 Java 字节码,硬件(CPU)本身并不认识字节码,所以 Java 提供了 Java 虚拟机来实际执行它。
模板引擎 = 编译器 + 虚拟机
本质上,模板引擎的工作就是将模板转换成一个内部的结构,可以是抽象语法树(AST),也可以是 python 代码,等等。同时还需要是一个虚拟机,能够理解这种内部结构,给出我们需要的结果。
好吧,那么模板引擎够复杂啊!不仅要写个编译器,还要写个虚拟机!放弃啦,不干啦!莫慌,容我慢慢道来~
Mustache 简介
Mustache 自称为 logic-less,与一般模板不同,它不包含 if
, for
这样的逻辑标签,而统一用 {{#prop}} 之类的标签解决。下面是一个 Mustache 模板:
Hello {{name}} |
对于如下的数据,JSON 格式的数据:
{ |
将输出如下的文本:
Hello Chris |
所以这里稍微总结一下 Mustache 的标签:
- {{ name }}: 获取数据中的 `name` 替换当前文本
-
{{# name }} ... {{/name}}: 获取数据中的 `name` 字段并依据数据的类型,执行如下操作:
- 若
name
为假,跳过当前块,即相当于if
操作 - 若
name
为真,则将name
的值加入上下文并解析块中的文本 - 若
name
是数组且个数大于 0,则逐个迭代其中的数据,相当于for
- 若
逻辑简单,易于理解。下面就让我们来实现它吧!
模板引擎的结构
如前文所述,我们实现的模板引擎需要包括一个编译器,以及一个虚拟机,我们选择抽象语法树作为中间表示。下图是一个图示:
学过编译原理的话,你可能知道编译器包括了词法分析器、语法分析器及目标代码的生成。但是我们不会单独实现它们,而是一起实现原因有两个:
- 模板引擎的语法通常要简单一些,Mustache 的语法比其它引擎比起来更是如此。
- Mustache 支持动态修改分隔符,因此词法的分析和语法的分析必需同时进行。
下面开始 Coding 吧!
辅助函数
上下文查找
首先,Mustache 有所谓上下文栈(context stack)的概念,每进入一个
{{#name}}...{{/name}} 块,就增加一层栈,下面是一个图示:这个概念和 Javscript 中的原型链是一样的。只是 Python 中并没有相关的支持,因此我们实现自己的查找函数:
def lookup(var_name, contexts=()): |
如上,每个上下文(context)可以是一个字典,也可以是数据元素(像字符串,数字等等),而上下文栈则是一个数组,contexts[0]
代表栈底,context[-1]
代表栈顶。其余的逻辑就很明直观了。
单独行判定
Mustache 中有“单独行”(standalone)的概念,即如果一个标签所在的行,除了该标签外只有空白字符,则称为单独行。判断函数如下:
spaces_not_newline = ' \t\r\b\f' |
其中,(start, end)
是当前标签的开始和结束位置。我们分别向前和向后匹配空白字符。向前是一个个字符地判断,向后则偷懒用了正则表达式。右是单独行则返回单独行的位置:(start+1, right.end())
。
语法树
我们从语法树讲起,因为这是编译器的输出,先弄清输出的结构,我们能更好地理解编译器的工作原理。
首先介绍树的节点的类型。因为语法树和 Mustache 的语法对应,所以节点的类型和 Mustache 支持的语法类型对应:
class Token(): |
这 6 种类型中除了 ROOT
,其余都对应了 Mustache 的一种类型,对应关系如下:
LITERAL
:纯文本,即最终按原样输出的部分VARIABLE
:变量字段,即 {{ name }} 类型SECTION
:对应 {{#name}} ... {{/name}}INVERTED
:对应 {{^name}} ... {{/name}}COMMENT
:注释字段 {{! name }}PARTIAL
:对应 {{> name}}
而最后的 ROOT
则代表整棵语法树的根节点。
了解了节点的类型,我们还需要知道每个节点需要保存什么样的信息,例如对于
Section
类型的节点,我们需要保存它对应的子节点,另外为了支持 lambda
类型的数据,我们还需要保存 section
段包含的文本。最终需要的字段如下:
def __init__(self, name, type=LITERAL, value=None, text='', children=None): |
name
:保存该节点的名字,例如 {{ header }} 是变量类型,name
字段保存的就是header
这个名字。type
:保存前文介绍的节点的类型value
:保存该节点的值,不同类型的节点保存的内容也不同,例如LITERAL
类型保存的是字符串本身,而VARIABLE
保存的是变量的名称,和name
雷同。text
:只对SECTION
和INVERTED
有用,即保存包含的文本children
:SECTION
、INVERTED
及ROOT
类型使用,保存子节点escape
:输出是否要转义,例如 {{name}} 是默认转义的,而{{{name}}}默认不转义delimiter
:与lambda
的支持有关。Mustache 要求,若SECTION
的变量是一个函数,则先调用该函数,返回时的文本用当前的分隔符解释,但在编译期间这些文本是不可获取的,因此需要事先存储。indent
是PARTIAL
类型使用,后面会提到。
可以看到,语法树的类型、结构和 Mustache 的语法息息相关,因此,要理解它的最好方式就是看 Mustache 的标准。 一开始写这个引擎时并不知道需要这么多的字段,在阅读标准时,随着对 Mustache 语法的理解而慢慢添加的。
虚拟机
所谓的虚拟机就是对编译器输出(我们的例子中是语法树)的解析,即给定语法树和数据,我们能正确地输出文本。首先我们为 Token 类定义一个调度函数:
class Token(): |
①:我们要求上下文栈(context stack)是一个列表(或称数组),为了方便用户,我们允许它是其它类型的。
②的逻辑很简单,就是根据当前节点的类型执行不同的函数用来渲染(render)文本。
另外每个“渲染函数”都有两个参数,即上下文栈contexts
和 partials
。
partials
是一个字典类型。它的作用是当我们在模板中遇见如 {{> part}} 的标签中,就从 partials
中查找 part
,并用得到的文本替换当前的标签。具体的使用方法可以参考 Mustache 文档
辅助渲染函数
它们是其它“子渲染函数”会用到的一些函数,首先是转义函数:
from html import escape as html_escape |
作用是如果当前节点需要转义,则调用 html_escape
进行转义,例如将文本 <b>
转义成 <b>
。
另一个函数是查找(lookup),在给定的上下文栈中查找对应的变量。
class Token(): |
这里有两点特殊的地方:
- 若变量名为
.
,则返回当前上下文栈中栈顶的变量。这是 Mustache 的特殊语法。 - 支持诸如以
.
号为分隔符的层级访问,如 {{a.b.c}} 代表首先查找变量a
,在a
的值中查找变量b
,以此类推。
字面量
即 LITERAL
类型的节点,在渲染时直接输出节点保存的字符串即可:
def _render_literal(self, contexts, partials): |
子节点
子节点的渲染其实很简单,因为语法树是树状的结构,所以只要递归调用子节点的渲染函数就可以了,代码如下:
def _render_children(self, contexts, partials): |
变量
即遇到诸如 {{name}}、{{{name}}} 或 {{&name}} 等的标签时,从上下文栈中查找相应的值即可:
def _render_variable(self, contexts, partials): |
这里的唯一不同是对 lambda
的支持,如果变量的值是一个可执行的函数,则需要先执行它,将返回的结果作为新的文本,重新渲染。这里的 render
函数后面会介绍。
例如:
contexts = [{ 'lambda': lambda : '{{value}}', 'value': 'world' }] |
Section
Section 的渲染是最为复杂的一个,因为我们需要根据查找后的数据的类型做不同的处理。
def _render_section(self, contexts, partials): |
①:当数据的类型是列表时,我们逐个迭代,将元素入栈并渲染它的子节点。
②:当数据的类型是函数时,与处理变量时不同,Mustache 要求我们将 Section 中包含的文本作为参数,调用该函数,再对该函数返回的结果作为新的模板进行渲染。且要求使用当前的分隔符。
③:正常情况下,我们需要渲染 Section 包含的子节点。注意 self.text
与
self.children
的区别,前者是文本字符串,后者是编译后的语法树节点。
Inverted
Inverted Section 起到的作用是 if not
,即只有当数据为假时才渲染它的子节点。
def _render_inverted(self, contexts, partials): |
注释
直接跳过该子节点即可:
def _render_comments(self, contexts, partials): |
Partial
Partial 的作用相当于预先存储的模板。与其它模板语言的 include
类似,但还可以递归调用。例如:
partials: {'strong': '<strong>{{name}}</strong>'} |
代码如下:
re_insert_indent = re.compile(r'(^|\n)(?=.|\n)', re.DOTALL) #① |
这里唯一值得一提的就是缩进问题②。Mustache 规定,如果一个 partial 标签是一个“单独行”,则需要将该标签的缩进添加到数据的所有行,然后再进行渲染。例如:
partials: {'content': '<li>\n {{name}}\n</li>\n'} |
因此我们用正则表达式对 partial 的数据进行处理。①中的正则表达式,(^|\n)
用于匹配文本的开始,或换行符之后。而由于我们不匹配最后一个换行符,所以我们用了
(?=.|\n)
。它要求,以任意字符结尾,而由于 .
并不匹配换行符 \n
,因此用了或操作(|
)。
虚拟机小结
综上,我们就完成了执行语法树的虚拟机。是不是还挺简单的。的确,一旦决定好了数据结构,其它的实现似乎也只是按部就班。
最后额外指出一个问题,那就是编译器与解释器的问题。传统上,解释器是指一句一句读取源代码并执行;而编译器是读取全部源码并编译,生成目标代码后一次性去执行。
在我们的模板引擎中,语法树是属于编译得到的结果,因为模板是固定的,因此能得到一个固定的语法树,语法树可以重复执行,这也有利于提高效率。但由于 Mustache 支持
partial 及 lambda,这些机制使得用户能动态地为模板添加新的内容,所以固定的语法树是不够的,因此我们在渲染时用到了全局 render
函数。它的作用就相当于解释器,让我们能动态地渲染模板(本质上依旧是编译成语法树再执行)。
有了这个虚拟机(带执行功能的语法树),我们就能正常渲染模板了,那么接下来就是如何把模板编译成语法树了。
词法分析
Mustache 的词法较为简单,并且要求能动态改变分隔符,所以我们用正则表达式来一个个匹配。
Mustache 标签由左右分隔符包围,默认的左右分隔符分别是 { {
(忽略中间的空格) 和 }}
:
DEFAULT_DELIMITERS = ('{{', '}}') |
而标签的模式是:左分隔符 + 类型字符 + 标签名 + (可选字符)+ 右分隔符,例如:
{{# name}} 和 {{{name}}}。其中 `#` 就代表类型,{{{name}}} 中的`}` 就是可选的字符。re_tag = re.compile(open_tag + r'([#^>&{/!=]?)\s*(.*?)\s*([}=]?)' + close_tag, re.DOTALL) |
例如:
In [6]: re_tag = re.compile(r'{{([#^>&{/!=]?)\s*(.*?)\s*([}=]?)}}', re.DOTALL) |
这样通过这个正则表达式就能得到我们需要的类型和标签名信息了。
只是,由于 Mustache 支持修改分隔符,而正则表达式的 compile 过程也是挺花时间的,因此我们要做一些缓存的操作来提高效率。
re_delimiters = {} |
①:这是比较神奇的一步,主要是有一些字符的组合在正则表达式里是有特殊含义的,为了避免它们影响了正则表达式,我们将除了字母和数字的字符进行转义,如 '[' => '\['
。
语法分析
现在的任务是把模板转换成语法树,首先来看看整个转换的框架:
def compiled(template, delimiters=DEFAULT_DELIMITERS): |
可以看到,整个步骤是由一个 while 循环构成,循环不断寻找下一个 Mustache 标签。这意味着我的解析是线性的,但我们的目标是生成树状结构,这怎么办呢?答案是①中,我们维护了两个栈,一个是 sections
,另一个是 tokens_stack
。至于怎么使用,下文会提到。
②:由于每次 while 循环时,我们跳过了中间那些不是标签的字面最,所以我们要将它们进行添加。这里将该节点保存在 last_literal
中是为了处理“单独行的情形”,详情见下文。
③:正常情况下,在循环末我们会将生成的节点(token)添加到 tokens
中,而有些情况下我们希望跳过这个逻辑,此时将 token 设置成 None
。
④:strip_space
代表该标签需要考虑“单独行”的情形,此时做出相应的处理,一方面将上一个字面量节点的末尾空格消除,另一方面将 index 后移至换行符。
分隔符的修改
唯一要注意的是 Mustache 规定分隔符的修改是需要考虑“单独行”的情形的。
if prefix == '=' and suffix == '=': |
变量
在解析变量时要考虑该变量是否需要转义,并做对应的设置。另外,末尾的可选字符
(suffix) 只能是 }
或 =
,分别都判断过了,所以此外的情形都是语法错误。
elif prefix == '{' and suffix == '}': |
注释
注释是需要考虑“单独行”的。
elif prefix == '!': |
Partial
一如既往,需要考虑“单独行”,不同的是还需要保存单独行的缩进。
elif prefix == '>': |
Section & Inverted
这是唯一需要使用到栈的两个标签,原理是选通过入栈记录这是 Section 或 Inverted 的开始标签,遇到结束标签时再出栈即可。
由于事先将 tokens 保存起来,因此遇到结束标签时,tokens 中保存的就是当前标签的所有子节点。
elif prefix == '#' or prefix == '^': |
结束标签
当遇到结束标签时,我们需要进行对应的出栈操作。无它。
elif prefix == '/': |
语法分析小结
同样,语法分析的内容也是按部就班,也许最难的地方就在于构思这个 while 循环。所以要传下教:思考问题的时候要先把握整体的内容,即要自上而下地思考,实际编码的时候可以从两边同时进行。
最后
最后我们再实现 render
函数,用来实际执行模板的渲染。
class SyntaxError(Exception): |
这是一个使用我们模板引擎的例子:
>>> render('Hellow {{name}}!', {'name': 'World'}) |
总结
综上,我们完成了一个完整的 mustache 模板引擎,完整的代码可以在 Github: pymustache 上下载。
实际测试了一下,我们的实现比 pystache 还更快,代码也更简单,去掉注释估计也就 300 行左右。
无论如何吧,我就想打个鸡血:如果真正去做了,有些事情并没有看起来那么难。如果本文能对你有所启发,那就是对我最大的鼓励。