500 行或更少
模板引擎

Ned Batchelder

Ned Batchelder 是一位拥有丰富经验的软件工程师,目前在 edX 工作,致力于构建开源软件来教育世界。他是 coverage.py 的维护者,波士顿 Python 的组织者,并在许多 PyCons 上发表过演讲。他在 http://nedbatchelder.com 上写博客。他曾经在白宫吃过晚饭。

介绍

大多数程序包含大量逻辑和少量文字数据。编程语言的设计是为了适应这种编程。但是,有些编程任务只涉及少量逻辑,而涉及大量文字数据。对于这些任务,我们希望有一个更适合这些文本密集型问题的工具。模板引擎就是这样一种工具。在本章中,我们将构建一个简单的模板引擎。

这些文本密集型任务中最常见的示例是 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 块,因此必须单独处理并与页面的其余部分组合。

以这种方式生成我们的玩具页面可能看起来像这样

# The main HTML for the whole page.
PAGE_HTML = """
<p>Welcome, {name}!</p>
<p>Products:</p>
<ul>
{products}
</ul>
"""

# The HTML for each product displayed.
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 页面被编写为模板,这意味着文件主要是静态 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 文本,逻辑嵌入在 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 中,而无需担心哪些字符在 HTML 中是特殊的。

支持的语法

模板引擎在它们支持的语法上有所不同。我们的模板语法基于 Django,这是一个流行的 Web 框架。由于我们在 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 函数,该函数接受一个称为上下文的数据字典。函数的主体从将数据从上下文解包到局部名称开始,因为它们在重复使用时速度更快。所有上下文数据都进入以 c_ 为前缀的局部变量,这样我们就可以使用其他局部变量,而不必担心冲突。

模板的结果将是一个字符串。从各部分构建字符串最快的 方法是创建一个字符串列表,并在最后将它们连接起来。result 将是字符串列表。因为我们要将字符串添加到此列表中,所以我们在局部名称 result_appendresult_extend 中捕获它的 appendextend 方法。我们创建的最后一个局部变量是 to_str,它是 str 内置函数的简写。

这些捷径不常见。让我们更仔细地看看它们。在 Python 中,对对象(如 result.append("hello"))进行的方法调用将在两个步骤中执行。首先,从 result 对象中获取 append 属性: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 中的名称可以是函数局部、模块全局或 Python 内置。查找局部名称比查找全局名称或内置名称更快。我们习惯于 str 是始终可用的内置函数,但 Python 每次使用 str 时仍需查找其名称。将它放在局部变量中可以节省一小段时间,因为局部变量比内置函数更快。

定义好这些快捷方式后,我们就可以从我们的特定模板创建 Python 行了。字符串将使用 append_resultextend_result 快捷方式添加到结果列表中,具体取决于我们要添加一个字符串还是多个字符串。模板中的文字将变成一个简单的字符串文字。

同时使用 append 和 extend 会增加复杂性,但请记住,我们的目标是实现模板的最快执行速度,而对单个项目使用 extend 意味着要创建一个包含单个项目的列表,以便我们可以将其传递给 extend。

{{ ... }} 中的表达式将被计算、转换为字符串并添加到结果中。表达式中的点由传递到函数的 do_dots 函数处理,因为点表达式的含义取决于上下文中的数据:它可以是属性访问或项目访问,也可以是可调用对象。

逻辑结构 {% if ... %}{% for ... %} 将被转换为 Python 条件语句和循环。{% if/for ... %} 标签中的表达式将成为 iffor 语句中的表达式,而直到 {% end... %} 标签之间的内容将成为语句的主体。

编写引擎

现在我们已经理解了引擎的功能,让我们来了解一下它的实现过程。

Templite 类

模板引擎的核心是 Templite 类。(明白了?它是一个模板,但它很轻量级!)

Templite 类具有一个小的接口。你可以使用模板文本构造一个 Templite 对象,然后在稍后使用它的 render 方法来渲染一个特定的上下文(数据字典)通过模板。

# 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'],
})

我们在创建对象时传递模板文本,以便我们只执行一次编译步骤,并在稍后多次调用 render 来重用编译结果。

构造函数还接受一个值字典,即初始上下文。这些值将存储在 Templite 对象中,并在稍后渲染模板时可用。这些值适合定义我们希望在任何地方都可用的函数或常量,例如前面示例中的 upper

在我们讨论 Templite 的实现之前,我们首先需要定义一个辅助函数:CodeBuilder。

CodeBuilder

我们引擎中的大部分工作是解析模板并生成必要的 Python 代码。为了帮助生成 Python 代码,我们有 CodeBuilder 类,它在我们构建 Python 代码时为我们处理簿记工作。它添加代码行、管理缩进,并最终为我们提供编译后的 Python 代码中的值。

一个 CodeBuilder 对象负责处理一块完整的 Python 代码。在我们的模板引擎中,Python 代码块始终是一个完整的函数定义。但 CodeBuilder 类并没有假设它只包含一个函数。这使得 CodeBuilder 代码更加通用,并且与其他模板引擎代码的耦合度更低。

正如我们将在后面看到的那样,我们还会使用嵌套的 CodeBuilder 来使我们能够在函数开头添加代码,即使我们直到最后才知道它会是什么。

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 列表主要是一个字符串列表,但也会包含指向这些部分的引用。

    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 代码的字符串。exec 的第二个参数是一个字典,它将收集代码定义的全局变量。例如,如果我们执行以下操作:

python_source = """\
SEVENTEEN = 17

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

那么 global_namespace['SEVENTEEN'] 为 17,而 global_namespace['three'] 实际上是一个名为 three 的函数。

虽然我们只使用 CodeBuilder 生成一个函数,但这里没有任何限制它只能用于生成一个函数。这使得类更易于实现,也更易于理解。

CodeBuilder 让我们可以创建一段 Python 源代码,并且对我们的模板引擎一无所知。我们可以使用它来定义三个不同的函数,然后 get_globals 将返回包含三个值的字典,即三个函数。实际上,我们的模板引擎只需要定义一个函数。但是,将这个实现细节保留在模板引擎代码中,而不在 CodeBuilder 类中,是一种更好的软件设计。

即使我们实际上使用它来定义单个函数,让 get_globals 返回字典也会使代码更具模块化,因为它不需要知道我们定义的函数的名称。无论我们在 Python 源代码中定义了什么函数名称,我们都可以从 get_globals 返回的字典中检索该名称。

现在我们可以开始研究 Templite 类本身的实现,看看 CodeBuilder 是如何在其中使用以及在哪里使用的。

Templite 类的实现

我们的大部分代码都在 Templite 类中。正如我们已经讨论过的,它既有编译阶段也有渲染阶段。

编译

将模板编译成 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)

上下文参数(如果有)将作为上下文元组传递给构造函数。然后,我们可以遍历 contexts 元组,依次处理每个上下文。我们只需创建一个名为 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,它将接受两个参数:context 是它应该使用的数据字典,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[:]

当我们创建需要进入编译后的函数的输出块时,我们需要将其转换为向结果追加的函数调用。我们希望将重复的 append 调用组合成一个 extend 调用。这又是另一种微优化。为了使这成为可能,我们缓冲这些块。

buffered 列表保存尚未写入函数源代码的字符串。在编译模板时,我们将字符串追加到 buffered,并在到达控制流点(例如 if 语句或循环的开始或结束)时将其刷新到函数源代码中。

flush_output 函数是一个闭包,这是一个高级术语,指的是引用自身外部变量的函数。这里 flush_output 引用 bufferedcode。这简化了我们对函数的调用:我们不需要告诉 flush_output 要刷新哪个缓冲区或在何处刷新它;它隐式地知道所有这些信息。

如果只缓冲了一个字符串,那么将使用 append_result 快捷方式将其追加到结果中。如果缓冲了多个字符串,那么将使用 extend_result 快捷方式以及所有缓冲的字符串,将其添加到结果中。然后,清除缓冲列表,以便可以缓冲更多字符串。

编译代码的其余部分将通过将代码行追加到 buffered 来将它们添加到函数中,并最终调用 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',则报告错误。

现在真正的解析开始了。我们使用正则表达式,或者称为 *正则表达式*,将模板文本分割成多个标记。正则表达式可能令人生畏:它们是一种非常紧凑的表示法,用于复杂模式匹配。它们也非常高效,因为匹配模式的复杂性是在 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 函数强制表达式的值为字符串,并将其添加到我们的结果中。

第三种情况是最重要的:{% ... %} 标签。这些是控制结构,将成为 Python 控制结构。首先,我们必须刷新缓冲的输出行,然后我们从标签中提取一个单词列表

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

现在我们有三个子情况,基于标签中的第一个词:ifforendif 情况显示了我们简单的错误处理和代码生成

                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 变量,这样我们就知道它们不会与我们在 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: 检查标记是否为空字符串,因为将空字符串添加到输出中毫无意义。因为我们的正则表达式是在标签语法上进行分割的,所以相邻的标签之间会有一个空标记。这里的检查是一种简单的方法,可以避免将无用的 append_result("") 语句放到我们编译的函数中。

这样就完成了对模板中所有标记的循环。当循环完成时,所有模板都已处理完毕。我们还有一个检查要进行:如果 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 函数。我们将在后面的渲染阶段使用它。

编译表达式

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

{{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 在 Python 中可以表示 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行。这是经过充分测试的代码的典型情况:你的测试代码比产品代码更多。

遗漏部分

功能齐全的模板引擎提供的功能远远超出了我们在这里实现的功能。为了使代码保持简洁,我们省略了一些有趣的思路,例如:

即使如此,我们的简单模板引擎也很有用。事实上,它是coverage.py用来生成其HTML报告的模板引擎。

总结

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