[翻譯] 用Python做一個模板引擎玩具

平田發表於2015-07-17

本文翻譯自 Alex Michael 的部落格,原文連結在此

文中 斜體部分 表示我自身對原文的理解表達的意思沒有完全理解或者自認翻譯有問題,望各位英文達人不吝指正,我也會根據大家的提議及時對譯文內容進行修正。


如果你曾經好奇模板引擎是怎樣工作的,那麼現在和我們一起來構建一個簡單的模板引擎,探索它的工作流程吧。

如果你想更加深入的瞭解程式碼細節,請訪問本專案的 Github頁面

語言設計

我們的模板引擎語言非常簡單,只有兩種標籤:變數(variables)程式碼塊(blocks)

<!-- 變數由 `{{` 和 `}}` 包裹 -->
<div>{{my_var}}</div>

<!-- 程式碼塊由 `{%` 和 `%}` 包裹 -->
{% each items %}
    <div>{{it}}</div>
{% end %}

幾乎所有的程式碼塊都如上例所示,是關閉的,而用於關閉的標籤就是 {% end %}

我們的模板引擎需要支援基本的 迴圈(loops)條件(conditionals)。我們還將在程式碼塊中,提供 呼叫(callables) 的功能——從我的角度來看,我發現它能夠方便地在我的模板裡呼叫任意Python函式使用。

迴圈——Loops

迴圈允許對集合或可迭代物件進行迭代

{% each people %}
    <div>{{it.name}}</div>
{% end %}

{% each [1, 2, 3] %}
    <div>{{it}}</div>
{% end %}

{% each records %}
    <div>{{..name}}</div>
{% end %}

在上例中,people 是集合,而 it 則是迭代中的當前項。我們使用 .. 符號來獲取變數名的父級上下文,而變數名所帶的點號(Dotted)路徑將解決字典項中巢狀屬性問題。

條件——Conditionals

條件則不需要過多闡述,我們的模板語言支援 if..else.. 結構,以及 ==, <=, >=, !=, is, >, < 幾個比較運算子。

{% if num > 5 %}
    <div>more than 5</div>
{% else %}
    <div>less than or equal to 5</div>
{% end %}

呼叫——Callables

呼叫可以通過傳遞模板的上下文,呼叫位置或模板中的關鍵字引數完成。進行呼叫的程式碼塊不需要關閉。

<!-- 支援位置引數... -->
<div class='date'>{% call prettify date_created %}</div>
<!-- ...和關鍵字引數 -->
<div>{% call log 'here' verbosity='debug' %}</div>

原理

在深入細節去了解我們的模板引擎將怎樣編譯和渲染模板之前,我們必須說一說,我們如何將一個模板編譯後存放在記憶體中。

編譯器使用 抽象語法樹(AST) 來展示一個計算機程式的結構。 AST 是對程式碼進行詞法分析後的產物。相比原始碼,AST 擁有諸多優勢,比如它剔除了分隔符等不必要的文字元素。不止如此,語法樹中的節點可以由屬性來增強而不必修改實際的原始碼。

我們將解析並分析模板,併為其建立一顆這樣的樹來表示編譯後的模板。我們將遍歷這棵樹,將每個節點對應到正確的上下文,並輸出HTML進行渲染。

將模板標記化

在解析模板中,我們首先要將模板內容拆分成片段。每一個片段可以是任意的內容,既可能是HTML,也可能是模板標籤。我們用正規表示式split()函式來拆分模板內容。

VAR_TOKEN_START = '{{'
VAR_TOKEN_END = '}}'
BLOCK_TOKEN_START = '{%'
BLOCK_TOKEN_END = '%}'
TOK_REGEX = re.compile(r"(%s.*?%s|%s.*?%s)" % (
    VAR_TOKEN_START,
    VAR_TOKEN_END,
    BLOCK_TOKEN_START,
    BLOCK_TOKEN_END
))

我們來分析一下 TOK_REGEX,可以看到,他有一個可選的變數標記和碼塊標記,這樣做的原因是,我們希望把變數和程式碼塊分割開。我們用捕獲括號把含有模式選項的匹配文字包裹起來用於生成 TOKEN,其中模式選項裡的 ? 是用於重複進行 非貪婪模式(non-greedy)。我們希望我們的正規表示式是惰性(lazy)的,在匹配到符合條件的第一項時就停止,這樣一來,舉個例子,我們就可以從程式碼塊中提取變數。這裡有一個很好的例子解釋瞭如何控制正規表示式的惰性方式。

接下來的這個例子展示了我們的正規表示式是如何工作的:

>>> TOK_REGEX.split('{% each vars %}<i>{{it}}</i>{% endeach %}')
['{% each vars %}', '<i>', '{{it}}', '</i>', '{% endeach %}']

我們將用 片段物件(Fragment object) 封裝每一個 片段(Fragment)。這個物件會確定片段的型別並準備片段提供給編譯函式使用。片段有可能是以下四種中的一種:

VAR_FRAGMENT = 0
OPEN_BLOCK_FRAGMENT = 1
CLOSE_BLOCK_FRAGMENT = 2
TEXT_FRAGMENT = 3

構造AST

當我們將模板文字標籤化之後,就該遍歷每個片段來建立語法樹了。我們將用 節點類(Node class) 來作為樹節點的基類,併為每個可能的節點型別建立具體的子類。一個子類應該提供 process_fragment()render() 方法的實現。process_fragment() 方法用於進一步解析片段的內容並將必要的屬性儲存在 節點類 中。render() 方法負責根據提供的上下文,將節點轉換為對應的HTML。

一個子類可以根據情況提供 enter_scope()exit_scope() 鉤子(hook) 的實現,由編譯器在編譯時呼叫以進一步提供初始化和清理功能。enter_scope() 在節點建立新作用域時被呼叫(之後有詳述),exit_scope()在節點作用域從作用域棧中被pop出來時被呼叫。

如下是我們的節點基類:

class _Node(object):
    def __init__(self, fragment=None):
        self.children = []
        self.creates_scope = False
        self.process_fragment(fragment)

    def process_fragment(self, fragment):
        pass

    def enter_scope(self):
        pass

    def render(self, context):
        pass

    def exit_scope(self):
        pass

    def render_children(self, context, children=None):
        if children is None:
            children = self.children
        def render_child(child):
            child_html = child.render(context)
            return '' if not child_html else str(child_html)
        return ''.join(man(render_child, children))

我們用變數節點作為例子,來展示一個具體子類:

class _Variable(_Node):
    def process_fragment(self, fragment):
        self.name = fragment

    def render(self, context):
        return resolve_in_context(self.name, context)

我們將根據片段的型別和內容確定節點的型別(用於正確的例項化節點類)。文字和變數片段直接轉換為文字節點和變數節點,程式碼塊片段需要稍多一點步驟——他們的型別由程式碼塊命令中的第一個詞確定。比如下面這個片段:

{% each items %}

就是一個 each 型別的程式碼塊節點。

一個節點也可以建立一個作用域。在編譯過程中,我們持續的追蹤當前作用域,並將新節點新增到這個作用域下作為子節點。一旦我們遇到一個正確的關閉標籤,我們就關閉該作用域,將其從作用域棧pop出來,然後返回到父級作用域並將父級作用域作為新的當前作用域。

def compile(self):
    root = _Root()
    scope_stack = [root]
    for fragment in self.each_fragment():
        if not scope_stack:
            raise TemplateError('nesting issues')
        parent_scope = scope_stack[-1]
        if fragment.type == CLOSE_BLOCK_FRAGMENT:
            parent_scope.exit_scope()
            scope_stack.pop()
            continue
        new_node = self.create_node(fragment)
        if new_node:
            parent_scope.children.append(new_node)
            if new_node.creates_scope:
                scope_stack.append(new_node)
                new_node.enter_scope()
    return root

渲染——Rendering

整個流水線的最後一步,是將AST渲染成HTML。我們遍歷AST中所有結點,並呼叫 render()方法 作為引數傳遞給模版的上下文。在渲染過程中,我們需要推斷我們是在處理字面常量還是根據上下文確定的變數的名字。為了解決該問題,我們使用 ast.literal_eval() 來安全地執行包含 Python程式碼 的字串:

def eval_expression(expr):
    try:
        return 'literal', ast.literal_eval(expr)
    except ValueError, SyntaxError:
        return 'name', expr

如果我們是在處理上下文變數的名字,那麼沒我們需要通過搜尋的方式,來確定他在上下文中的值。我們需要注意點號(dotted)後的名稱和關聯到父級上下文的名稱。下例是我們解決這個問題的函式:

def resolve(name, context):
    if name.startswith('..'):
        context = context.get('..', {})
        name = name[2:]
    try:
        for tok in name.split('.'):
            context = context[tok]
        return context
    except KeyError:
        raise TemplateContextError(name)

結語——Conclusion

我希望這篇文章能讓你大概明白模版引擎內部工作機制。這雖然離一個成熟產品的質量要求還很遠,但它至少可以作為一個基礎來創造更好的產品。

你可以在 Github 找到全部的程式碼實現,你也可以在 Hacker News 上針對本文發表更進一步的意見和建議。

向對本文原稿進行校審的 Nassos Hadjipapas, Alex Loizou, Panagiotis Papageorgiou Gearoid O’Rourke 諸位表示最誠摯的感謝!

相關文章