500 Lines or Less 是一个开源项目,也是一本同名书。代码+文档,相得益彰。其中部分内容还有中文翻译。这个项目由多个独立的章节组成,每个章节由领域大牛试图用 500 行或者更少(500 or less)的代码,让读者了解一个功能的实现原理。

模版引擎(Template Engine) 是其中的一个子项目,介绍 python 的模版引擎实现原理。项目仅仅 250 行左右,功能却非常全面。Templatecoverage.py 的实现同源,它们是同一个作者。后者广泛用于测试框架 pytest/unittest 并生成单元测试覆盖率报告。

本文目录:

  • 项目结构
  • 模版 api
  • CodeBuilder 代码构造器
  • Templite 模版渲染器
  • 单元测试用例
  • 小结和小技巧

项目结构

500lines 项目有些年头了,我们取 master 版本即可。获取代码后进入 template-engine 目录,项目结构如下:

功能
CodeBuilder 代码辅助类,帮助生成缩进合法的 python 代码
Templite 模版引擎实现,实现模版对象及其渲染方法等
TempliteTest 模版引擎的测试类,包括各种模版语法的测试

阅读本文之前,强烈建议先阅读项目中的 md 文档或参考链接中的中文翻译,对理解项目源码非常有帮助。

模版 api

Templite 模版使用方法如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Make a Templite object.
templite = Templite('''
    <h1>Hello {{name|upper}}!</h1>
    {% for topic in topics %}
        <p>You are interested in {{topic}}.</p>
    {% endfor %}
    ''',
    {'upper': str.upper},
)

# Later, use it to render some data.
text = templite.render({
    'name': "Ned",
    'topics': ['Python', 'Geometry', 'Juggling'],
})

上述代码主要功能:

  • 创建一个 Templite 对象, 它有两个构造参数。第一个参数是模版字符串,第二个参数是变量对象。
  • 模版的语法是 django 模版引擎语法的子集 : 使用 {{}} 表示变量, {% for .. %} 实现循环, 使用 | 实现格式化变量等。
  • 使用 Templite 对象的 render 方法将模版渲染成 text 文本。render 同时还可以继续追加变量对象。

执行后输出:

1
2
3
4
5
6
7
<h1>Hello NED!</h1>

<p>You are interested in Python.</p>

<p>You are interested in Geometry.</p>

<p>You are interested in Juggling.</p>

通过示例演示,我们可以知道,模版引擎把模版中变量变成值后,格式化输出纯文本内容,实现静态的 html。利用模版引擎,可以让我们的 web 服务动态化。对网站来说格式是相对固定的,数据是千变万化的。模版可以省去很多繁琐的编码实现。

CodeBuilder 代码构造器

CodeBuilder 类顾名思义,帮助构建 python 代码。在开始分析这个类之前,我们再回顾一下 python 语法:

1
2
3
4
5
6
7
1 class Person(object):
2
3     def __init__(self):
4         pass
5
6     def hello():
7         print("hello world")

这是一个简单的 Person 对象,总共 7 行代码组成,包含构造函数 init 和 hello 方法。第 3 行到第 7 行是 Person 对象的内容段,相对于第 1 行应该缩进一下;第 4 行是 init 函数的内容段,应该相对第 3 行进行缩进。同时按照 PEP8 规范,推荐函数之间空行,每个缩进采用 4 个空格。

了解 python 的语法规则后,我们继续 CodeBuilder 的实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class CodeBuilder(object):
    """Build source code conveniently."""

    def __init__(self, indent=0):
        self.code = []
        self.indent_level = indent

    def add_line(self, line):
        """Add a line of source to the code.

        Indentation and newline will be added for you, don't provide them.

        """
        self.code.extend([" " * self.indent_level, line, "\n"])

    def add_section(self):
        """Add a section, a sub-CodeBuilder."""
        section = CodeBuilder(self.indent_level)
        self.code.append(section)
        return section

    INDENT_STEP = 4      # PEP8 says so!

    def indent(self):
        """Increase the current indent for following lines."""
        self.indent_level += self.INDENT_STEP

    def dedent(self):
        """Decrease the current indent for following lines."""
        self.indent_level -= self.INDENT_STEP
  • 代码由数组构成,数组元素可以是 line,也可以是 section(CodeBuilder)
  • 每个 line 可以是一个三元组: 缩进空格,代码和换行符构成
  • 每个 section 多个 line 可以构成,每个 section 处于相同的缩进级别

这样形成了 CodeBuilder 的树状结构,可以通过递归的方式形成代码的字符串:

1
2
def __str__(self):
    return "".join(str(c) for c in self.code)

当然仅仅形成代码的源文件还是不够,我们需要编译它形成可以执行的函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def get_globals(self):
    """Execute the code, and return a dict of globals it defines."""
    # A check that the caller really finished all the blocks they started.
    assert self.indent_level == 0
    # Get the Python source as a single string.
    python_source = str(self)
    # Execute the source, defining globals, and return them.
    global_namespace = {}
    exec(python_source, global_namespace)
    return global_namespace

使用 exec 动态的将字符串源码编译到 global_namespace 这个字典中。我们可以通过下面示例理解 exec 函数:

1
2
3
4
5
6
>>> s = '''
... def hello(name):
...     print("hello",name)
... '''
>>> d = {}
>>> exec(s,d)
  • 定义一个字符串 s,字符串里是一个 hello 函数文本
  • 定义一个字典 d
  • 将 s 编译到 d,这样 d 中就含有一个名为 hello 的函数

可以查看 hello 函数和执行 hello 函数:

1
2
3
4
>>> d['hello']
<function hello at 0x7fc3880b1af0>
>>> d['hello']("game404")
hello game404

我们通过 CodeBuilder 获得在运行期,动态创建可执行的函数的能力。

Templite 模版渲染器

Templite 类的注释,介绍了 templite 支持的 4 种模版语法: 变量,循环,逻辑分支和注释:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class Templite(object):
    """A simple template renderer, for a nano-subset of Django syntax.
    Supported constructs are extended variable access::

        {{var.modifer.modifier|filter|filter}}

    loops::

        {% for var in list %}...{% endfor %}

    and ifs::

        {% if var %}...{% endif %}

    Comments are within curly-hash markers::

        {# This will be ignored #}

这 4 个语法也是编程语言的基础指令,每门编程语言都包含这几个语法的解析。模版引擎 cool 的地方就在这里,我们用一门编程语言创造了一门新的语言。

Templite 类主要包括两个方法: init 方法和 render 方法。结合 CodeBuilder 的实现,我们可以合理猜测 init 方法主要是生成源码,render 方法是调用源码里的函数进行渲染。

我们先看 init 方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
    def __init__(self, text, *contexts):
        """Construct a Templite with the given `text`.

        `contexts` are dictionaries of values to use for future renderings.
        These are good for filters and global values.

        """
        self.context = {}
        for context in contexts:
            self.context.update(context)
  • init 方法构造一个模版对象,使用了 text 文本参数和全局的 contexts 上下文
  • contexts 推荐设置一些过滤器和全局变量

接下来构建一个 CodeBuilder 生成一个基础的 render_function 函数, 大概结构如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
 # We construct a function in source form, then compile it and hold onto
# it, and execute it to render the template.
code = CodeBuilder()
code.add_line("def render_function(context, do_dots):")
code.indent()
vars_code = code.add_section()
code.add_line("result = []")

...

for var_name in self.all_vars - self.loop_vars:
        vars_code.add_line("c_%s = context[%r]" % (var_name, var_name))
code.add_line("return ''.join(result)")
code.dedent()
  • render_function 函数包括 2 个参数 context 和 do_dots
  • render_function 返回一个由 result 数组拼接而成的字符串
  • render_function 将 context 解析成内部变量,用于格式化输出

其中的重点,就是如何把 text 文本解析成函数的行。 也就是上述代码中省略的部分。首先我们注意到模版语法中的 token 包括:

  • {{}}
  • {##}
  • {%%}

可以使用正则 r"(?s)({{.*?}}|{%.*?%}|{#.*?#})" 将文本分割成不同的段。

每个 token 都是两两匹配的,我们可以用一个栈 ops_stack 来处理(类似 json 语法的{}[])。遇到的 token 入栈,再遇到相同的 token 出栈。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
ops_stack = []

# Split the text to form a list of tokens.
tokens = re.split(r"(?s)({{.*?}}|{%.*?%}|{#.*?#})", text)

for token in tokens:
    if token.startswith('{#'):
        # Comment: ignore it and move on.
        continue
    elif token.startswith('{{'):
        # An expression to evaluate.
        expr = self._expr_code(token[2:-2].strip())
        buffered.append("to_str(%s)" % expr)
    elif token.startswith('{%'):
        ...
    else:
        # Literal content.  If it isn't empty, output it.
        if token:
            buffered.append(repr(token))

{{name|upper}} 语法会去掉首位的 token 符号后变成 expr 表达式。表达式包括四种类型:

  • | 这种 fitler 语法
  • . 这种属性取值
  • 变量取值
  • 直接输出原文

对于每个表达式,需要继续处理,查找到变量名称:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
def _expr_code(self, expr):
    """Generate a Python expression for `expr`."""
    if "|" in expr:
        pipes = expr.split("|")
        code = self._expr_code(pipes[0])
        for func in pipes[1:]:
            self._variable(func, self.all_vars)
            code = "c_%s(%s)" % (func, code)
    elif "." in expr:
        dots = expr.split(".")
        code = self._expr_code(dots[0])
        args = ", ".join(repr(d) for d in dots[1:])
        code = "do_dots(%s, %s)" % (code, args)
    else:
        self._variable(expr, self.all_vars)
        code = "c_%s" % expr
    return code

合法的变量名称,会记录下来到 vars_set 中,执行时候再用 context 种的变量值进行格式输出:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def _variable(self, name, vars_set):
    """Track that `name` is used as a variable.

    Adds the name to `vars_set`, a set of variable names.

    Raises an syntax error if `name` is not a valid name.

    """
    if not re.match(r"[_a-zA-Z][_a-zA-Z0-9]*$", name):
        self._syntax_error("Not a valid name", name)
    vars_set.add(name)
  • 可以看到变量规范是首个字符不能够是数字,否则不符合 python 语法

复杂一些的是循环和逻辑分支, 也就是{% token 构成的模版内容:

  • ifendif
  • forendfor

我们看看 if 分支的解析:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
words = token[2:-2].strip().split()
if words[0] == 'if':
    # An if statement: evaluate the expression to determine if.
    if len(words) != 2:
        self._syntax_error("Don't understand if", token)
    ops_stack.append('if')
    code.add_line("if %s:" % self._expr_code(words[1]))
    code.indent()
...
elif words[0].startswith('end'):
    # Endsomething.  Pop the ops stack.
    if len(words) != 1:
        self._syntax_error("Don't understand end", token)
    end_what = words[0][3:]
    if not ops_stack:
        self._syntax_error("Too many ends", token)
    start_what = ops_stack.pop()
    if start_what != end_what:
        self._syntax_error("Mismatched end tag", end_what)
    code.dedent()
  • 碰到 if 进行入栈
  • 添加代码行 if exp:
  • 增加缩进
  • if 中的表达式输出,临时添加到 buffered 中
  • 遇到 end 进行出栈
  • 减少缩进

for 循环会比 if 复杂一点,但是原理是一样的,就不再赘述。这样通过 init 方法,我们大概可以得到这样一个模版函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
def render_function(context, do_dots):
    c_upper = context['upper']
    c_topics = context['topics']
    c_name = context['name']
    result = []
    append_result = result.append
    extend_result = result.extend
    to_str = str
    extend_result(['\n        <h1>Hello ', to_str(c_upper(c_name)), '!</h1>\n        '])
    for c_topic in c_topics:
        extend_result(['\n            <p>You are interested in ', to_str(c_topic), '.</p>\n        '])
    append_result('\n        ')
    return ''.join(result)

接下来继续查看 render 函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
def render(self, context=None):
    """Render this template by applying it to `context`.

    `context` is a dictionary of values to use in this rendering.

    """
    # Make the complete context we'll use.
    render_context = dict(self.context)
    if context:
        render_context.update(context)
    return self._render_function(render_context, self._do_dots)
  • render 使用 context,这里的 context 和构造函数 init 不同,仅仅用于本次渲染
  • render 调用模版生成的 render_function 内部函数,并使用 do_dots 作为变量获取方法

do_dots 方法其实没有什么特别,就是从 context 中获取变量的值,如果变量是一个可以执行的函数就执行这个函数得到值:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def _do_dots(self, value, *dots):
    """Evaluate dotted expressions at runtime."""
    for dot in dots:
        try:
            value = getattr(value, dot)
        except AttributeError:
            value = value[dot]
        if callable(value):
            value = value()
    return value

单元测试用例

单元测试用例非常详尽,我们重点看看关于 for 循环部分的测试用例。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
def test_loops(self):
    # Loops work like in Django.
    nums = [1,2,3,4]
    self.try_render(
        "Look: {% for n in nums %}{{n}}, {% endfor %}done.",
        locals(),
        "Look: 1, 2, 3, 4, done."
        )
    # Loop iterables can be filtered.
    def rev(l):
        """Return the reverse of `l`."""
        l = l[:]
        l.reverse()
        return l

    self.try_render(
        "Look: {% for n in nums|rev %}{{n}}, {% endfor %}done.",
        locals(),
        "Look: 4, 3, 2, 1, done."
        )
  • 将函数局部变量循环打印输出
  • 对于集合变量还可以进行链式调用 nums|rev , rev 是临时定义的一个反转函数
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def test_nested_loops(self):
        self.try_render(
            "@"
            "{% for n in nums %}"
                "{% for a in abc %}{{a}}{{n}}{% endfor %}"
            "{% endfor %}"
            "!",
            {'nums': [0,1,2], 'abc': ['a', 'b', 'c']},
            "@a0b0c0a1b1c1a2b2c2!"
            )
  • for 循环可以进行嵌套

通过单元测试用例,我们可以快速了解 templates 的所有功能。

小结

本文我们一起学习了如何从零开始构造一个 python 模版引擎,其主要原理是先定义模版引擎语法,然后使用正则表达式将模版解析成 python 代码行,再通过 exec 编译成可执行函数, 最后传入参数进行渲染执行。

作为延升话题,python 的解释器也可以理解为一种模版引擎,我们可以使用 python 实现一个 python 的解释器,pypy 就是其中的佼佼者。当然 pypy 代码量非常大,难以上手。好在 500lines 中也有一个原理实现项目 Byterun 以后我们一起学习它,敬请期待。

小技巧

函数中可以将全局变量,重命名为局部变量,提高循环执行的效率:

1
2
3
code.add_line("append_result = result.append")
code.add_line("extend_result = result.extend")
code.add_line("to_str = str")

参考链接