Windless
订阅/Feed
稗田千秋(i@wind.moe)

模板引擎实现 (A Template Engine)

稗田千秋
Apr.16 2017 code

译自 500 Lines or Less -- A Template Engine 当前进度 98%

引言

编程语言对逻辑友好,但是有些情况下,我们需要处理大量的文本数据和少量逻辑,所以需要一个更适合的工具,模板引擎就是一个这样的工具。我们将会在下文构建一个简单的模板引擎。

Web 应用是重文本任务的常见示例。 Web 应用中最重要的一个部分就是生成 HTML 供浏览器解析。只有很少的纯静态 HTML 页面,涉及一点动态数据,如用户名等。通常,一个页面会包含大量动态数据:产品列表,朋友的新动态等。

同时,每个HTML页面都包含大量静态文本,并且页面都很庞大,含有成千上万字节的文本。因此,Web应用开发者面临着一个问题:如何优雅地生成一个包含静态和动态数据的混合文本?除此之外,静态的文本实际上是由团队中的其他成员如前端工程师所编写的 HTML ,他们也希望能够用相近的方式来处理这些混合文本。

为了简单说明,假设我们需要生成如下 HTML

<p>Welcome, Charlie!</p>
<p>Products:</p>
<ul>
    <li>Apple: $1.00</li>
    <li>Fig: $1.50</li>
    <li>Pomegranate: $3.25</li>
</ul>

此处的用户名是动态的,产品的名称和价格亦是。甚至产品的数量也不是固定的,因为库存量是波动的。

生成这条 HTML 数据的一种方法是在代码中加入字符串常量,再把它们和静态数据结合来生成页面,动态数据将以某种字符串替换的形式插入。部分动态数据是重复的,比如其中的产品列表,这意味这将有一堆重复的 HTML 块,因此我们需要将它们另行处理后再与页面的其他部分相结合。

以这种方式生成的页面应该像这样

# 整个页面的主要内容
PAGE_HTML = """
<p>Welcome, {name}!</p>
<p>Products:</p>
<ul>
{products}
</ul>
"""

# 每个产品的 HTML
PRODUCT_HTML = "<li>{prodname}: {price}</li>\n"

def make_page(username, products):
    product_html = ""
    for prodname, price in products:
        product_html += PRODUCT_HTML.format(
            prodname=prodname, price=format_price(price))
    html = PAGE_HTML.format(name=username, products=product_html)
    return html

代码能运行,但是它看起来很乱。HTML 代码于多个字符串常量嵌在我们的代码中。这导致页面的逻辑很难理清,因为静态内容被分开成不同的部分。而数据是如何被格式化处理的细节丢失在 Python 代码中。为了修改 HTML 页面,前端工程师还需要编辑 Python 代码来进行更改。假使页面的复杂度提高数十倍,代码看起来将会是什么样子?反正很快就会变得难以维护。

模板

更好生成 HTML 页面的方式是使用模板。用模板创建一个 HTML 页面,使得这个文件大部分是静态的 HTML 文本,而动态数据使用特殊符号来进行嵌入,上面的页面使用模板的话看起来就象这样

<p>Welcome, {{user_name}}!</p>
<p>Products:</p>
<ul>
{% for product in product_list %}
    <li>{{ product.name }}:
        {{ product.price|format_price }}</li>
{% endfor %}
</ul>

这时候重点就放在嵌入些许逻辑数据的 HTML 文本上了,将这个以文本为主的方法与以逻辑为主的代码比较,之前的程序主要是 Python 代码,HTML 文本嵌入在 Python 的逻辑中,而现在的程序主要是静态的 HTML 文本。

模板静态为主的风格与大多数编程语言的工作方式相反。举个栗子,Python 中大多数源文件都是可执行代码,如果需要生成静态文本,需要将其嵌入字符串中

def hello():
    print("Hello, world!")

hello()

当 Python 读取这个源文件时,它会将 def hello(): 解释为需要执行的指令。print("Hello, world!") 中的双引号表示其中的字符是字面上的意思。这就是大多数编程语言工作的原理:多数动态和一些嵌入其中的静态内容。静态内容由引号来标记。

模板语言与之相反:模板文件主要有静态文本组成,夹杂着一些用特殊符号表示的可执行动态部分。

<p>Welcome, {{user_name}}!</p>

这里的文本意味着以字面形式输出在生成的 HTML 页面中,直到遇到 '{{' 表示切换到动态模式,其中的 user_name 变量将会在输出中被替换。

字符串格式化函数像 Python 中的 "foo = {foo}!".format(foo=17),是一种利用字符串和插入的数据创建文本的小型语言示例(待润色)。模板拓展了这个想法,以包括条件和循环等来构造,差异仅仅在于其拓展的程度。

这些文件之所以被称作模板,就是因为它们被用来生成具有微小差别但结构相似的页面。

为了在程序中使用 HTML 模板,我们需要一个模板引擎:一个接收 描述页面结构和静态内容的静态模板 和 嵌入模板中动态数据的动态上下文 作为参数的函数。这个模板引擎结合了模板和上下文来生成完整的 HTML 字符串。模板引擎的任务就是用实际数据替换动态片段来解释模板。

另外,模板引擎中的 HTML 与一般没有什么特别的,可以用于生成任何文本结果。例如,它们也可以用来生成纯文本电子邮件。但它们通常用于 HTML,偶尔也会有涉及到 HTML 的一些特性,像是字符转义,这样可以将数据插入 HTML 中而不用担心有特殊字符在其中。

语法支持

不同的模板引擎所支持的语法不同。这里使用的模板语法是基于一个流行的 Web 框架 Django 的。由于我们用 Python 来实现模板引擎,因此语法中会出现一些 Python 的概念。我们已经在上面的例子中见到了一部分语法,接下来我们将要快速总结一下所有语法。

使用双花括号来插入上下文中的数据:

<p>Welcome, {{user_name}}!</p>

模板被渲染时,其可用的数据在呈现模板的上下文中提供,具体稍后再说。

模板引擎通常具有简化和宽松的语法提供对元素的访问。在 Python 中,这些表达式都有着不同的效果:

dict["key"]
obj.attr
obj.method()

在我们的模板语法中,这些操作都用点来取代:

dict.key
obj.attr
obj.method

点将访问对象的属性或者字典值,如果生成的值可调用,则会自动的调用它。这与 Python 代码不同,Python 中需要为这些操作使用不同的语法,因而这也使得模板的语法更加简单:

<p>The price is: {{ product.price }}, with a {{ product.discount }}% discount.</p>

还可以使用一个被称为过滤器的函数来修改值,使用管道字符 "|" 来调用过滤器:

<p>Short name: {{ story.subject | slugify | lower }}</p>

想要构建一个有趣的页面通常需要一点条件判断,因此可以使用条件语句:

{% if user.is_logged_in %}
    <p>Welcome, {{ user.name }}!</p>
{% endif %}

循环让页面中可以包含数据集合:

<p>Products:</p>
<ul>
{% for product in product_list %}
    <li>{{ product.name }}: {{ product.price|format_price }}</li>
{% endfor %}
</ul>

与其他编程语言一样,条件语句和循环语句可以相互嵌套以构建复杂的页面逻辑结构。

最后,我们可以在模板中使用注释,用花括号和哈希符号将其包围起来:

{# This is the best template ever! #}
实现方式

模板引擎在渲染页面的过程中有两个主要的阶段,一是解析模板,二是渲染模板。

渲染模板具体包括:

  • 管理动态上下文和数据源
  • 执行逻辑元素
  • 实现点访问和执行过滤器

从解析阶段到渲染阶段的过程是一个关键点。解析出什么内容用来渲染,有两个主要选择,我们使用其他语言中的术语将其称为解释和编译。

在解释模型中,解析生成表示模板结构的数据结构。渲染的过程使用该数据结构,根据其指令来组装结果文本。现在 Django 模板引擎就是使用这种方法。

在编译模型中,解析产生某种形式的直接可执行代码。渲染阶段执行该代码来产生结果。Jinja2 和 Mako 都是使用编译方法的模板引擎。

我们的引擎使用编译来实现:将模板编译成 Python 代码,运行时,Python 代码将结果组合起来。

这里描述的模板引擎一开始是作为 coverage.py 的一部分编写的,用于生成 HTML 报告。在 coverage.py 中,只有少量的模板,它们被重复地用于生成许多文件。总的来说,如果模板被编译成 Python 代码,程序将会运行的更快,因为即使编译过程略显复杂,也只需要运行一次。而编译后的代码执行了很多次,且比解释为数据结构执行多次要快。

将模板编译为python代码有些复杂,但并没有你想象的那么难。此外,正如任何开发人员都知道的,编写一个能写代码的程序比编写代码本身更为有趣!

我们的模板编译器算是一个被称为代码生成的通用技术小例子。代码生成技术是构成许多强大而灵活的工具的基础,包括编程语言编译器。代码生成可以变得复杂,但它会成为你的技术栈中很有用的一门技术。

如果模板使用的次数较少,有些应用可能会倾向使用解释的方式来渲染模板。然后,编译模板到 Python 代码的代价从长远来看代价稍大,从整体来看,一个简单的解释过程可能会更加合适。

编译到 Python

在开始实现模板引擎代码之前,我们先来看看它生成的代码。解释阶段将模板转换为 Python 函数,下面是我们的一个小模板

<p>Welcome, {{user_name}}!</p>
<p>Products:</p>
<ul>
{% for product in product_list %}
    <li>{{ product.name }}:
        {{ product.price|format_price }}</li>
{% endfor %}
</ul>

模板引擎将这个模板编译成Python代码,所得到的Python代码看起来非同一般,因为我们选择了一些快捷的方式来使产生更加轻量和快速的代码。下面是为了可读性而处理过的 Python 代码:

def render_function(context, do_dots):
    c_user_name = context['user_name']
    c_product_list = context['product_list']
    c_format_price = context['format_price']

    result = []
    append_result = result.append
    extend_result = result.extend
    to_str = str

    extend_result([
        '<p>Welcome, ',
        to_str(c_user_name),
        '!</p>\n<p>Products:</p>\n<ul>\n'
    ])
    for c_product in c_product_list:
        extend_result([
            '\n    <li>',
            to_str(do_dots(c_product, 'name')),
            ':\n        ',
            to_str(c_format_price(do_dots(c_product, 'price'))),
            '</li>\n'
        ])
    append_result('\n</ul>\n')
    return ''.join(result)

每个模板都被转换为一个render_function函数,其接收一个称作 context 的数据字典。函数主体先将上下文中的数据解包为局部变量,这样会使重复使用运行地更快。所有的上下文数据都被加上 c_ 前缀称为局部变量,这样我们可以使用其他的变量名而不用担心冲突。

模板的结果会是一个字符串,从组件中构建一个字符串最快的方式是创建一个字符串列表,并将它们最终结合在一起,result 就是一个字符串列表。因为我们将要添加字符串到这个列表中,我们捕获它的appendextend方法赋给局部变量result_appendresult_extend。最后一个创建的局部变量是内置方法to_str的简写str

这些快捷方式是不寻常的,让我们更仔细地看看:在python中,一个被对象调用的方法如 result.append("hello") 分两步执行。首先,从 result 中获取result.append 属性,然后将取得的值被作为函数调用,传递参数 hello 进去。尽管我们习惯一起执行这些步骤,但它们实际上是分开执行的。如果保存了第一步的结果,那么可以在储存的结果上进行第二步。所以下面这两段 Python 代码是一样的:

# The way we're used to seeing it:
result.append("hello")

# But this works the same:
append_result = result.append
append_result("hello")

在模板引擎的代码中,我们用分开的方式,使得无论运行多少地第二步也只用进行一次第一步。这样可以少量节省我们的时间,不必浪费时间去寻找 append 属性。

这就是微优化的一个例子:一种不同的编码技术使运行速度得到微小提升。它会使得代码可读性更差、逻辑更混乱,所以对性能瓶颈处的代码使用微优化才是合理的。有些开发人员对于怎样合理进行微优化有不同意见,同时一些初学者则会过度使用它。这里的优化只有在时间测试中表明能够提升性能的情况下才会被加上,即使只有一点点提升。微优化可能具有启发性,因为它们利用了 Python 某些特殊的方面,但是记住不要过度使用它。

上面的 str 快捷方式同样是微优化。 Python 中的变量可以是局部函数、全局模块或者是内建关键字。寻找本地变量的速度要比从全局或内建的关键字要快。我们习惯 str 总是一个内建函数的事实,,但是 Python 仍在需要使用它的时候查找这个名称。把它放在局部变量中可以节省一小段时间,因为局部的优先级高于内建。

一旦定义了这些快捷键,就可以开始考虑从特定模板中生成 python 代码。字符串将使用 append_resultextend_result 添加到 result 列表中,选择哪一种取决于我们要添加一个还是多个字符串。模板中的文本开始变成了一个简单的字符串文本。

同时使用 appendextend 方法增加了复杂度,但是我们的目的是最快的操作模板,对一个项目使用 extend 意味着需要创建一个新项目列表以便将其传到 extend 中。

{{...}}中的表达式将被计算,转换为字符串,并添加到 result 中。表达式中的点由函数中的 do_dots 函数处理,因为加点的表达式的意义取决于上下文中的数据:它可能是属性访问,对象访问,需要是可调用的。

逻辑结构 {% if ... %}{% for ... %} 会被转换为python的条件语句和循环语句。在 {% if/for ... %} 标签中的表达式将会成为 iffor 语句中的表达式,然后到 {% end... %} 标签之间的内容都会成为正文。

开始编引擎

现在我们了解了引擎会做什么,现在开始来实现它。

模板类

模板引擎的核心是 Templite 类。(学到了吗?它是一个轻量的模板)

Templite 类有一个小接口。你可以使用模板中的文本构建一个 Templite 对象,然后可以使用 render 方法来渲染一个特定的上下文即数据字典到模板中。

# 创建一个模板对象
templite = Templite('''
    <h1>Hello {{name|upper}}!</h1>
    {% for topic in topics %}
        <p>You are interested in {{topic}}.</p>
    {% endfor %}
    ''',
    {'upper': str.upper},
)

# 接着渲染数据
text = templite.render({
    'name': "Ned",
    'topics': ['Python', 'Geometry', 'Juggling'],
})

我们将模板中的文本在创建对象时传递给它,这样我们就只需执行一次编译步骤,然后多次调用 render 函数以重用编译结果。

构造函数也接受一个字典来作为初始的上下文。这些数据存储在 Templite 对象中,并且可以用在模板渲染时。这对于一些我们希望能随时获取的函数和常量都很有用,就像上文的 upper 函数一样。

在讨论 Templite 的实现之前,需要先定义一个辅助类:CodeBuilder。

CodeBuilder

我们模板引擎的主要工作是解析模板并生成关键的 Python 代码。为了辅助生成 Python 代码,我们创建了个在构建 Python 代码时处理簿记的 CodeBuiler 类。它负责增加代码,管理缩进,最终给出编译后的 Python 代码。

一个 CodeBuilder 对象负责整块的 Python 代码。对于我们的模板引擎,Python 代码块总是一个完整的函数定义。但是 CodeBuilder 类并不会假设它只是一个函数,这让 CodeBuilder 更具通用性,且与其他的模板引擎代码的耦合度较低。

正如我们看到的,我们也使用嵌套的 CodeBuilders 来让使代码可以放在函数开头,即使我们可能在完成时才知道它做了什么。

一个 CodeBuilder 对象保留一个最终组装成 Python 代码的字符串列表,它唯一需要的其他状态是当前的缩进:

class CodeBuilder(object):
    """Build source code conveniently."""

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

CodeBuilder并不做太多工作。add_line 添加了一行新的代码,它会将文本自动缩进到当前缩进级别,并增加一个换行符:

    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"])

indentdedent 负责控制缩进:

    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

add_section 由另一个 CodeBuilder 对象管理。这使得我们可以保留代码中的一些引用,并在之后添加文本。self.code 列表主要是字符串列表,但是也会保存对这些部分的引用。(TODO)

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

__str__ 生成一个包含所有代码的字符串,简单地把 self.code 中的所有字符串组合在一起。注意,因为 self.code 中包含其他部分,这可能会递归调用其它 CodeBuilder 对象。

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

get_globals 生成执行代码的最终值。它对对象进行字符串化处理,执行它并得到定义,返回最终的值:

    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

最后的方法利用了 Python 的一些特性。exec 函数执行一个包含 Python 代码的字符串,第二个参数是一个字典,用来收集代码中定义的全局变量。例如,如果我们这样做:

python_source = """\
SEVENTEEN = 17

def three():
    return 3
"""
global_namespace = {}
exec(python_source, global_namespace)

那么 globalnamespace['SEVENTEEN'] 是17,globalnamespace['three'] 是一个名为 three 的函数。

虽然我们只用 CodeBuilder 来生成一个函数,但是没有什么来限制它使用。这使得该类更容易实现和理解。

CodeBuilder 允许我们创建 Python 代码块,而且根本没有关于我们模板引擎的具体知识。我们可以用它在 Python 中定义三个不同的函数,然后 get_globals 返回含有三个函数的字典。这样,我们的模板引擎只需要定义一个函数。但是,更好的软件设计可以将实现细节保留在模板引擎代码中,并在 CodeBuilder 类之外。

即使我们实际用它来定义一个函数,拥有返回字典的 get_globals 函数使得代码更加模块化,因为它并不需要知道我们定义的函数名称。无论我们在 Python 代码中定义了什么函数名,都可以通过 get_globals 返回的字典来检索该名称。

现在我们可以开始实现 Templite 类了,并看看 CodeBuilder 的用法。

Templite 类的实现

我们的大部分代码都在Template类中。 正如我们所讨论的,它具有编译和渲染两个阶段。

编译

将模板编译成 Python 函数的所有工作都发生在 Templite 的构造函数中。 首先,保存上下文:

    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)

注意我们使用了 *contexts 作为可变参数。星号表示将任意数量的位置参数打包成 contexts 元组传入,这被称为参数解包,意味着调用者可以提供许多不同的上下文字典。现在下面的调用都是有效的:

t = Templite(template_text)
t = Templite(template_text, context1)
t = Templite(template_text, context1, context2)

上下文参数(如果存在的话)将被作为上下文元组提供给构造函数。然后可以遍历这个上下文元组,依次处理每个元组。我们简单地创建一个名为 self.context的字典,包含所有上下文。如果有重复的名称,则取最后的。

为了使编译后的函数尽可能快地运行,我们将上下文中的变量赋给 Python 本地变量。我们将通过保留一组遇到的变量名集合来获取这些名称,还需要跟踪模板中定义的变量名称,如循环变量:

        self.all_vars = set()
        self.loop_vars = set()

稍后我们将看到如何使用这些东西来帮助构建函数的序言。首先,我们使用之前编写的 CodeBuilder 类开始构建我们的编译函数:

        code = CodeBuilder()

        code.add_line("def render_function(context, do_dots):")
        code.indent()
        vars_code = code.add_section()
        code.add_line("result = []")
        code.add_line("append_result = result.append")
        code.add_line("extend_result = result.extend")
        code.add_line("to_str = str")

这里我们构造我们的 CodeBuilder 对象,开始向里面写入语句。我们的 Python 函数将被称为 render_function,函数接受两个参数:使用上下文数据的字典和实现点属性访问的 do_dots 函数。

这里的上下文是传递给 Templite 构造函数的上下文和被传给 render 函数的上下文的组合。这是我们在 Templite 构造函数中创建模板的完整数据集。

请注意,CodeBuilder 很简单:它不会知道函数的定义,只有几行代码。这使得 CodeBuilder 在实现和使用都很简单。我们可以在这里读取我们生成的代码,而无需耗费太多精神插入太多专门的 CodeBuilder

我们创建一个名为 vars_code 的代码块。稍后我们将在该块中写入变量提取语句。vars_code 对象可以让我们在函数中保留一个位置,在我们得到需要的信息后会被填充。

然后写入四条固定语句,定义一个结果列表,添加拓展列表的方法和内置 str() 的快捷方式。正如我们之前讨论的,这个奇怪的步骤只是从渲染中挤出更高的性能提升。

同时拥有 appendextend 的快捷方式使我们当面对一行或多行语句的添加时,可以选择最有效的方法。

接下来我们定义一个内部函数来帮助我们缓冲输出字符串:

        buffered = []
        def flush_output():
            """Force `buffered` to the code builder."""
            if len(buffered) == 1:
                code.add_line("append_result(%s)" % buffered[0])
            elif len(buffered) > 1:
                code.add_line("extend_result([%s])" % ", ".join(buffered))
            del buffered[:]

当我们创建需要加入编译后的函数的输出块时,需要将它们转换为加入 result 列表的函数调用。我们倾向于将重复的 append 调用合并为一个 extend 调用,这是另一个微优化。为了做到这点,我们需要缓冲这些块。

缓冲列表保存尚未被写入函数源代码的字符串。当我们的模板编译在运行时,会向 buffered 添加字符串,当我们遇到流程控制点如if语句,循环的开始或结束,将它们更新到函数源代码中。

flus_output 是个闭包函数,闭包是对引用了自身之外变量的函数的别称。这里的 flus_output 引用了 buffered 和 code。这简化了我们对函数调用:我们不必告诉 flush_output 要刷新的缓冲区或者在哪刷新,它隐式地知道这些东西。

如果仅仅缓冲了一个字符串,那么 append_result 的快捷方式将被添加到 result 中。如果有多个缓冲,则使用 extend_result 快捷方式,再添加到 result 中。然后缓冲队列被清空,因此可以用来缓冲之后更多的字符串。

编译代码的其余部分将是添加语句到缓冲区,最终调用 flush_output 将它们写入 CodeBuilder

有了这个函数,我们可以在编译器中拥有像这样的一行代码:

buffered.append("'hello'")

这意味着我们编译后的 Python 函数将具有如下语句:

append_result('hello')

这会将字符串"hello"添加到模板的渲染输出。我们在这里有多个抽象级别,很难一眼看明。编译器使用了 buffered.append("'hello'") ,它在编译出的 Python 函数中创建 append_result('hello') ,在运行时添加 hello 字符串到最终的模板结果中。

回到 Templite 类,当解析控制结构时,我们希望检查它们是否正确嵌套了。此处 ops_stack 列表是个字符串堆栈:

        ops_stack = []

例如当我们遇到 {% if .. %} 标签,就将 'if' 压入堆栈。当遇到一个 {% endif %} 标签,再将之前的 'if' 弹出堆栈。如果栈顶没有 'if' 则报告错误。

现在开始真正的解析。我们使用正则表达式将模板文本分割成多个部分。正则表达式可能是令人生畏的:它们是用来进行复杂模式匹配的非常紧凑的符号。它们也是非常高效的,因为模式匹配的复杂部分是在引擎中用 C 实现的,而不是在 Python 代码中实现。这是我们的正则表达式:

        tokens = re.split(r"(?s)({{.*?}}|{%.*?%}|{#.*?#})", text)

这看起来很复杂,让我们分解一下。

re.split 函数将使用正则表达式分割字符串。我们的模式在括号中,所以匹配将被用来分割字符串,分割后的字符串将组合成列表返回。我们的匹配模式由标签语法决定,我们使用括号将其包起来以便使字符串在标签中分割,然后还将返回标签。

正则表达式中的 (?s) 标志意味着一个点甚至换行符也能被匹配。接着,有三个匹配方案:{{ .\*? }} 匹配一个表达式,{% .\*? %} 匹配一个标签,{# .\*? #} 匹配一个注释。在这之中,我们使用 .*? 匹配任意数量的字符,但是会优先匹配最短序列。

re.split 返回一个字符串列表。例如,这是模板文本:

<p>Topics for {{name}}: {% for t in topics %}{{t}}, {% endfor %}</p>

它将被分割成如下部分:

[
    '<p>Topics for ',               # literal
    '{{name}}',                     # expression
    ': ',                           # literal
    '{% for t in topics %}',        # tag
    '',                             # literal (empty)
    '{{t}}',                        # expression
    ', ',                           # literal
    '{% endfor %}',                 # tag
    '</p>'                          # literal
]

一旦文本被分割成这样,我们就可以循环使用它,并依次来处理。根据类型来分割的话,就可以分别处理每种类型。

编译代码就是循环处理这些部分:

        for token in tokens:

检察每个部分,看看属于四种情况中的哪一种。只看前两个字符就够了,第一种情况是注释,很好处理:只需忽略它并处理到下一个部分:

            if token.startswith('{#'):
                # Comment: ignore it and move on.
                continue

接着对于 {{ ... }} 表达式,我们在它前后截断大括号,并除去无用空格,然后将整个表达式传递给 _expr_code 函数:

            elif token.startswith('{{'):
                # An expression to evaluate.
                expr = self._expr_code(token[2:-2].strip())
                buffered.append("to_str(%s)" % expr)

_expr_code 方法将模板表达式编译为 Python 表达式。稍后再来看这个功能。我们使用 to_str 函数将表达式的值强制转为字符串,并将它加到 result 列表中。

第三种情况是最常见的 {% ... %} 标签。要将它们转成 Python 控制结构。首先我们要刷新缓冲区,然后我们从标签中提取单词列表:

            elif token.startswith('{%'):
                # Action tag: split into words and parse further.
                flush_output()
                words = token[2:-2].strip().split()

现在我们有三种子情形,取决于标签的第一个单词:if, for 或 end。下面的 if 情形展示了简单的错误处理和代码生成:

                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()

if 标签应有一个表达式,所以 words 列表应该只有两个元素。如果不符,我们利用 _syntax_error 辅助方法来抛出语法异常错误。我们将 if 压入 ops_stack 中,以便我们检查 endif 标签。if 标签的表达式部分使用 _expr_code 将其编译为 Python 表达式,并用作 Python if 语句的条件表达式。

第二个标签类型是 for,将被编译为 Python for 语句:

                elif words[0] == 'for':
                    # A loop: iterate over expression result.
                    if len(words) != 4 or words[2] != 'in':
                        self._syntax_error("Don't understand for", token)
                    ops_stack.append('for')
                    self._variable(words[1], self.loop_vars)
                    code.add_line(
                        "for c_%s in %s:" % (
                            words[1],
                            self._expr_code(words[3])
                        )
                    )
                    code.indent()

我们进行语法检查,并将 for 压入栈。_variable 方法检查变量的语法,并将其加入我们提供的集合中,就是我们在编译过程中收集所有变量名称的集合。之后我们需要编写函数的序言,我们将在这里解包从上下文获得的所有变量名。为了能正确地做到这一点,我们需要知道所有遇到的变量名称,self.all_vars 以及循环和 self.loop_vars 中定义的所有变量名称。

接着添加一行 for 语句到函数源代码中。所有的模板变量都加上 c_ 前缀转换为 Python 变量,以便我们了解它们不会与函数中的其它名称冲突。我们使用 _expr_code 函数将模板中的迭代表达式编译成 Python 中的迭代表达式。

最后一种标签是 end 了,包含 {% endif %}{% endfor %} 。对我们编译后的函数代码是一样的:简单地在 iffor 语句末尾结束缩进:

                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()

注意这里结束标签需要的只是一条语句:取消函数代码的缩进。其余语句都是错误检查,以确保模板正确生成。这在程序的翻译代码中并不稀奇。

说到错误处理,如果标签不是一个 ifforend,那么我们不知道它是什么,所以抛出语法异常:

                else:
                    self._syntax_error("Don't understand tag", words[0])

我们完成对三种特殊语法({{ ... }}, {# ... #} 和 {% ... %})的处理,剩下的就是文字内容。我们将文字内容添加到缓冲输出中,使用内置的 repr 函数来生成 Python 字符串字面值:

            else:
                # Literal content.  If it isn't empty, output it.
                if token:
                    buffered.append(repr(token))

如果没有使用 repr,那么编译出的函数最终会出现如下语句:

append_result(abc)      # Error! abc isn't defined

我们需要这样引用值:

append_result('abc')

repr 函数提供了围绕字符串的引号,并且会在必要时加上反斜杠:

append_result('"Don\'t you like my hat?" he asked.')

这里我们首先用if token:检察 token 是否为空,输出空字符串是没有意义的。还有因为我们的正则表达式是按标签语法分割的,所以相邻的标签中会出现空的ttoken。这里的检查很简单,避免无用的 append_result("") 语句加入编译后的函数中。

这样就完成了模板中所有 token 的循环。循环完成后,模板中的所有内容都被处理完毕。最后做个检查:如果 ops_stack 不为空,那么肯定少了一个结束标签。然后我们将刷新缓冲区,并输出到函数代码中。

        if ops_stack:
            self._syntax_error("Unmatched action tag", ops_stack[-1])

        flush_output()

在函数的开头已经创建了一个部分。它的作用是将模板变量从上下文中解包到 Python 本地变量中。现在我们已经处理了整个模板,也知道了所有变量的名称,所以可以在函数序言中写出这些行。

我们必须做些工作来知道我们需要定义什么名称,看看示例模板:

<p>Welcome, {{user_name}}!</p>
<p>Products:</p>
<ul>
{% for product in product_list %}
    <li>{{ product.name }}:
        {{ product.price|format_price }}</li>
{% endfor %}
</ul>

这里有两个变量,user_nameproductall_vars 集内有这两个的名称,因为它们都在 {{...}} 表达式中被使用。但是,只有 user_name 需要在序言中从上下文提取,因为 product 是在循环中定义的。

模板中使用的所有变量都在 all_vars 集中,模板中定义的变量都在 loop_vars 中。loop_vars 中的所有名称都在代码中定义了,因为它们在循环中被使用了。所以我们需要解包所有在 all_vars 而不在 loop_vars 中的名称:

        for var_name in self.all_vars - self.loop_vars:
            vars_code.add_line("c_%s = context[%r]" % (var_name, var_name))

每个名称在函数序言中都变成一行代码,将上下文变量解包成合法的局部变量。

我们已经快完成将模板编译成 Python 函数了。我们的函数已将字符串添加到 result 中,所以函数的最后一行只是简单地将它们合在一起返回:

        code.add_line("return ''.join(result)")
        code.dedent()

现在我们已经编写了编译出 Python 函数的代码,我们需要从从 CodeBuilder 对象获取函数本身。get_globals 方法执行我们已组装的 Python 代码。记住我们的代码是一个函数定义(从 def render_function(..): 开始),所以执行代码将会定义 render_function,但是不执行 render_function 的主体。

get_globals 的结果是代码中定义的值的字典。我们从中获取 render_function 的值,并将其保存为 Templite 对象中的属性:

        self._render_function = code.get_globals()['render_function']

现在 self._render_function 是一个可调用的 Python 函数,稍后我们会在渲染阶段使用它。

编译表达式

我们还没有看到编译过程的重要部分:_expr_code 方法,将模板表达式编译成 Python 表达式。我们的模板表达式可以只有一个简单的名称:

{{user_name}}

也可以是包含属性访问和过滤器的复杂序列

{{user.name.localized|upper|escape}}

_expr_code 方法将处理所有可能情况。正如其他语言中的表达式一样,我们也是递归构建的:大的表达式由较小的表达式组成。一个完整的表达式是由管道符分隔的,第一个是由点分隔的,依此类推。所以我们的函数自然会采取递归的形式:

    def _expr_code(self, expr):
        """Generate a Python expression for `expr`."""

要考虑的第一种情况是表达式中有管道。如果有的话,那么我们把它分割为管道块列表。第一个块将被递归地传给 _expr_code ,将其转换为 Python 表达式。

        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)

剩下的每个管道块都是一个函数名。该值通过函数传递来产生最终的值。每个函数名都是个变量,它被加入 all_vars 中,所以我们可以在序言中正确提取它。

如果没有管道的话,可能会有点操作符。如果有的话,按点来分割。第一部分将递归传递给 _expr_code 以将其转换为 Python 表达式,然后依次处理每个点名称。

        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)

要了解点操作是如何编译的,记住模板中的 x.y 可能意味着 x['y']x.y,取决于哪个能够运行,如果结果是可调用的,就调用它。这种不确定性意味着我们必须在运行时尝试这些可能性,而不是在编译时。所以我们将 x.y.z 编译成一个函数调用 do_dots(x, 'y', 'z')。点函数将尝试各种访问方法并返回成功的值。

do_dots函数将在运行时传入我们编译后的 Python 函数中,稍后我们会看到它的实现。

_expr_code 函数的最后一行处理没有管道或点操作符的情况。这种情况下,这仅仅是个名称。我们将它记录在 all_vars 中,并使用加上前缀的 Python 变量来获取它:

        else:
            self._variable(expr, self.all_vars)
            code = "c_%s" % expr
        return code

辅助函数

在编译过程中,我们使用了几个辅助函数。例如 _syntax_error 方法简单组合出完整的错误信息并抛出异常:

    def _syntax_error(self, msg, thing):
        """Raise a syntax error using `msg`, and showing `thing`."""
        raise TempliteSyntaxError("%s: %r" % (msg, thing))

_variable 方法帮助我们验证变量名称并将其加入我们在编译过程中收集的名称集合。我们使用正则表达式来检查名称是否是有效的 Python 标识符,然后将名称添加到集合中:

    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 函数,所以渲染代码没有太多事情要做。它必须准备好上下文,然后调用编译后的 Python 代码:

    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)

请记住,我们在构建 Templite 对象时,从数据上下文开始。我们在这里复制它,然后将它和被传入渲染函数的数据合并。复制是为了连续的渲染函数调用不会看到彼此的数据,然后合并是为了使我们有一个字典用于数据查找。这就是我们如何从构建和渲染模板时提供的上下文中构建一个统一的数据上下文。

注意这里我们传递给 render 的数据可能会覆盖传递给 Templite 构造函数的数据。这不会轻易发生,因为传递给构造函数的上下文包含的具有全局性的过滤器和常量,而传给 render 的上下文具有该次渲染的特定数据。

然后我们简单地调用我们编译的 render_function。第一个参数是完整的数据上下文,第二个参数是实现点语义的函数。我们每次使用相同的实现:自己的_do_dots 方法。

    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

在编译期间,像 x.y.z 的模板表达式变为 do_dots(x, 'y', 'z')。这个函数循环遍历点的名称,并且对于每个点都先尝试是否是属性,失败的话,再尝试将其作为字典的键。所以我们能在单个模板语法中灵活使用 x.yx['y']。在每个步骤中,我们还会尝试调用新的值。一旦我们完成了处理,得到的值就是我们想要的值。

这里我们再次使用了 Python 的参数解包(*dots),以便 _do_dots 可以处理任意数量的点操作。这给了我们的函数较高的灵活性,可以用于所有模板中遇到的点表达式。

需要注意的是,当调用 self._render_function 时,我们传递一个函数用来评估点表达式,但是我们总是传递同一个。我们能够使该代码作为编译模板的一部分, 但是对于每个模板而言,它们都是八行代码,这八行代码是模板工作方式的一部分,而不是特定模板的一部分细节。所以这样实现会比将其作为编译模板的一部分看起来更清楚。

测试

模板引擎还提供了一套覆盖所有行为和边缘样例的测试。实际上代码有点超出500行的限制:模板引擎有252行,测试有275行。这是经过良好测试的代码:测试代码比产品代码还多。

遗漏的东西

一个完整的模板引擎比我们在这里实现的还要复杂的多。为了保证精简的代码,我们将会留下了一些有趣的问题:

  • 模板继承和包含
  • 自定义标签
  • 自动转义
  • 过滤参数
  • 复杂的条件逻辑,如 else 和 elif
  • 具有多个循环变量的循环
  • 空白控制

即使如此,我们的简单模板引擎也很有用。实际上,它被用于为 coverage.py 生成 HTML 报告。

总结

在252行中,我们得到一个简单而有一定功能的模板引擎。真实的模板引擎具有更多的功能,但是这个代码勾阐述了这个过程的基本思路:将模板编译成 Python 函数,然后执行该函数来生成文本结果。

校对 0/1
润色 0/1

--END--
文章创建于 2017-04-16 11:07:27,最后更新 2017-04-24 22:34:36
Comment
尝试加载Disqus评论, 失败则会使用基础模式.
    • play_arrow

    About this site

    version:1.02 Alpha
    博客主题: Lime
    联系方式: i@wind.moe
    写作语言: zh_CN & en_US
    博客遵循 CC BY-NC-SA 4.0许可进行创作

    此外,本博客会基于访客的Request Headers记录部分匿名数据用于统计(Logger的源码见Github),包含Referer, User-Agent & IP Address.个人绝不会主动将数据泄露给第三方