Template Engine 原始碼閱讀

語言: CN / TW / HK

大家好,我是肖恩,原始碼解析每週見

500 Lines or Less 是一個開源專案,也是一本同名書。程式碼+文件,相得益彰。其中部分內容還有中文翻譯。這個專案由多個獨立的章節組成,每個章節由領域大牛試圖用 500 行或者更少(500 or less)的程式碼,讓讀者瞭解一個功能的實現原理。

模版引擎(Template Engine) 是其中的一個子專案,介紹 python 的模版引擎實現原理。專案僅僅 250 行左右,功能卻非常全面。 Templatecoverage.py 的實現同源,它們是同一個作者。後者廣泛用於測試框架 pytest/unittest 並生成單元測試覆蓋率報告。

本文目錄:

  • 專案結構

  • 模版 api

  • CodeBuilder 程式碼構造器

  • Templite 模版渲染器

  • 單元測試用例

  • 小結和小技巧

專案結構

500lines 專案有些年頭了,我們取 master 版本即可。獲取程式碼後進入 template-engine 目錄,專案結構如下:

功能
CodeBuilder 程式碼輔助類,幫助生成縮排合法的 python 程式碼
Templite 模版引擎實現,實現模版物件及其渲染方法等
TempliteTest 模版引擎的測試類,包括各種模版語法的測試

閱讀本文之前,強烈建議先閱讀專案中的 md 文件或參考連結中的中文翻譯,對理解專案原始碼非常有幫助。

模版 api

Templite 模版使用方法如下:

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

上述程式碼主要功能:

  • 建立一個 Templite 物件, 它有兩個構造引數。第一個引數是模版字串,第二個引數是變數物件。

  • {{}}
    {% for .. %}
    |
    
  • 使用 Templite 物件的 render 方法將模版渲染成 text 文字。render 同時還可以繼續追加變數物件。

執行後輸出:

<h1>Hello NED!</h1>

<p>You are interested in Python.</p>

<p>You are interested in Geometry.</p>

<p>You are interested in Juggling.</p>

通過示例演示,我們可以知道,模版引擎把模版中變數變成值後,格式化輸出純文字內容,實現靜態的 html。利用模版引擎,可以讓我們的 web 服務動態化。對網站來說格式是相對固定的,資料是千變萬化的。模版可以省去很多繁瑣的編碼實現。

CodeBuilder 程式碼構造器

CodeBuilder 類顧名思義,幫助構建 python 程式碼。在開始分析這個類之前,我們再回顧一下 python 語法:

1 class Person(object):
2
3 def __init__(self):
4 pass
5
6 def hello():
7 print("hello world")

這是一個簡單的 Person 物件,總共 7 行程式碼組成,包含建構函式 init 和 hello 方法。第 3 行到第 7 行是 Person 物件的內容段,相對於第 1 行應該縮排一下;第 4 行是 init 函式的內容段,應該相對第 3 行進行縮排。同時按照 PEP8 規範,推薦函式之間空行,每個縮排採用 4 個空格。

瞭解 python 的語法規則後,我們繼續 CodeBuilder 的實現:

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

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

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"])

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
  • 程式碼由陣列構成,陣列元素可以是 line,也可以是 section(CodeBuilder)

  • 每個 line 可以是一個三元組: 縮排空格,程式碼和換行符構成

  • 每個 section 多個 line 可以構成,每個 section 處於相同的縮排級別

這樣形成了 CodeBuilder 的樹狀結構,可以通過遞迴的方式形成程式碼的字串:

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

當然僅僅形成程式碼的原始檔還是不夠,我們需要編譯它形成可以執行的函式:

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

使用 exec 動態的將字串原始碼編譯到 global_namespace 這個字典中。我們可以通過下面示例理解 exec 函式:

>>> s = '''
... def hello(name):
... print("hello",name)
... '''

>>> d = {}
>>> exec(s,d)
  • 定義一個字串 s,字串裡是一個 hello 函式文字

  • 定義一個字典 d

  • 將 s 編譯到 d,這樣 d 中就含有一個名為 hello 的函式

可以檢視 hello 函式和執行 hello 函式:

>>> d['hello']
<function hello at 0x7fc3880b1af0>
>>> d['hello']("game404")
hello game404

我們通過 CodeBuilder 獲得在執行期,動態建立可執行的函式的能力。

Templite 模版渲染器

Templite 類的註釋,介紹了 templite 支援的 4 種模版語法: 變數,迴圈,邏輯分支和註釋:

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

這 4 個語法也是程式語言的基礎指令,每門程式語言都包含這幾個語法的解析。模版引擎 cool 的地方就在這裡,我們用一門程式語言創造了一門新的語言。

Templite 類主要包括兩個方法: init 方法和 render 方法。結合 CodeBuilder 的實現,我們可以合理猜測 init 方法主要是生成原始碼,render 方法是呼叫原始碼裡的函式進行渲染。

我們先看 init 方法:

    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)
  • init 方法構造一個模版物件,使用了 text 文字引數和全域性的 contexts 上下文

  • contexts 推薦設定一些過濾器和全域性變數

接下來構建一個 CodeBuilder 生成一個基礎的 render_function 函式, 大概結構如下:

 # 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 = []")

...

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()
  • render_function 函式包括 2 個引數 context 和 do_dots

  • render_function 返回一個由 result 陣列拼接而成的字串

  • render_function 將 context 解析成內部變數,用於格式化輸出

其中的重點,就是如何把 text 文字解析成函式的行。 也就是上述程式碼中省略的部分。首先我們注意到模版語法中的 token 包括:

  • {{}}
  • {##}
  • {%%}

可以使用正則 r"(?s)({{.*?}}|{%.*?%}|{#.*?#})" 將文字分割成不同的段。

每個 token 都是兩兩匹配的,我們可以用一個棧 ops_stack 來處理(類似 json 語法的 {}[] )。遇到的 token 入棧,再遇到相同的 token 出棧。

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('{%'):
...
else:
# Literal content. If it isn't empty, output it.
if token:
buffered.append(repr(token))

{{name|upper}} 語法會去掉首位的 token 符號後變成 expr 表示式。表示式包括四種類型:

  • | 這種 fitler 語法
  • . 這種屬性取值
  • 變數取值

  • 直接輸出原文

對於每個表示式,需要繼續處理,查詢到變數名稱:

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

合法的變數名稱,會記錄下來到 vars_set 中,執行時候再用 context 種的變數值進行格式輸出:

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 語法

複雜一些的是迴圈和邏輯分支, 也就是 {% token 構成的模版內容:

  • ifendif
  • forendfor

我們看看 if 分支的解析:

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].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()
  • 碰到 if 進行入棧

  • 新增程式碼行 if exp:
  • 增加縮排

  • if 中的表示式輸出,臨時新增到 buffered 中

  • 遇到 end 進行出棧

  • 減少縮排

for 迴圈會比 if 複雜一點,但是原理是一樣的,就不再贅述。這樣通過 init 方法,我們大概可以得到這樣一個模版函式:

def render_function(context, do_dots):
c_upper = context['upper']
c_topics = context['topics']
c_name = context['name']
result = []
append_result = result.append
extend_result = result.extend
to_str = str
extend_result(['\n <h1>Hello ', to_str(c_upper(c_name)), '!</h1>\n '])
for c_topic in c_topics:
extend_result(['\n <p>You are interested in ', to_str(c_topic), '.</p>\n '])
append_result('\n ')
return ''.join(result)

接下來繼續檢視 render 函式:

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)
  • render 使用 context,這裡的 context 和建構函式 init 不同,僅僅用於本次渲染

  • render 呼叫模版生成的 render_function 內部函式,並使用 do_dots 作為變數獲取方法

do_dots 方法其實沒有什麼特別,就是從 context 中獲取變數的值,如果變數是一個可以執行的函式就執行這個函式得到值:

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

單元測試用例

單元測試用例非常詳盡,我們重點看看關於 for 迴圈部分的測試用例。

def test_loops(self):
# Loops work like in Django.
nums = [1,2,3,4]
self.try_render(
"Look: {% for n in nums %}{{n}}, {% endfor %}done.",
locals(),
"Look: 1, 2, 3, 4, done."
)
# Loop iterables can be filtered.
def rev(l):
"""Return the reverse of `l`."""
l = l[:]
l.reverse()
return l

self.try_render(
"Look: {% for n in nums|rev %}{{n}}, {% endfor %}done.",
locals(),
"Look: 4, 3, 2, 1, done."
)
  • 將函式區域性變數迴圈列印輸出

  • 對於集合變數還可以進行鏈式呼叫 nums|rev , rev 是臨時定義的一個反轉函式
def test_nested_loops(self):
self.try_render(
"@"
"{% for n in nums %}"
"{% for a in abc %}{{a}}{{n}}{% endfor %}"
"{% endfor %}"
"!",
{'nums': [0,1,2], 'abc': ['a', 'b', 'c']},
"@a0b0c0a1b1c1a2b2c2!"
)
  • for 迴圈可以進行巢狀

通過單元測試用例,我們可以快速瞭解 templates 的所有功能。

小結

本文我們一起學習瞭如何從零開始構造一個 python 模版引擎,其主要原理是先定義模版引擎語法,然後使用正則表示式將模版解析成 python 程式碼行,再通過 exec 編譯成可執行函式, 最後傳入引數進行渲染執行。

作為延升話題,python 的直譯器也可以理解為一種模版引擎,我們可以使用 python 實現一個 python 的直譯器,pypy 就是其中的佼佼者。當然 pypy 程式碼量非常大,難以上手。好在 500lines 中也有一個原理實現專案 Byterun 以後我們一起學習它,敬請期待。

小技巧

函式中可以將全域性變數,重新命名為區域性變數,提高迴圈執行的效率:

code.add_line("append_result = result.append")
code.add_line("extend_result = result.extend")
code.add_line("to_str = str")

參考連結

  • http://docs.python.org/3/library/functions.html#exec

  • http://github.com/aosabook/500lines/blob/master/README.md

  • http://github.com/HT524/500LineorLess_CN

  • http://www.jianshu.com/p/d6551dfacd58