Python 模板的实现

本文翻译自500 lines or less系列文章,原文链接,作者:Ned Batchelder

Ned Batchelder是一名非常有经验的工程师,目前就职于edX,主要从事开源软件推广工作,他同时也是coverage.py的维护者,Boston Python的组织者,并参与多个PyCons,甚至还接受过美国的白宫晚宴,他的博客地址为http://nedbatchelder.com

简介

大多数的程序都是由多数逻辑计算和少量的文字处理构成的,程序语言一般也以逻辑计算为中心,但是有些时候我们也需要进行大量的文字处理,这就需要有个一比较好的文字处理工具。本次设计的目的就是为了解决文字处理这一方面的需求,在这一章,我们将设计一个基于模板的文字处理引擎。

文字处理大量被应用在互联网程序设计中,比如生成共浏览器显示的HTML网页,实际上现在的HTML网页已经很少是完全的静态网页,一般都是含有一部分动态数据,比如网页中的用户名,产品列表,朋友的更新,新闻的更新等等。

与此同时,每一个HTML页面还含有大量的静态文本,这样多大几K的动态数据和静态数据混合的文本就成为程序处理的一个难点,另外HTML里面的静态文本一般由前端工程师完成,动态数据则由后端生成,如何更好的使前后端协调工作?

为了解决这方面的难题,我们假设需要如下的一个静态页面:

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

在上述页面里,用户名就属于动态数据,同样的还有产品名字和产品价格,另外还有产品数量没有添加到页面,有可能还有更多或更少的产品需要显示。

为了生成这样的页面,比较简单的方法是将所有的字符添加到我们的程序代码里面,通过在代码中动态生成相应的结果字符串,而对于产品的显示,我们可能需要通过循环来适应不同的产品数量。

这种生成方式的程序代码如下:

# The main HTML for 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直接包含在我们的代码中,并且被拆分成很多部分,这样就失去了代码的逻辑性。另外,如果前端工程师需要就该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的设计,少量的逻辑代码嵌入其中。

在模板中用到的大量的静态文本与程序设计中的模式并不相同,比如,在Python中,大量的代码都是可执行的,如果需要静态文本,我们需要加上引号:

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

hello()

当Python读取源文件的时候,Python会把像def hello()这样的文本翻译为指令,而引号中的像``"Hello, world"的文本则代表静态的文本。这就是一般的程序执行模式:大量的逻辑代码和少量的静态文本,静态文本通过特殊的方式标示。

而模板语言则刚好相反,大量的文本都属于静态文本,只含有少量的动态可执行部分:

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

模板里面的文本一般都是直接显示到HTML的结果页面,直到遇到{{这样的需要转换为动态数据的标志,之后user_name将会被作为变量输出到结果。

模板语言参考了类似于"foo = {}!".format(foo=17)这样的格式化函数,从而实现了动态数据的添加。另外模板将这种思想进行了丰富,实现了对于条件和循环等多种标签的支持。

之所以把这些文件称之为模板是因为通过这些文件可以生成有统一格式的各种文件。

为了在我们程序中实现HTML模板的功能,我们需要一个模板引擎,这个引擎接受描述数据样式的模板数据和将要插入到模板中显示的动态数据,引擎主要完成对模板的解析,将模板中的动态数据标签用动态数据结果进行替换的功能。

另外,这里的模板不仅仅局限于生成HTML页面,实际上任何纯文本的结果都可以通过这个模板引擎实现,例如生成纯文本的电子邮件。

支持的语法

不同的模板支持各种不同的语法,我们的模板语法是基于Django的模板系统实现的。另外,因为我们引擎是通过Python实现的,因此在语法里面会有一些Python的概念,下面就是我们将要设计的模板支持的语法的一个汇总:

数据的引用通过双大括号{{}}实现:

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

数据将在模板渲染(render)的过程进行转换,详细细节后面会有提及。

模板引擎同样支持对数据元素的的提取,在Python中,不同的数据类型有不同的提取方式:

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

但是在我们的模板设计中,所有的操作都是通过.实现:

dict.key
obj.attr
obj.method

点号会直接访问对象的属性或字典的值,如果结果是一个可调用的函数,那么将会直接对结果进行调用。这与Python代码是有区别的。这主要是为了简化模板的设计,采用这种语法的一个简单示例:

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

对于一个对象,点号可以级联使用,比如对象的属性是另外一个对象,可以通过级联的方式进行更深层的访问。

在模板中还可以调用filter功能,该功能允许更改数据转换的方式,同样也可以级联:

<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! #}

实现方法

概括的说,我们的模板引擎需要实现两个主要功能:分析模板和渲染模板。
渲染模板主要任务:

  • 管理动态数据内容
  • 执行逻辑单元代码块
  • 实现点号数据访问和filter的执行

这里面的关键问题是分析模板的结果如何传递到模板渲染模块。什么样的分析结果可以被直接转换?有翻译(interpretation)和编译(compilation)两种选择。

翻译模式,分析模块生成一个代表模板结构的数据结构。渲染模块遍历整个数据结构,将需要翻译的部分进行替换,Django的模板引擎就是采用的这种方法。

编译模式,分析模块直接产生可执行代码,渲染模块执行代码得到结果,Jinja2Mako就是采用的这种方法。

我们今天将要采用编译模式实现我们的模板引擎:我们将会将模板编译为Python代码,执行代码,最后得到结果。

这里所描述的代码引擎实际上是coverage.py的一部分,主要用来产生HTML报告。在coverage.py里面,通过反复调用少量的模板生成很多类似的结构的文件。总的来说,如果把模板编译成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代码,这里的代码可能会有一些别扭,因为我们用了一些代码优化,以便可以得到更好的运行效率(下面代码为方便阅读已经修改):

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的字典。函数内部首先对字典进行解包,然后将context转化为本地局部变量,因为转换之后可以更快的进行多次调用。所有本地局部变量冠以c_前缀。

函数的运行结果是一个字符串。最快速的通过字符串片段生成字符串的方式就是先建立一个字符串列表,然后通过join函数生成结果字符串。result就是这个字符串列表,因为我们还需要对result列表进行expandextend操作,于是我们把这两个方法也本地化成append_resultextend_result,这样可以得到更高效率的重复调用。我们最后还将str函数本地化了,因为这个函数也需要多次调用。

虽然这样的本地化的方式在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_resultextend_result对result列表进行操作。

同时使用expandextend可能有点混乱,但是我们的目标是尽可能高的执行效率,通过extend可以生成一个性的列表,这个列表可以再次被传递到extend以便执行下次循环。

{{ ... }}中的语句会被执行,转换成字符串,添加到结果,而点号会被do_dots函数单独处理,因为这里面点号可能有多种含义,可能是字典元素,或是对象属性,也可能是可执行方法。

逻辑代码块{% if ... %}{% for ... %}被转换成Python的条件和循环语句。其中的表达式会变成iffor语句中的表达式,而中间直到{% end ... %}将会转换为iffor的代码块.

引擎的编写

前面我们已经了解了模板引擎的实现方法,现在我们开始着手实现这个引擎。

Templite 类

模板引擎的核心就是这个Templite类(Template Lite)

Templite有一个小的接口。一旦你构造了这样一个类,后面就可以通过调用render方法实现对特定context(内容字典)的渲染:

# 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方法对模板进行多次调用。

构造函数接受一个字典参数作为内容的初始化,他们直接被存储在类内部,在后期调用render方法的时候可以直接引用。同样,一些会用到的函数或常量也可以在这里输入,比如之前的upper函数。

再开始讨论Temlite类实现之前,我们先来看一下这样一个类:CodeBuilder。

CodeBuilder

我们编写模板引擎的主要工作就是模板解析和产生必要的Python代码。为了帮助我们更好的产生Python代码,我们需要一个CodeBuilder的类,这个类主要负责代码的生成:添加代码,管理缩进以及返回最后的编译结果。

一个CodeBuilder实例完成一个Python方法的构建,虽然在我们模板引擎中只需要一个函数,但是为了更好的抽象,降低模块耦合,我们的CodeBuilder将不仅仅局限于生成一个函数。

虽然我们可能直到最后才会知道我们的结果是什么样子,我们还是把这部分拿到前面来说一下。

CodeBuilder主要有两个元素,一个是用于保存代码的字符串列表,另外一个是标示当前的缩进级别。

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

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

下面我们来看一下我们需要的接口和具体实现。
add_line方法将添加一个新的代码行,缩进将自动添加

    def add_line(self, line):
        """Add a line of source to the code.
        Indentation and new line will be added for you, don't provide them.
        """

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

indentdedent增加和减少缩进级别的函数:

    INDENT_STEP = 4

    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 secton, a sub-CodeBuilder."""
        section = CodeBuilder(self.indent_level)
        self.code.append(section)
        return section

__str__用于产生所有代码,它将遍历self.code列表,而对于self.code中的sections,它也会进行递归调用:

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

get_globals通过执行代码迭代生成结果:

    def get_globals(self):
        """Executer the code, and return a dict of globals if defnes."""
        # A check that caller really finished all the blocks
        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_source = """\
SEVENTEEN = 17

def three():
    return 3
"""

global_namespace = {}
exec(python_source, global_namespace)
print(global_namespace['SEVENTEEN'], global_namespace['three'])

输出结果:

(17, <function three at 0x029FABB0>)
[Finished in 0.1s]

虽然我们只需要CodeBuilder产生一个函数,但是实际CodeBuilder的使用并不局限于一个函数,它实际是一个更为通用的类。

CodeBuilder可以产生Python代码,但是并不依赖于我们的模板,比如我们要产生三个函数,那么get_global实际就可以产生含有三个函数的字典,这是一种非常实用的程序设计方法。

下面我们回归Templite类,看一下如何去实现这样一个类

Templite类的实现

就像之前我们所讲的一样,我们的主要任务在于实现模板发解析和渲染。

编译(解析Compiling)

这部分工作需要完成模板代码到python代码的转换,我们先尝试写一下构造器:

    def __init__(self, text, *contexts):
        """Construct a Templite with the given 'text'.
        'contexts' are dictionaries of values to future renderings.
        These are good for filters and global values.
        
        """
        super(Templite, self).__init__()
        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中对重名情况直接使用最近的定义。

同样,为了更有效的编译函数,我们将context中的变量也本地化了,我们同样还需要对模板中的变量进行整理,于是我们定义如下两个元素:

        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类,添加函数名称为render_function,以及函数的两个参数:数据字典context和实现点号属性获取的函数do_dots

这里的数据字典包括传入Templite例化的数据字典和用于渲染的数据字典。是整个可以获取的数据的一个集合。

而作为代码生成工具的CodeBuilder并不关心自己内部是什么代码,这样的设计使CodeBuilder更为简洁和易于实现。

我们还创建了一个名称为vars_code的代码段,后面我们会把我们的变量放到这个段里面,该代码段为我们预留了一个后面添加代码的空间。

另外的四行分别添加了结果列表result的定义,局部函数的定义,正如之前说过的,这都是为了提升运行效率而添加的变量。

接下来,我们定义一个用于缓冲输出的内部函数:

        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[:]

因为我们需要添加很多code到CodeBuilder,所以我们选择将这种重复的添加合并到一个扩展函数,这是另外的一种优化,为了实现这种优化,我们添加一个缓冲函数。

buffered函数保存我们将要写入的code,而在我们处理模板的时候,我们会往buffered列表里添加字符串,直到遇到其他要处理的点,我们再将缓冲的字符写入生成函数,要处理的点包括代码段,或者循环判断语句的开始等标志。

flush_output函数是一个闭包,里面的变量包括bufferedcode。这样我们以后调用的时候就不需要指定写入那个code,从那个变量读取数据了。

在函数里,如果只是一个字符串,那么调用append_result函数,如果是字符串列表,则调用extend_result函数。

拥有这个函数之后,后面需要添加代码的时候只需要往buffered里面添加就可以了,最后调用一次flush_ouput即可完成代码到CodeBuilder中的添加。

比如我们有一行代码需要添加,即可采用下面的形式:

buffered.append("'hello'")

后面会添加如下代码到CodeBuilder

append_result('hello')

也就是将字符串hello添加到模板的渲染。太多层的抽象实际很难保持一致性。编译器使用buffered.append("'hello'"), 这将生成append_result('hello')``到编译结果中。

让我们再回到Templite类,在我们进行解析的时候,我们需要判断模板
能够正确的嵌套,这就需要一个ops_stack来保存字符串堆栈:

        ops_stack = []

比如在遇到{% if ... %}标签的时候,我们就需要将'if'进行压栈,当遇到{% endif %}的时候,需要将之前的的'if'出栈,如果解析完模板的时候,栈内还有数据,就说明模板没有正确的使用。

现在开始做解析模块。首先通过使用正则表达式将模板文本进行分组。正则表达式是比较烦人的: 正则表达式主要通过简单的符号完成对字符串的模式匹配。因为正则表达式的执行是通过C完成的,因此有很高的效率,但是最初接触时比较复杂难懂,比如:

        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.startwith('{#'):
                # Comment: ignore it and move on.
                continue

对于{{ ... }}这样的表达式,需要将两边的括号删除,删减表达式两边的空格,最后将表达式传入到_expr_code:

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

_expr_code方法会将模板中的表达式编译成Python语句,后面会具体降到这个方法的实现。再之后通过to_str函数将编译后的表达式转换为字符串添加到我们的结果中。

后面一个条件判断最为复杂:{% ... %}语法标签的处理。它们将会被编译成Python中的代码段。在操作之前,首先需要将之前的结果保存,之后需要从标签中抽取必要的关键词进行处理:

            elif token.startwith("{%"):
                # Action tag: split into words and parse futher
                flush_output()
                words = token[2:-2].strip().split()

目前支持的语法标签主要包含三种结构:if, forend. 我们来看看对于if的处理:

                if words[0] == 'if':
                    # An if statement: evalute 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的长度应该为2(译者:难道不会有空格??),如果长度不正确,那么将会产生一个语法错误。之后会对if语句进行压栈处理以便后面检测是否有相应的endif结束标签。if后面的判断语句通过_expr_code编译,并添加if代码后添加到结果,最后增加一级缩进。

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

            elif word[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._veriable(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 %}来说都是一样的:主要完成对相应代码段的减少缩进功能。

                elif word[0].startwith('end'):
                    #Endsomting. 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 engs", token)
                    start_what = ops_stack.pop()
                    if start_what ~= end_what:
                        self._syntax_error("Mismatched end tag", end_what)
                    code.dedent()

注意,这里结束标签最重要的功能就是结束函数代码块,减少缩进。其他的都是一些语法检查,这种操作在翻译模式一般都是没有的。

说到错误处理,如果标签不是if, for或者end,那么程序就无法处理,应该抛出一个异常:

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

在处理完三种不同的特殊标签{{ ... }}, {# ... #}{% ... %}之后。剩下的应该就是普通的文本内容。我们需要将这些文本添加到缓冲输出,通过repr方法将其转换为Python中的字符串:

            else:
                #literal content, if not 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:, 因为我们没必要将空字符也添加到输出。空的tokens一般出现在两个特殊的语法符号中间,这里的空字符检测可以避免向最终的结果添加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_nameproduct。这些变量在模板遍历后都会放到all_vars集合中。但是在这里我们只需要对user_name进行处理,因为product是在for循环中定义的。

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

这里每一个变量都会从context数据字典中获得相应的值。

现在我们基本上已经完成了对模板的编译。最后我们还需要将函数结果添加到result列表中,因此最后还需要添加如下代码到我们的代码生成器:

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

到这里我们已经实现了对模板到python代码的编译,编译结果需要从代码生成器CodeBuilder中获得。可以通过get_globals方法直接返回。还记得吗,我们需要的代码只是一个函数(函数以def 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 }}

有时候会相当复杂,包含一系列的属性和过滤器(filters):

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

_expr_code需要对上面各种情况做出处理,实际复杂的表达式也是由简单的表达式组合而成的,跟一般语言一样,这里用到了递归处理,完整的表达式通过|分割,表达式内部还有点号.分割。因此在函数定义的时候我们采用可递归的形式:

    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)

而后面的则是一系列的函数名。第一个表达式作为参数传递到后面的这些函数中去,所有的函数也会被添加到all_vars集合中以便例化

如果没有|,那么可能有点号.操作,那么首先将开头的表达式进行递归处理,后面再依次处理点好之后的表达式。

        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()。这种不确定性意味着我们需要在执行的过程中依次对其进行尝试,而不能再编译时就去定义。因此我们把这部分编译为一个函数调用do_dots(x, 'y', 'z'),这个函数将会对各种情形进行遍历并返回最终的结果值。

do_dots函数已经传递到我们编译的结果函数中去了。它的实现稍后就会讲到。

最后要处理的就是没有|.的部分,这种情况下,这些就是简单的变量名,我们只需要将他们添加到all_vars集合,然后同带前缀的名字去获取即可:

        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方法对变量进行验证,并将他们添加到变量集合中,我们利用一个正则表达式去验证变量名是否有效:

    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):
        """Evalute 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'). 在函数中会对各个名字进行遍历,每一次都会先尝试获取属性值,如果失败,在尝试作为字典值获取。这样使得模板语言更加灵活。在每次遍历时还会检测结果是不是可以调用的函数,如果可以调用就会对函数进行调用,并返回结果。

这里,函数的参数列表定义为(*dots),这样就可以获得任意数目的参数,这同样使模板设计更为灵活。

注意,在调用self._render_function的时候,我们传进了一个函数,一个固定的函数。可以认为这个是模板编译的一部分,我们可以直接将其编译到模板,但是这样每个模板都需要一段相同的代码。将这部分代码提取出来会使得编译结果更加简单。

测试

假设需要对整个代码进行详尽的测试以及边缘测试,那么代码量可能超过500行,现在模板引擎只有252行代码,测试代码就有275行。测试代码的数量多于正是代码是个比较好的的测试代码。

未涉及的地方

完整的代码引擎将会实现更多的功能,为了精简代码,我们省略了如下的功能:

  • 模板继承和包含
  • 自定义标签
  • 自动转义
  • 参数过滤器
  • 例如elseelif的复杂逻辑
  • 多于一个变量的循环
  • 空白符控制

即便如此,我们的模板引擎也十分有用。实际上这个引擎被用在coverage.py中以生成HTML报告。

总结

通过252行代码,我们实现了一个简单的模板引擎,虽然实际引擎需要更多功能,但是这其中包含了很多基本思想:将模板编译为python代码,然后执行代码得到最终结果。


本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。

相关文章

仅有 1 条评论
  1. Typecho

    欢迎加入 Typecho 大家族

    Typecho 回复
发表新评论