社区所有版块导航
Python
python开源   Django   Python   DjangoApp   pycharm  
DATA
docker   Elasticsearch  
aigc
aigc   chatgpt  
WEB开发
linux   MongoDB   Redis   DATABASE   NGINX   其他Web框架   web工具   zookeeper   tornado   NoSql   Bootstrap   js   peewee   Git   bottle   IE   MQ   Jquery  
机器学习
机器学习算法  
Python88.com
反馈   公告   社区推广  
产品
短视频  
印度
印度  
Py学习  »  Python

252 行代码搞定 Python 模板实现

编程派 • 7 年前 • 688 次点击  

本文翻译自 500 lines or less 系列文章,作者 Ned Batchelder,译者 mageelen。


译文链接:http://mageelen.github.io/2016/04/16/500linesorless/Atemplate_engine/index.html

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

简介

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

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

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

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

  1. Welcome, Charlie!

     
  2. Pruducts:

     
    •  

    •    

    • Apple: $1.00

    •  
    •    

    • Fig: $1.50

    •  
    •    

    • Pomegranate: $3.25

    •  
    •  

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

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

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

  1. # The main HTML for whole page.  

  2. PAGE_HTML = """  

  3. Welcome, {name}!

     

  4. Products:

     

    •  

  5.    {products}  

  6.  

  7. """  

  8. # The HTML for each product displayed.  

  9. PRODUCT_HTML = "

  10. {prodname}: {price}
  11. n"  

  12. def make_page(username, products):  

  13.    product_html = ""  

  14.    for prodname, price in products:  

  15.        product_html  = PRODUCT_HTML.format(  

  16.            prodname=prodname, price=format_price(price))  

  17.    html = PAGE_HTML.format(name=username, products= product_html)  

  18.    return html  

上述代码是可以工作的,但是这里面有很多问题,因为 HTML 直接包含在我们的代码中,并且被拆分成很多部分,这样就失去了代码的逻辑性。另外,如果前端工程师需要就该 HTML 代码,他不得不去研究在 Python 里面这部分代码是如何工作的,假设 HTML 的代码有几百上千个,那基本上 HTML 代码是无法修改的。

模板

一个比较好的方法是通过 HTML 页面模板的方式实现我们需要的功能,这种情况下,多数的静态 HTML 代码包含在模板中,少量的动态数据通过特定格式插入到模板中,比如上述页面就可以转换成如下的模板文件:

  1. Welcome, {{user_name}}!

     
  2. Products:

     
    •  

    •    {% for product in product_list %}  

    •        

    • {{ product.name }}:  

    •        {{ product.price|format_price }}

    •  
    •    {% endfor %}  

    •  

在上面的模板中,我们主要是完成 HTML 的设计,少量的逻辑代码嵌入其中。

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

  1. def hello():  

  2.    print("Hello, world!")  

  3. hello()  

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

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

  1. Welcome, {{ user_name }}!

     

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

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

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

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

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

支持的语法

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

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

  1. Welcome, {{ user_name }}!

     

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

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

  1. dict["key"]  

  2. obj.attr  

  3. obj.method()  

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




    
  1. dict.key  

  2. obj.attr  

  3. obj.method  

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

  1. The price is: {{product.price}}, with a {{product.discount}}% discount.

     

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

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

  1. Short name: {{story.subject|slugify|lower}}

     

有些时候,我们还需要少量的逻辑代码,比如条件语句:

  1. {% if user.is_logged_in %}  

  2.    

    Welcome, {{ user.name }}!p>  

  3. {% endif %}  

以及循环语句:

  1. Products:

     
    •  

    • {% for product in product_list %}  

    •    

    • {{ product.name }}: {{ product.price|format_price }}

    •  
    • {% endfor %}  

    •  

最后,我们可能还需要对模板进行必要的注释:

  1. {# This is the best template ever! #}  

实现方法

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

  • 管理动态数据内容

  • 执行逻辑单元代码块

  • 实现点号数据访问和 filter 的执行

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

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

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

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

这里所描述的代码引擎实际上是 coverage.py 的一部分,主要用来产生 HTML 报告。在 coverage.py 里面,通过反复调用少量的模板生成很多类似的结构的文件。总的来说,如果把模板编译成 Python 代码,程序运行会更有效率。因为虽然编译需要很长时间,但在使用过程中只需要编译一次,而运行次数则不受限制,这样可以实现一次编译,多次运行,显著的提高了运行效率。

虽然把模板编译为可执行代码有点麻烦,但也不至于你想的那样糟糕。而且任何的码农后会觉得编写一个可以生成程序的程序更有意思。

我们的模板引擎实际上是一个代码生成技术的一个例子。代码生成通过各种灵活软件及编译器直接生成复杂的可执行代码。

如果每次编译模板只运行几次,或模板需要经常更改,那么推荐使用翻译模式,那样将会得到更好的效率。

编译到 Python

在开始之前,让我们先来看一下我们预期要达到的一个编译结果,首先再来回顾一下我们的模板:

  1. Welcome, {{user_name}}!

     
  2. Products:

     
    •  

    • {% for product in product_list %}  

    •    

    • {{ product.name }}:  

    •        {{ product.price|format_price }}

    •  
    • {% endfor %}  

    •  

我们的模板引擎会分析模板,并将结果转换为可执行的 Python 代码,这里的代码可能会有一些别扭,因为我们用了一些代码优化,以便可以得到更好的运行效率(下面代码为方便阅读已经修改):

  1. def render_function(context, do_dots):  

  2.    c_user_name = context['user_name']  

  3.    c_product_list = context['product_list']  

  4.    c_format_price = context['format_price']  

  5.    result = []  

  6.    append_result = result.append  

  7.    extend_result = result.extend  

  8.    to_str = str  

  9.    extend_result([  

  10.        '

    Welcome, '

    ,  

  11.        to_str(c_user_name),  

  12.        '!

    n

    Products:

    n
      n'  
  13.    ])  

  14.    for c_product in c_product_list:  

  15.        extend_result([  

  16.            'n    

  17. '
  18. ,  

  19.            to_str(do_dots(c_product, 'name')),  

  20.            ':n        ',  

  21.            to_str(c_format_price(do_dots(c_product, 'price'))),  

  22.            '

  23. n'  
  24.        ])  

  25.    append_result('nn')  

  26.    return ''.join(result)  

每一个模板都会被转换成一个 render_function 函数,函数接收一个名称为 context 的字典。函数内部首先对字典进行解包,然后将 context 转化为本地局部变量,因为转换之后可以更快的进行多次调用。所有本地局部变量冠以 c_ 前缀。

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

虽然这样的本地化的方式在 Python 中并不常用,但是因为节省了函数查找的时间,可以得到更好的执行效率。

  1. # The way we're used to seeing it:  

  2. result.append("hello")  

  3. # But this works the same:  

  4. append_result = result.append  

  5. append_result("hello")  

这是一个比较简单的优化技巧:通过非常规编程得到更好的运行效率,这些优化虽然不宜读,可能还有些复杂,但是却对程序运行的效率有明显改善。但即使如此,有些技巧也勿滥用,毕竟它影响了程序的可读性。

一旦我们定义好这些局部函数,我们就可以对他们进行调用,比如通过 append_result 或 extend_result 对 result 列表进行操作。

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

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

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

引擎的编写

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

Templite 类

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

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

  1. # Make a Templite object.  

  2. templite = Templite('''  

  3.    

    Hello {{name|upper}}!

     

  4.    {% for topic in topics %}  

  5.        

    You are interested in {{topic}}.

     

  6.    {% endfor %}  

  7.    ''',  

  8.    {'upper': str.upper},  

  9. )  

  10. # Later, use it to render some data.  

  11. text = templite.render({  

  12.    'name': "Ned",  

  13.    'topics': ['Python', 'Geometry', 'Juggling'],  

  14. })  

这里,我们在例化的时候已经将模板传入,之后我们就可以直接对模板进行一次编译,在之后就可以通过 render 方法对模板进行多次调用。

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

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

CodeBuilder

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

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

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

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

  1. class CodeBuilder(object):  

  2.    """Build source code conveniently."""  

  3.    def __init__(self, indent=0):  

  4.        self.code = []  

  5.        self.indent_level = indent  

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

  1. def add_line(self, line):  

  2.    """Add a line of source to the code.  

  3.    Indentation and new line will be added for you, don't provide them.  

  4.    """  

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

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

  1. INDENT_STEP = 4  

  2. def indent(self ):  

  3.    """Increase the current indent for following lines."""  

  4.    self.indent_level  = self.INDENT_STEP  

  5. def dedent(self):  

  6.    """Decrease the current indent for following lines."""  

  7.    self.indent_level -= self.INDENT_STEP  

add_section 通过另一个 CodeBuilder 管理,这里先预留一个位置,后面再继续完善, self.code 主要由代码字符列表构成,但同时也支持对其他代码块的引用。

  1. def add_section(self):  

  2.    """Add a secton, a sub-CodeBuilder."""  

  3.    section = CodeBuilder(self.indent_level)  

  4.    self.code.append(section)  

  5.    return section  

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

  1. def __str__(self):  

  2.    return ''.join(str(c) for c in self.code)  

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

  1. def get_globals(self):  

  2.    """Executer the code, and return a dict of globals if defnes."""  

  3.    # A check that caller really finished all the blocks  

  4.    assert self.indent_level == 0  

  5.    # Get the Python source as a single string  

  6.    python_source = str(self)  

  7.    # Execute the source, defining globals, and return them.  

  8.    global_namespace = {}  

  9.    exec(python_source, global_namespace)  

  10.    return global_namespace  

在这里面用到了 Python 一个非常有特色的功能, exec 函数,该函数可以将字符串作为代码执行,函数的第二个参数是一个用于收集代码定义全局变量的一个字典,比如:

  1. python_source = """  

  2. SEVENTEEN = 17  

  3. def three():  

  4.    return 3  

  5. """  

  6. global_namespace = {}  

  7. exec(python_source, global_namespace)  

  8. print(global_namespace['SEVENTEEN'], global_namespace['three'])  

输出结果:

  1. (17, <function three at 0x029FABB0>)  

  2. [Finished in 0.1s]  

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

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

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

Templite 类的实现

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

编译(解析 Compiling)

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

  1. def __init__(self, text, *contexts):  

  2.    """Construct a Templite with the given 'text'.  

  3.    'contexts' are dictionaries of values to future renderings.  

  4.    These are good for filters and global values.  

  5.    """  

  6.    super( Templite, self).__init__()  

  7.    self.context = {}  

  8.    for context in contexts:  

  9.        self.context.update(context)  

注意,我们使用 *contexts 作为一个参数, * 代表可以传入任意数量的参数,所有的参数都将打包在一个元组里面,元组名称为 contexts。这称之为参数解包,比如我们可以通过如下方式进行调用:

  1. t = Templite(template_text)  

  2. t = Templite(template_text, context1)  

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

内容参数作为一个元组传入,我们通过对元组进行遍历,对其依次进行处理,在构造器中我们声明了一个 self. context 的字典, python 中对重名情况直接使用最近的定义。

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

  1. self.all_vars = set()  

  2. self.loop_vars = set()  

之后我们会讲到如何去运用这些变量。首先,我们需要用 CodeBuilder 类去产生我们编译函数的定义:

  1. code = CodeBuilder()  

  2. code.add_line("def render_function(context, do_dots):")  

  3. code.indent()  

  4. vars_code = code.add_section()  

  5. code.add_line("result = []")  

  6. code.add_line("append_result = result.append")  

  7. code.add_line( "extend_result = result.extend")  

  8. code.add_line("to_str = str")  

这里,我们构造一个 CodeBuilder 类,添加函数名称为 render_function,以及函数的两个参数:数据字典 context 和实现点号属性获取的函数 do_dots

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

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

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

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

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

  1. buffered = []  

  2. def flush_output():  

  3.    """ Force 'buffered' to the code builder."""  

  4.    if len(buffered) == 1:  

  5.        code.add_line("append_result(%s)" % buffered[0])  

  6.     elif len(buffered) > 1:  

  7.        code.add_line("extend_result([%s])" % ", ".join(buffered))  

  8.    del buffered[:]  

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

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

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

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

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

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

  1. buffered.append("'hello'")  

后面会添加如下代码到 CodeBuilder

  1. append_result('hello')  

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

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

  1. ops_stack = []  

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

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

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

看起来是不是相当复杂?我们来简单解释一下:

re.split 函数主要通过正则表达式完成对字符串的分组,而我们的正则表达式内部也含有分组信息( ()),因此函数将返回对字符串分组后的结果,这里的正则主要匹配语法标签,所以最终字符串将在还有语法标签的地方被分割,并且相应的语法标签也会被返回。

正则表达式里的 (?s)  表示即使在一个新行也需要有一个点号(?),后面的分组有三种不同的选项: {{.*? 会匹配一个标签, {%.*?%} 会匹配一个语句表达式, {#.*?#} 会匹配一个注释。这几个选项里面,我们用 .*? 来匹配任意数目的任意字符,不过用了非贪婪匹配,因此它将只匹配最少数目的字符。

re.split 的输出结果是一个字符串列表,如果模板是如下的字符:

  1. Topics for {{name}}: {% for t in topics %}{{t}}, {% endfor %}

     

将会返回如下的结果

  1. [  

  2.    '

    Topics for '

    ,               # literal  

  3.    '{{name}}',                     # expression  

  4.    ': ',                           # literal  

  5.    '{% for t in topics %}',        # tag  

  6.    '',                             # literal (empty)  

  7.    '{{t}}',                        # expression  

  8.    ', ',                           # literal  

  9.    '{% endfor %}',                 # tag  

  10.    '

    '                          # literal  
  11. ]  

一旦将模板进行了分组,我们就可以对结果进行遍历,对每种不同的类型进行不同的处理。

比如对各种符号的编译可以采用如下的形式:

  1. for token in tokens:  

在遍历的时候,我们需要判断每个标志的类型,实际我们只需要判断前两个字符。而对于注释的标志处理最为简单,我们只需要简单的跳过即可:

  1. if token.startwith('{#'):  

  2.    # Comment: ignore it and move on.  

  3.    continue  

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

  1. elif token.startwith("{{"):  

  2.    # An expression to evalute.  

  3.    expr = self._expr_code (token[2:-2].strip())  

  4.    buffered.append("to_str(%s)" % expr)  

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

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

  1. elif token.startwith("{%"):  

  2.    # Action tag: split into words and parse futher  

  3.    flush_output()  

  4.    words = token[2:-2].strip().split()  

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

  1. if words[0] == 'if':  

  2.    # An if statement: evalute the expression to determine if.  

  3.    if len(words) != 2:  

  4.        self._syntax_error("Don't understand if", token)  

  5.    ops_stack.append('if')  

  6.    code.add_line("if %s:" % self._expr_code(words[1]))  

  7.    code.indent()  

这里 if 后面必须有一个表达式,因此 words 的长度应该为 2(译者:难道不会有空格??),如果长度不正确,那么将会产生一个语法错误。之后会对 if 语句进行压栈处理以便后面检测是否有相应的 endif  结束标签。 if 后面的判断语句通过 _expr_code 编译,并添加 if 代码后添加到结果,最后增加一级缩进。

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

  1. elif word[0] == 'for':  

  2.    # A loop: iterate over expression result.  

  3.    if len(words) != 4 or words[2] != 'in':  

  4.        self._syntax_error("Don't understand for", token)  

  5.    ops_stack.append('for')  

  6.    self._veriable(words[1], self.loop_vars)  

  7.    code.add_line(  

  8.            "for c_%s in %s:" % (  

  9.                words[1],  

  10.                self._expr_code(words[3]))  

  11.        )  

  12.    code.indent()  

这一步我们检查了模板的语法,并且将 for 标签压栈。 _variable 方法主要检测变量的语法,并将变量加入我们的变量集。我们通过这种方式来实现编译过程中变量的统计。后面我们会对函数做一个统计,并将变量集合添加在里面。为实现这一操作,我们需要将遇到的所有变量添加到 self.all_vars,而对于循环中定义的变量,需要添加到 self.loop_vars.

在这之后,我们添加了一个 for 代码段。而模板中的变量通过加 c_ 前缀被转化为 python 中的变量,这样可以防止模板中变量与之冲突。通过使用 _expr_code 将模板中的表达式编译成 Python 中的表达式。

最后我们还需要处理 end 标签;实际对 {% endif %} 和  {% endfor %} 来说都是一样的:主要完成对相应代码段的减少缩进功能。

  1. elif word[0].startwith('end'):  

  2.    #Endsomting. pop the ops stack.  

  3.    if len(words) != 1:  

  4.        self._syntax_error("Don't understand end", token)  

  5.    end_what = words[0][3:]  

  6.    if not ops_stack:  

  7.        self._syntax_error("Too many engs", token)  

  8.    start_what = ops_stack.pop()  

  9.    if start_what ~= end_what:  

  10.        self._syntax_error("Mismatched end tag", end_what)  

  11.    code.dedent()  

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

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

  1. else:  

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

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

  1. else:  

  2.    #literal content, if not empty, output it  

  3.    if token:  

  4.        buffered.append(repr(token))  

如果不使用 repr 方法,那么在编译的结果中就会变成:

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

相应的我们需要如下的形式:

  1. append_result('abc')  

repr 函数会自动给引用的文本添加引号,另外还会添加必要的转意符号:

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

另外我们首先检测了字符是否为空 if token:, 因为我们没必要将空字符也添加到输出。空的 tokens 一般出现在两个特殊的语法符号中间,这里的空字符检测可以避免向最终的结果添加 append_result("") 这样没有用的代码。

上面的代码基本完成了对模板中语法标签的遍历处理。当遍历结束时,模板中所有的代码都被处理。在最后,我们还需要进行一个检测:如果 ops_stack 非空,说明模板中有未闭合的标签。最后我们再将所有的结果写入编译结果。

  1. if ops_stack:  

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

  3. flush_output()  

还记得吗,我们在最开始创建了一个代码段。它的作用是为了将模板中的代码抽取并转换到 Python 本地变量。 现在我们对整个模板都已经遍历处理,我们也得到了模板中所有的变量,因此我们可以开始着手处理这些变量。

在这之前,我们来看看我们需要处理变量名。先看看我们之前定义的模板:

  1. Welcome, {{user_name}}!

     
  2. Products:

     
    •  

    • {% for product in product_list %}  

    •    

    • {{ product.name }}:  

    •        {{ product.price|format_price }}

    •  
    • {% endfor %}  

    •  

这里面有两个变量 user_name 和 product。这些变量在模板遍历后都会放到 all_vars 集合中。但是在这里我们只需要对 user_name  进行处理,因为 product 是在 for 循环中定义的。

all_vars 存储了模板中的所有变量,而 loop_vars 则存储了循环中的变量,因为循环中的变量会在循环的时候进行定义,因此我们这里只需要定义在 all_vars 却不在 loop_vars的变量:

  1. for var_name in self.all_vars - self.loop_vars:  

  2.    vars_code.add_line("c_%s = context[%r]" % (var_name, var_name))  

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

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

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

  2. code.dedent()  

到这里我们已经实现了对模板到 python 代码的编译,编译结果需要从代码生成器 CodeBuilder  中获得。可以通过 get_globals 方法直接返回。还记得吗,我们需要的代码只是一个函数(函数以 def render_function(): 开头), 因此编译结果是得到这样一个 render_function 函数而不是函数的执行结果。

get_globals 的返回结果是一个字典,我们从中取出 render_function 函数,并将它保存为 Templite 类的一个方法。

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

现在 self._render_function 已经是一个可以调用的 Python 函数,我们后面渲染模板的时候会用到这个函数。

表达式编译

到现在我们还不能看到实际的编译结果,因为有个一重要的方法 _expr_code 还没有实现。这个方法可以将模板中的表达式编译成 python 中的表达式。有时候模板中的表达式会比较简单,只是一个单独的名字,比如:

  1. {{ user_name }}  

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

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

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

  1. def _expr_code(self, expr):  

  2.    """Generate a Python expression for 'expr'."""  

函数内部首先考虑 | 分割,如果有 |,就按照 | 分割成多个表达式,然后对第一个元素进行递归处理:

  1. if "|" in expr:  

  2.    pipes = expr.split('|')  

  3.    code = self._expr_code(pipes[0])  

  4.    for func in pipes[1:]:  

  5.         self._variable(func, self.all_vars)  

  6.        code = "c_%s(%s)" % (func, code)  

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

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

  1. elif "." in expr:  

  2.    dots = expr.split('.')  

  3.    code = self._expr_code(dots[0])  

  4.    args = ", ".join(repr(d) for d in dots[1:])  

  5.    code = "do_dots(%s, %s)" % (code, args)  

为了理解点号是怎么编译的,我们来回顾一下,在模板中 x.y 可能代表 x['y']x.y 甚至 x.y()。这种不确定性意味着我们需要在执行的过程中依次对其进行尝试,而不能再编译时就去定义。因此我们把这部分编译为一个函数调用 do_dots(x, 'y', 'z'),这个函数将会对各种情形进行遍历并返回最终的结果值。

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

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

  1. else:  

  2.    self._variable(expr, self.all_vars)  

  3.    code = "c_%s" % expr  

  4. return code  

辅助函数

在编译过程中,我们还用到了几个辅助函数。 _syntax_error 函数将错误输出并抛出异常:

  1. def _syntax_error(self, msg, thing):  

  2.    """Raise a syntax error using 'msg', and showing 'thing'. """  

  3.    raise TempliteSyntaxError("%s: %r" % (msg, thing))  

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

  1. def _variable(self, name, vars_set):  

  2.    """Track that `name` is used as a variable.  

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

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

  5.    """  

  6.    if not re.match(r"[_a-zA-Z][_a-zA-Z0-9]*$", name):  

  7.        self._syntax_error("Not a valid name", name)  

  8.    vars_set.add(name)  

到这里,编译代码已经完成!

渲染

剩下的工作就是编写渲染代码。既然我们已经将模板编译为 Python 代码,这里工作量就大大减少了。这部分主要准备数据字典,并调用编译的 python 代码即可:

  1. def render(self, context=None):  

  2.    """Render this template by applying it to `context`.  

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

  4.    """  

  5.    # Make the complete context we'll use.  

  6.    render_context = dict(self.context)  

  7.    if context:  

  8.        render_context.update(context)  

  9.    return self._render_function(render_context, self._do_dots)  

记住,在我们例化 Templite 的时候就已经初始化了一个数据字典。这里我们将他复制,并将其与新的字典进行合并。拷贝的目的在于使各次的渲染数据独立,而合并则可以将字典简化为一个,有利于初始数据和新数据的统一。

另外,写入到 render 的数据字典可能覆盖例化 Templite 时的初始值,但实际上例化时的字典有全局的一些东西,比如过滤器定义或者常量定义,而传入到 render 中的数据一般是特殊数据。

最后我们只需要调用 _render_function 方法,第一个参数是数据字典,第二个参数是 _do_dots 的实现函数,是每次都相同的自定义函数,实现如下:

  1. def _do_dots(self, value, *dots):  

  2.    """Evalute dotted expressions at runtime"""  

  3.    for dot in dots:  

  4.        try:  

  5.            value = getattr(value, dot)  

  6.        except AttributeError:  

  7.            value = value[dot]  

  8.        if callable(value):  

  9.            value = value()  

  10.    return value  

在编译过程中,模板中像 x.y.z 的代码会被编译为 ``do_dots(x, 'y', 'z'). 在函数中会对各个名字进行遍历,每一次都会先尝试获取属性值,如果失败,在尝试作为字典值获取。这样使得模板语言更加灵活。在每次遍历时还会检测结果是不是可以调用的函数,如果可以调用就会对函数进行调用,并返回结果。

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

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

测试

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

未涉及的地方

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

  • 模板继承和包含

  • 自定义标签

  • 自动转义

  • 参数过滤器

  • 例如 else 和 elif 的复杂逻辑

  • 多于一个变量的循环

  • 空白符控制

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

总结

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


题图:pexels,CC0 授权。

点击阅读原文,查看更多 Python 教程和资源。


今天看啥 - 高品质阅读平台
本文地址:http://www.jintiankansha.me/t/Ir8SwPPigv
Python社区是高质量的Python/Django开发社区
本文地址:http://www.python88.com/topic/2713
 
688 次点击