原文請見:http://aosabook.org/en/500L/a-template-engine.html
程式碼請見:https://github.com/aosabook/500lines/tree/master/template-engine
引言
大多數程式包含大量的邏輯,以及少量文字資料。程式語言被設計成適合這種型別的程式設計。但是一些程式設計任務只涉及一點邏輯,以及大量的文字資料。 對於這些任務,我們希望有一個更適合這些問題的工具。模板引擎就是這樣一種工具。在本章中,我們將構建一個簡單的模板引擎。
最常見的一個以文字為主的任務是在 web 應用程式。任何 web 應用程式的一個重要工序是生成用於瀏覽器顯示的 HTML。 很少有 HTML 頁面是完全靜態的:它們至少包含少量的動態資料,比如使用者名稱。通常,它們包含大量的動態資料:產品列表、好友的新聞更新等等。
與此同時,每個HTML頁面都包含大量的靜態文字。這些頁面很大,包含成千上萬個位元組的文字。 web 應用程式開發人員有一個問題要解決:如何最好地生成包含靜態和動態資料混合的大段字串?另一個問題是: 靜態文字實際上是由團隊的另一個成員、前端設計人員編寫的 HTML 標記,他們希望能夠以熟悉的方式使用它。
為了便於說明,假設我們想要生成這個 HTML:
<p>Welcome, Charlie!</p>
<p>Products:</p>
<ul>
<li>Apple: $1.00</li>
<li>Fig: $1.50</li>
<li>Pomegranate: $3.25</li>
</ul>
複製程式碼
這裡,使用者的名字將是動態的,就像產品的名稱和價格一樣。甚至產品的數量也不是固定不變的:有時可能會有更多或更少的產品展示出來。
構造這個 HTML 的一種方法是在我們的程式碼中將字串常量們合併到一起來生成頁面。動態資料將插入以替換某些字串。我們的一些動態資料是重複的,就像我們的產品列表一樣。 這意味著我們將會有大量重複的 HTML,因此這些內容必須單獨處理,並與頁面的其他部分合並。
比如,我們的 demo 頁面像這樣:
# The main HTML for the 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 是嵌入在我們的程式碼中的多個字串常量。頁面的邏輯很難看到,因為靜態文字被拆分為獨立的部分。如何格式化資料的細節隱藏在 Python 程式碼中。為了修改 HTML 頁面,我們的前端設計人員需要能夠編輯 Python 程式碼。想象一下,如果頁面是10(或者100)倍的複雜,程式碼會是什麼樣子。它很快就會變得無法維護。
模板
生成 HTML 頁面的更好方法是使用模板。HTML 頁面是作為模板編寫的,這意味著該檔案主要是靜態的 HTML,其中嵌入了使用特殊符號標記的動態片段。我們的 demo 頁面模板可以像這樣:
<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 程式碼,HTML 嵌入在 Python 邏輯中。這裡我們的程式主要是靜態 HTML 標記。
要在我們的程式中使用 HTML 模板,我們需要一個模板引擎:一個使用靜態模板來描述頁面的結構和靜態內容的函式,以及提供動態資料插入模板的動態上下文。模板引擎將模板和上下文結合起來生成完整的 HTML 字串。模板引擎的工作是解釋模板,用真實資料替換動態片段。
支援的語法
模板引擎在它們支援的語法中有所不同。我們的模板語法基於 Django,一個流行的 web 框架。既然我們在 Python 中實現了我們的引擎,那麼一些 Python 概念將出現在我們的語法中。在我們的 demo 示例中,我們已經看到了這一章的一些語法,下面是我們將要實現的所有語法:
使用雙花括號插入上下文中的資料:
<p>Welcome, {{user_name}}!</p>
複製程式碼
當模板被呈現時,模板中可用的資料將提供給上下文。稍後將進行更詳細的討論。
模板引擎通常使用簡化的、輕鬆的語法來訪問資料中的元素。在 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>
複製程式碼
您可以使用過濾器函式來修改值,通過管道字元呼叫:
<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! #}
複製程式碼
實現方法
總的來說,模板引擎有兩個主要的工作:解析模板,渲染模板。
渲染模板具體涉及:
- 管理動態上下文,資料的來源
- 執行邏輯元素
- 實現點訪問和篩選執行
從解析階段傳遞什麼到呈現階段是關鍵。
解析可以提供什麼?有兩種選擇:我們稱它們為解釋和編譯。
在解釋模型中,解析生成一個表示模板結構的資料結構。呈現階段將根據所找到的指令對資料結構進行處理,並將結果文字組合起來。Django 模板引擎使用這種方法。
在編譯模型中,解析生成某種形式的可直接執行的程式碼。呈現階段執行該程式碼,生成結果。Jinja2 和 Mako 是使用編譯方法的模板引擎的兩個例子。
我們的引擎的實現使用編譯模型:我們將模板編譯成 Python 程式碼。當它執行時,組裝成結果。 模板被編譯成 Python 程式碼,程式將執行得更快,因為即使編譯過程稍微複雜一些,但它只需要執行一次。 將模板編譯為 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)
複製程式碼
幾點說明:
- 通過快取了一些函式到區域性變數來對程式碼進行了優化(比如 append_result = result.append 等)
- 點符號操作被轉化成了
do_dots
函式 - 邏輯程式碼被轉化成了 python 程式碼和迴圈
編寫模板引擎
模板類
可以使用模板的文字構造了 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物件中,當模板稍後呈現時將可用。這些都有利於定義我們想要在任何地方都可用的函式或常量,比如上一個例子中的upper。
在討論實現 Templite 之前,讓我們先搞定一個工具類: CodeBuilder
CodeBuilder
引擎中的大部分工作是解析模板並生成 Python 程式碼。為了幫助生成 Python,我們建立了 CodeBuilder 類,它幫我們新增程式碼行,管理縮排,最後從編譯的 Python 中給出結果。
CodeBuilder 物件儲存了一個字串列表,這些字串將一起作為最終的 Python 程式碼。它需要的另一個狀態是當前的縮排級別:
class CodeBuilder(object):
"""Build source code conveniently."""
def __init__(self, indent=0):
self.code = []
self.indent_level = indent
複製程式碼
CodeBuilder 做的事並不多。add_line新增了一個新的程式碼行,它會自動將文字縮排到當前的縮排級別,並提供一條新行:
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"])
複製程式碼
indent
和 dedent
提高或減少縮排級別:
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
複製程式碼
add_section
由另一個 CodeBuilder
物件管理。這讓我們可以在程式碼中預留一個位置,隨後再新增文字。self.code 列表主要是字串列表,但也會保留對這些 section 的引用:
def add_section(self):
"""Add a section, a sub-CodeBuilder."""
section = CodeBuilder(self.indent_level)
self.code.append(section)
return section
複製程式碼
__str__
使用所有程式碼生成一個字串,將 self.code 中的所有字串連線在一起。注意,因為 self.code 可以包含 sections,這可能會遞迴呼叫其他 CodeBuilder
物件:
def __str__(self):
return "".join(str(c) for c in self.code)
複製程式碼
get_globals
通過執行程式碼生成最終值。他將物件字串化,然後執行,並返回結果值:
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
複製程式碼
最後一個方法利用了 Python 的一些奇異特性。exec
函式執行包含 Python 程式碼的字串。exec
的第二個引數是一個字典,它將收集由程式碼定義的全域性變數。舉個例子,如果我們這樣做:
python_source = """\
SEVENTEEN = 17
def three():
return 3
"""
global_namespace = {}
exec(python_source, global_namespace)
複製程式碼
則 global_namespace['SEVENTEEN']
是 17,global_namespace['three']
返回函式 three
。
雖然我們只使用 CodeBuilder
來生成一個函式,但是這裡沒有限制它只能做這些。這使得類更易於實現,也更容易理解。
CodeBuilder
允許我們建立一大塊 Python 原始碼,並且不需要了解我們的模板引擎相關知識。get_globals
會返回一個字典,使程式碼更加模組化,因為它不需要知道我們定義的函式的名稱。無論我們在 Python 原始碼中定義了什麼函式名,我們都可以從 get_globals
返回的物件中檢索該名稱。
現在,我們可以進入 Templite
類本身的實現,看看 CodeBuilder
是如何使用的以及在哪裡使用。
實現模板類
編譯
將模板編譯成 Python 函式的所有工作都發生在 Templite 建構函式中。首先,傳入的上下文被儲存:
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)
複製程式碼
這裡,使用了 python 的可變引數,可以傳入多個上下文,且後面傳入的會覆蓋前面傳入的。
我們用集合 all_vars
來記錄模板中用到的變數,用 loop_vars
記錄模板迴圈體中用到的變數:
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
物件,並開始編寫程式碼行。我們的 Python 函式將被稱為 render_function
,它將接受兩個引數:上下文是它應該使用的資料字典,而 do_dots
是實現點屬性訪問的函式。
我們建立一個名為 vars_code
的部分。稍後我們將把變數提取行寫到這一部分中。vars_code
物件讓我們在函式中儲存一個位置,當我們有需要的資訊時,它可以被填充。
然後快取了 list
的兩個方法及 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[:]
複製程式碼
當我們建立大量程式碼到編譯函式中時,我們需要將它們轉換為 append
函式呼叫。我們希望將重複的 append
呼叫合併到一個 extend
呼叫中,這是一個優化點。為了使這成為可能,我們緩衝了這些塊。
緩衝列表包含尚未寫入到我們的函式原始碼的字串。在我們的模板編譯過程中,我們將附加字串緩衝,當我們到達控制流點時,比如 if 語句,或迴圈的開始或結束時,將它們重新整理到函式程式碼。
flush_output
函式是一個閉包。這簡化了我們對函式的呼叫:我們不必告訴 flush_output
要重新整理什麼緩衝區,或者在哪裡重新整理它;它清楚地知道所有這些。
如果只緩衝了一個字串,則使用 append_result
將其新增到結果中。如果有多個緩衝,那麼將使用 extend_result
將它們新增到結果中。
回到我們的 Templite 類。在解析控制結構時,我們希望檢查它們語法是否正確。需要用到棧結構 ops_stack
:
ops_stack = []
複製程式碼
例如,當我們遇到控制語句 \{\% if \%\}
,我們入棧 if
。當我們遇到 \{\% endif \%\}
時,出棧並檢查出棧元素是否為if
。
現在真正的解析開始了。我們使用正規表示式將模板文字拆分為多個 token。這是我們的正規表示式:
tokens = re.split(r"(?s)({{.*?}}|{%.*?%}|{#.*?#})", text)
複製程式碼
split
函式將使用正規表示式拆分一個字串。我們的模式是圓括號,因此匹配將用於分割字串,也將作為分隔列表中的片段返回。
(?s)
為單行模式,意味著一個點應該匹配換行符。接下來是匹配表示式/控制結構/註釋,都為非貪婪匹配。
拆分的結果是字串列表。例如,該模板文字:
<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
]
複製程式碼
將文字拆分為這樣的 tokens 之後,我們可以對這些 tokens 進行迴圈,並依次處理它們。根據他們的型別劃分,我們可以分別處理每種型別。 編譯程式碼是對這些 tokens 的迴圈:
for token in tokens:
# 註釋直接忽略
if token.startswith('{#'):
# Comment: ignore it and move on.
continue
# 表示式:提取出內容交給 _expr_code 進行處理,然後生成一行程式碼
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語句只能有兩個單詞
if len(words) != 2:
self._syntax_error("Don't understand if", token)
# if 入棧
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)
# for 入棧
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:]
# end 語句多了
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))
複製程式碼
有幾點需要注意:
- 使用
repr
來給文字加上引號,否則生成的程式碼會像這樣:
extend_result([
<h1>Hello , to_str(c_upper(c_name)), !</h1>
])
複製程式碼
- 使用
if token:
來去掉空字串,避免生成不必要的空行程式碼
迴圈結束後,需要檢查 ops_stack
是否為空,不為空說明控制語句格式有問題:
if ops_stack:
self._syntax_error("Unmatched action tag", ops_stack[-1])
flush_output()
複製程式碼
前面我們通過 vars_code = code.add_section()
建立了一個 section,它的作用是將傳入的上下文解構為渲染函式的區域性變數。
迴圈完後,我們收集到了所有的變數,現在可以新增這一部分的程式碼了,以下面的模板為例:
<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_name
product_list
product
。 all_vars
集合會包含它們,因為它們被用在表示式和控制語句之中。
但是,最後只有 user_name
product_list
會被解構成區域性變數,因為 product
是迴圈體內的區域性變數:
for var_name in self.all_vars - self.loop_vars:
vars_code.add_line("c_%s = context[%r]" % (var_name, var_name))
複製程式碼
到此,我們程式碼就都加入到 result
中了,最後將他們連線成字串就大功告成了:
code.add_line("return ''.join(result)")
code.dedent()
複製程式碼
通過 get_globals
我們可以得到所建立的渲染函式,並將它儲存到 _render_function
上:
self._render_function = code.get_globals()['render_function']
複製程式碼
表示式
現在讓我們來仔細的分析下表示式的編譯過程。
我們的表示式可以簡單到只有一個變數名:
{{user_name}}
複製程式碼
也可以很複雜:
{{user.name.localized|upper|escape}}
複製程式碼
這些情況, _expr_code
都會進行處理。同其他語言中的表示式一樣,我們的表示式是遞迴構建的:大表示式由更小的表示式組成。一個完整的表示式是由管道分隔的,其中第一個部分是由逗號分開的,等等。所以我們的函式自然是遞迴的形式:
def _expr_code(self, expr):
"""Generate a Python expression for `expr`."""
複製程式碼
第一種情形是表示式中有 |
。
這種情況會以 |
做為分隔符進行分隔,並將第一部分傳給 _expr_code
繼續求值。
剩下的每一部分都是一個函式,我們可以迭代求值,即前面函式的結果作為後面函式的輸入。同樣,這裡要收集函式變數名以便後面進行解構。
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)
複製程式碼
我們的渲染函式中的變數都加了c_字首,下同
第二種情況是表示式中沒有 |
,但是有 .
。
則以 .
作為分隔符分隔,第一部分傳給 _expr_code
求值,所得結果作為 do_dots
的第一個引數。
剩下的部分都作為 do_dots
的不定引數。
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.z
會被解析成函式呼叫 do_dots(x, 'y', 'z')
最後一種情況是什麼都不包含。這種比較簡單,直接返回帶字首的變數:
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)
複製程式碼
渲染
前面我們已經將模板編譯成了 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)
複製程式碼
render
函式首先將初始傳入的資料和引數進行合併得到最後的上下文資料,最後通過呼叫 _render_function
來得到最後的結果。
最後,再來分析一下 _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
複製程式碼
前面說過,表示式 x.y.z
會被編譯成 do_dots(x, 'y', 'z')
。 下面以此為例:
首先,將 y 作為物件 x 的一個屬性嘗試求值。如果失敗,則將其作為一個鍵求值。最後,如果 y 是可呼叫的,則進行呼叫。
然後,以得到的 value 作為物件繼續進行後面的相同操作。
TODO
為了保持程式碼的精簡,我們還有很多功能有待實現:
- 模板繼承和包含
- 自定義標籤
- 自動轉義
- 過濾器引數
- 複雜的控制邏輯如 else 和 elif
- 超過一個迴圈變數的迴圈體
- 空格控制