python 模板实现-引擎的编写(有时间试一下)

关于简介和模板问题请在参考文档查看

参考文档:

1.模板的编写:https://blog.csdn.net/MageeLen/article/details/68920913

一、引擎的编写

1.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。

2.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方法将添加一个新的代码行,缩进将自动添加

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

目前支持的语法标签主要包含三种结构:ifforend. 我们来看看对于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()

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

说到错误处理,如果标签不是iffor或者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

三、渲染

剩下的工作就是编写渲染代码。既然我们已经将模板编译为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代码,然后执行代码得到最终结果。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏有趣的Python

慕课网-Linux C语言指针与内存-学习笔记

Linux C语言指针与内存 工具与原理 指针 数组 字符串 堆内存与栈内存 gdb内存调试工具。 C语言中指针的基本用法 #include <stdio.h>...

2834
来自专栏程序员互动联盟

【答疑释惑】JavaScript解释器是干什么用的?

问题: ? JavaScript解释器主要是干什么用的?他不是一门语言吗? 解答: JavaScript是一门脚本语言,是需要被别人解释执行的,这个别人就是Ja...

2926
来自专栏Jackson0714

C#多线程之旅(4)——APM初探

40513
来自专栏有趣的Python

2-Linux C语言指针与内存-学习笔记

为原来的变量值加上*, change函数改为传入&a &b 3和5可以成功的交换。

1803
来自专栏云霄雨霁

Java--类和对象之基础知识

1503
来自专栏达摩兵的技术空间

js中的作用域

相信自从es6出来之后,你一定多少知道或者已经在项目中实践了部分的块级作用域,在函数或者类的内部命名变量已经在使用let了,但是你知道它真正的作用是什么吗?又是...

1062
来自专栏老马说编程

计算机程序的思维逻辑 (9) - 条件执行的本质

条件执行 前面几节我们介绍了如何定义数据和进行基本运算,为了对数据有透彻的理解,我们介绍了各种类型数据的二进制表示。 现在,让我们回顾程序本身,只进行基本操...

19210
来自专栏技巅

Thrift之代码生成器Compiler原理及源码详细解析2

2215
来自专栏Micro_awake web

JSON.parse与JSON.stringify

JSON:JavaScript Object Notation(JavaScript对象表示法);甚至我们就可以大致认为JSON就是Javascript的对象,...

1192
来自专栏java工会

JAVA 同步实现原理

Synchronized是Java中解决并发问题的一种最常用的方法,也是最简单的一种方法。Synchronized的作用主要有三个:

520

扫码关注云+社区