1.2 实验内容
模版引擎使得用户界面能够与业务数据分离,前端与后端分离,它通常用于渲染页面文件。本课程将使用 Python 实现一个具备基础功能的模板引擎。
1.3 实验知识点
本课程项目完成过程中,我们将学习:
- 实现模版引擎的原理与方法
- 如何编写程序生成代码
先从一个简单的例子开始,我们想要生成以下 HTML 文本:
然这段代码奏效了,但是看上去有点混乱。静态文本被分成了 PAGE_HTML 与 PRODUCT_HTML两部分,而且动态数据格式化的细节操作都在 Python 代码中,使得前端设计师不得不去修改 Python 文件。随着代码量的增多,这段代码也会渐渐变得难以维护。
2.2 模板使用的语法
模板引擎因其支持的语法而异,本课程中使用的引擎语法基于 Django - 一个非常流行的web框架。
代入数据使用双花括号:
2.3 引擎的实现方法
大方向上,模板的处理流程可以分为两部分:解析阶段与渲染阶段。
渲染模板需要考虑以下几方面:
- 管理数据来源(即上下文环境)
- 处理逻辑(条件判断、循环)的部分
- 实现点取得成员属性或者键值的功能、实现过滤器调用
问题的关键在于从解析阶段到渲染阶段是如何过渡的。解析得到了什么?渲染又是在渲染什么?解析阶段可能有两种不同的做法:解释或者是编译,这正对应了我们的程序语言的实现方式。
每一个模版都会被转换为 render_function 函数,其中 context 上下文环境存储导入的数据词典。 do_dots 存储用来取得对象属性或者词典键值的函数。
我们从头开始分析这段代码,最开始是对输入的数据词典进行解包,得到的每个变量都使用 c_作为前缀。先使用队列来存储结果, append 与 extend 可能会在在代码中多次用到,所以使用append_result 与 extend_result 来引用它们,这样会比平时直接使用 append 少一次检索的开销。之后就是使用 append_result 与 extend_result 把结果串起来,其中需要替换的部分就用输入数据替换,最后把队列合成一个字符串作为结果文本返回。
Templite 类会在之后实现,我们需要先完成代码构建器的编写。
四、代码构建器
代码构建器是为了方便 Templite 生成代码而编写的小工具,它的工作主要有添加代码、控制缩进、返回完整的代码字符串等。
我们创建 CodeBuilder 类作为代码构建器。
__str__ 返回生成的代码字符串,遍历 code 的内容时如果遇到 CodeBuilder 对象,则会递归调用该对象的 __str__ 方法。
五、实现 Templite 类
Templite 类的代码分为编译与渲染两个阶段。
5.1 编译
首先保存上下文:
创建 CodeBuilder 对象,开始往里面添加代码。
为什么提取变量的代码要在之后实现呢?因为输入的变量不一定全都用得上,我们只提取要用的部分,这得通过分析完模板才能确定下来。
为什么添这些代码?可以参照之前的那个例子:
那么正则后会得到:
if token.startswith('{#'):
continue
处理数据替换 {{ ... }}
我们将花括号切掉,然后调用 _expr_code() 得到结果, _expr_code() 的作用是将模板内的表达式转换成 Python 表达式,它会在之后实现:
这里的 if 只能支持一个表达式,所以如果 words 大于2就直接报错。我们往栈中弹入一个 if标签,直到找到一个 endif 标签才弹出。 _syntax_error 是一个报语法错误异常的函数,它会在之后实现。注意 words[1] 同样需要利用 _expr_code 将其从模板表达式转换成 Python 表达式。
处理 for 标签:
如果弹出的不是以上任意一种则报错:
else:
self._syntax_error("Don't understand tag", words[0])
处理文本
文本追加进 buffered 就行。
可以看到模板中存在三个变量 user_name 、 product_list 与 product , all_vars 会包含以上所有的变量名, loop_vars 只会包含 product 。所以我们根据它们的差集提取数据, vars_code 就是之前 add_section 得到的 CodeBuilder 对象:
def _expr_code(self, expr):
"""根据`expr`生成Python表达式."""
首先需要考虑模板表达式中有没有管道符号 | ,如果有则需要将表达式拆解放入队列,第一个管道前的表达式就是数据源,我们递归调用 _expr_code 得到数据源的 Python 表达式。
实现 _variable 函数帮助我们将变量存入指定的变量集中,同时帮我们验证变量名的有效性:
def _variable(self, name, vars_set):
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)
5.2 渲染
渲染阶段几乎没什么工作,只需要实现 render 与 _do_dots 就可以了。
实现 render 几乎就是把 _render_function 封装一下
六、完整代码
完整代码如下:
"""A simple Python template renderer, for a nano-subset of Django syntax."""
# Coincidentally named the same as http://code.activestate.com/recipes/496702/
import re
class TempliteSyntaxError(ValueError):
"""Raised when a template has a syntax error."""
pass
class CodeBuilder(object):
"""Build source code conveniently."""
def __init__(self, indent=0):
self.code = []
self.indent_level = indent
def __str__(self):
return "".join(str(c) for c in self.code)
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, " "])
def add_section(self):
"""Add a section, a sub-CodeBuilder."""
section = CodeBuilder(self.indent_level)
self.code.append(section)
return section
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
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
class Templite(object):
"""A simple template renderer, for a nano-subset of Django syntax.
Supported constructs are extended variable access::
{{var.modifer.modifier|filter|filter}}
loops::
{% for var in list %}...{% endfor %}
and ifs::
{% if var %}...{% endif %}
Comments are within curly-hash markers::
{# This will be ignored #}
Construct a Templite with the template text, then use `render` against a
dictionary context to create a finished string::
templite = Templite('''
<h1>Hello {{name|upper}}!</h1>
{% for topic in topics %}
<p>You are interested in {{topic}}.</p>
{% endif %}
''',
{'upper': str.upper},
)
text = templite.render({
'name': "Ned",
'topics': ['Python', 'Geometry', 'Juggling'],
})
"""
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)
self.all_vars = set()
self.loop_vars = set()
# We construct a function in source form, then compile it and hold onto
# it, and execute it to render the template.
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")
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[:]
ops_stack = []
# Split the text to form a list of tokens.
tokens = re.split(r"(?s)({{.*?}}|{%.*?%}|{#.*?#})", text)
for token in tokens:
if token.startswith('{#'):
# Comment: ignore it and move on.
continue
elif token.startswith('{{'):
# An expression to evaluate.
expr = self._expr_code(token[2:-2].strip())
buffered.append("to_str(%s)" % expr)
elif token.startswith('{%'):
# Action tag: split into words and parse further.
flush_output()
words = token[2:-2].strip().split()
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()
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()
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()
else:
self._syntax_error("Don't understand tag", words[0])
else:
# Literal content. If it isn't empty, output it.
if token:
buffered.append(repr(token))
if ops_stack:
self._syntax_error("Unmatched action tag", ops_stack[-1])
flush_output()
for var_name in self.all_vars - self.loop_vars:
vars_code.add_line("c_%s = context[%r]" % (var_name, var_name))
code.add_line("return ''.join(result)")
code.dedent()
self._render_function = code.get_globals()['render_function']
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)
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)
else:
self._variable(expr, self.all_vars)
code = "c_%s" % expr
return code
def _syntax_error(self, msg, thing):
"""Raise a syntax error using `msg`, and showing `thing`."""
raise TempliteSyntaxError("%s: %r" % (msg, thing))
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)
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)
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
使用以下代码看一下效果:
from templite import Templite
from collections import namedtuple
template_text = """
<p>Welcome, {{user_name}}!</p>
<p>Products:</p>
<ul>
{% for product in product_list %}
<li>{{ product.name }}:
{{ product.price|format_price}}</li>
{% endfor %}
</ul>
"""
Product = namedtuple("Product",["name", "price"])
product_list = [Product("Apple", 1), Product("Fig", 1.5), Product("Pomegranate", 3.25)]
def format_price(price):
return "$%.2f" % price
t = Templite(template_text, {"user_name":"Charlie", "product_list":product_list}, {"format_price":format_price})
运行结果:
七、测试
我们的测试文件覆盖了所有的行为与条件边界,它甚至比模版引擎的代码还长。但这在实际生产中也是很典型很常见的,说明代码有被好好测试过。这里给出测试文件。
wget http://labfile.oss.aliyuncs.com/courses/583/test_templite.py
测试命令:
python -m unittest test_templite
测试结果:
进群:125240963 即可获取数十套PDF哦!