1. 程式人生 > >寫一個 Mustache 模板引擎

寫一個 Mustache 模板引擎

前幾天在伯樂在線上看到 介紹 mustache.js 的文章Mustache 是一種模板語言,語法簡單,功能強大,已經有各個語言下的實現。那麼我們今天就用 python 來一步步實現它吧!

#前言

What I cannot create I do not understand.

Richard Feynman

要理解一個事物最有效的方式就是動手創造一個,而真正動手創造的時候,你會發現,事情並沒有相像中的困難。

首先要說說什麼是編譯器,它就像是一個翻譯,將一種語言 X 翻譯成另一種語言 Y。通常語言 X 對人類更加友好,而語言 Y 則是我們不想直接使用的。以 C 語言編譯器為例,它的輸出是組合語言,組合語言太瑣碎了,通常我們不想直接用它來寫程式。而相對而言,C 語言就容易理解、容易編寫。

但是翻譯後的語言 Y 也需要實際去執行,在 C 語言的例子中,它是直接由硬體去執行的,以此得到我們需要的結果。另一些情形下,我們需要做一臺“虛擬機器”來執行。例如 Java 的編譯器將 Java 程式碼轉換成 Java 位元組碼,硬體(CPU)本身並不認識位元組碼,所以 Java 提供了 Java 虛擬機器來實際執行它。

模板引擎 = 編譯器 + 虛擬機器

本質上,模板引擎的工作就是將模板轉換成一個內部的結構,可以是抽象語法樹(AST),也可以是 python 程式碼,等等。同時還需要是一個虛擬機器,能夠理解這種內部結構,給出我們需要的結果。

好吧,那麼模板引擎夠複雜啊!不僅要寫個編譯器,還要寫個虛擬機器!放棄啦,不幹啦!莫慌,容我慢慢道來~

#Mustache 簡介

Mustache 自稱為 logic-less,與一般模板不同,它不包含 if, for 這樣的邏輯標籤,而統一用 {{#prop}} 之類的標籤解決。下面是一個 Mustache 模板:

Hello {{name}}You have just won {{value}} dollars!{{#in_ca}}Well, {{taxed_value}} dollars, after taxes.{{/in_ca}}

對於如下的資料,JSON 格式的資料:

{  "name": "Chris",  "value": 10000,  "taxed_value": 10000 - (10000 * 0.4),
"in_ca": true}

將輸出如下的文字:

Hello ChrisYou have just won 10000 dollars!Well, 6000.0 dollars, after taxes.

所以這裡稍微總結一下 Mustache 的標籤:

  • {{ name }}: 獲取資料中的 `name` 替換當前文字
  • {{# name }} ... {{/name}}: 獲取資料中的 `name` 欄位並依據資料的型別,執行如下操作:
    • name 為假,跳過當前塊,即相當於 if 操作
    • name 為真,則將 name 的值加入上下文並解析塊中的文字
    • name 是陣列且個數大於 0,則逐個迭代其中的資料,相當於 for

邏輯簡單,易於理解。下面就讓我們來實現它吧!

#模板引擎的結構

如前文所述,我們實現的模板引擎需要包括一個編譯器,以及一個虛擬機器,我們選擇抽象語法樹作為中間表示。下圖是一個圖示:

學過編譯原理的話,你可能知道編譯器包括了詞法分析器、語法分析器及目的碼的生成。但是我們不會單獨實現它們,而是一起實現原因有兩個:

  1. 模板引擎的語法通常要簡單一些,Mustache 的語法比其它引擎比起來更是如此。
  2. Mustache 支援動態修改分隔符,因此詞法的分析和語法的分析必需同時進行。

下面開始 Coding 吧!

#輔助函式

#上下文查詢

首先,Mustache 有所謂上下文棧(context stack)的概念,每進入一個

{{#name}}...{{/name}} 塊,就增加一層棧,下面是一個圖示:

這個概念和 Javscript 中的原型鏈是一樣的。只是 Python 中並沒有相關的支援,因此我們實現自己的查詢函式:

def lookup(var_name, contexts=()):    for context in reversed(contexts):        try:            if var_name in context:                return context[var_name]        except TypeError as te:            # we may put variable on the context, skip it            continue    return None

如上,每個上下文(context)可以是一個字典,也可以是資料元素(像字串,數字等等),而上下文棧則是一個數組,contexts[0] 代表棧底,context[-1] 代表棧頂。其餘的邏輯就很明直觀了。

#單獨行判定

Mustache 中有“單獨行”(standalone)的概念,即如果一個標籤所在的行,除了該標籤外只有空白字元,則稱為單獨行。判斷函式如下:

spaces_not_newline = ' \t\r\b\f're_space = re.compile(r'[' + spaces_not_newline + r']*(\n|$)')def is_standalone(text, start, end):    left = False    start -= 1    while start >= 0 and text[start] in spaces_not_newline:        start -= 1    if start < 0 or text[start] == '\n':        left = True    right = re_space.match(text, end)    return (start+1, right.end()) if left and right else None

其中,(start, end) 是當前標籤的開始和結束位置。我們分別向前和向後匹配空白字元。向前是一個個字元地判斷,向後則偷懶用了正則表示式。右是單獨行則返回單獨行的位置:(start+1, right.end())

#語法樹

我們從語法樹講起,因為這是編譯器的輸出,先弄清輸出的結構,我們能更好地理解編譯器的工作原理。

首先介紹樹的節點的型別。因為語法樹和 Mustache 的語法對應,所以節點的型別和 Mustache 支援的語法型別對應:

class Token():    """The node of a parse tree"""    LITERAL   = 0    VARIABLE  = 1    SECTION   = 2    INVERTED  = 3    COMMENT   = 4    PARTIAL   = 5    ROOT      = 6

這 6 種類型中除了 ROOT,其餘都對應了 Mustache 的一種型別,對應關係如下:

  • LITERAL:純文字,即最終按原樣輸出的部分
  • VARIABLE:變數欄位,即 {{ name }} 型別
  • SECTION:對應 {{#name}} ... {{/name}}
  • INVERTED:對應 {{^name}} ... {{/name}}
  • COMMENT:註釋欄位 {{! name }}
  • PARTIAL:對應 {{> name}}

而最後的 ROOT 則代表整棵語法樹的根節點。

瞭解了節點的型別,我們還需要知道每個節點需要儲存什麼樣的資訊,例如對於 Section 型別的節點,我們需要儲存它對應的子節點,另外為了支援 lambda 型別的資料,我們還需要儲存 section 段包含的文字。最終需要的欄位如下:

def __init__(self, name, type=LITERAL, value=None, text='', children=None):    self.name = name    self.type = type    self.value = value    self.text = text    self.children = children    self.escape = False    self.delimiter = None # used for section    self.indent = 0 # used for partial
  • name :儲存該節點的名字,例如 {{ header }} 是變數型別,name 欄位儲存的就是 header 這個名字。
  • type:儲存前文介紹的節點的型別
  • value:儲存該節點的值,不同型別的節點儲存的內容也不同,例如 LITERAL 型別儲存的是字串本身,而 VARIABLE 儲存的是變數的名稱,和 name 雷同。
  • text :只對 SECTIONINVERTED 有用,即儲存包含的文字
  • childrenSECTIONINVERTEDROOT型別使用,儲存子節點
  • escape:輸出是否要轉義,例如 {{name}} 是預設轉義的,而{{{name}}}預設不轉義
  • delimiter:與 lambda 的支援有關。Mustache 要求,若 SECTION 的變數是一個函式,則先呼叫該函式,返回時的文字用當前的分隔符解釋,但在編譯期間這些文字是不可獲取的,因此需要事先儲存。
  • indentPARTIAL 型別使用,後面會提到。

可以看到,語法樹的型別、結構和 Mustache 的語法息息相關,因此,要理解它的最好方式就是看 Mustache 的標準。 一開始寫這個引擎時並不知道需要這麼多的欄位,在閱讀標準時,隨著對 Mustache 語法的理解而慢慢新增的。

#虛擬機器

所謂的虛擬機器就是對編譯器輸出(我們的例子中是語法樹)的解析,即給定語法樹和資料,我們能正確地輸出文字。首先我們為 Token 類定義一個排程函式:

class Token():    ...    def render(self, contexts, partials={}):        if not isinstance(contexts, (list, tuple)): # ①            contexts = [contexts]        # ②        if self.type == self.LITERAL:            return self._render_literal(contexts, partials)        elif self.type == self.VARIABLE:            return self._render_variable(contexts, partials)        elif self.type == self.SECTION:            return self._render_section(contexts, partials)        elif self.type == self.INVERTED:            return self._render_inverted(contexts, partials)        elif self.type == self.COMMENT:            return self._render_comments(contexts, partials)        elif self.type == self.PARTIAL:            return self._render_partials(contexts, partials)        elif self.type == self.ROOT:            return self._render_children(contexts, partials)        else:            raise TypeError('Invalid Token Type')

①:我們要求上下文棧(context stack)是一個列表(或稱陣列),為了方便使用者,我們允許它是其它型別的。

②的邏輯很簡單,就是根據當前節點的型別執行不同的函式用來渲染(render)文字。

另外每個“渲染函式”都有兩個引數,即上下文棧contextspartialspartials是一個字典型別。它的作用是當我們在模板中遇見如 {{> part}} 的標籤中,就從 partials 中查詢 part,並用得到的文字替換當前的標籤。具體的使用方法可以參考 Mustache 文件

#輔助渲染函式

它們是其它“子渲染函式”會用到的一些函式,首先是轉義函式:

from html import escape as html_escapeEMPTYSTRING = ""class Token():    ...    def _escape(self, text):        ret = EMPTYSTRING if not text else str(text)        if self.escape:            return html_escape(ret)        else:            return ret

作用是如果當前節點需要轉義,則呼叫 html_escape 進行轉義,例如將文字 <b> 轉義成 &lt;b&gt;

另一個函式是查詢(lookup),在給定的上下文棧中查詢對應的變數。

class Token():    ...    def _lookup(self, dot_name, contexts):        if dot_name == '.':            value = contexts[-1]        else:            names = dot_name.split('.')            value = lookup(names[0], contexts)            # support {{a.b.c.d.e}} like lookup            for name in names[1:]:                try:                    value = value[name]                except:                    # not found                    break;        return value

這裡有兩點特殊的地方:

  1. 若變數名為 .,則返回當前上下文棧中棧頂的變數。這是 Mustache 的特殊語法。
  2. 支援諸如以 . 號為分隔符的層級訪問,如 {{a.b.c}} 代表首先查詢變數 a,在 a 的值中查詢變數 b,以此類推。

#字面量

LITERAL 型別的節點,在渲染時直接輸出節點儲存的字串即可:

def _render_literal(self, contexts, partials):    return self.value

#子節點

子節點的渲染其實很簡單,因為語法樹是樹狀的結構,所以只要遞迴呼叫子節點的渲染函式就可以了,程式碼如下:

def _render_children(self, contexts, partials):    ret = []    for child in self.children:        ret.append(child.render(contexts, partials))    return EMPTYSTRING.join(ret)

#變數

即遇到諸如 {{name}}、{{{name}}} 或 {{&name}} 等的標籤時,從上下文棧中查詢相應的值即可:

def _render_variable(self, contexts, partials):    value = self._lookup(self.value, contexts)    # lambda    if callable(value):        value = render(str(value()), contexts, partials)    return self._escape(value)

這裡的唯一不同是對 lambda 的支援,如果變數的值是一個可執行的函式,則需要先執行它,將返回的結果作為新的文字,重新渲染。這裡的 render 函式後面會介紹。

例如:

contexts = [{ 'lambda': lambda : '{{value}}', 'value': 'world' }]'hello {{lambda}}' => 'hello {{value}}' => 'hello world'

#Section

Section 的渲染是最為複雜的一個,因為我們需要根據查詢後的資料的型別做不同的處理。

def _render_section(self, contexts, partials):    val = self._lookup(self.value, contexts)    if not val:        # false value        return EMPTYSTRING    if isinstance(val, (list, tuple)):        if len(val) <= 0:            # empty lists            return EMPTYSTRING        # non-empty lists        ret = []        for item in val: #①            contexts.append(item)            ret.append(self._render_children(contexts, partials))            contexts.pop()        return self._escape(''.join(ret))    elif callable(val): #②        # lambdas        new_template = val(self.text)        value = render(new_template, contexts, partials, self.delimiter)    else:        # context ③        contexts.append(val)        value = self._render_children(contexts, partials)        contexts.pop()    return self._escape(value)

①:當資料的型別是列表時,我們逐個迭代,將元素入棧並渲染它的子節點。

②:當資料的型別是函式時,與處理變數時不同,Mustache 要求我們將 Section 中包含的文字作為引數,呼叫該函式,再對該函式返回的結果作為新的模板進行渲染。且要求使用當前的分隔符。

③:正常情況下,我們需要渲染 Section 包含的子節點。注意 self.textself.children 的區別,前者是文字字串,後者是編譯後的語法樹節點。

#Inverted

Inverted Section 起到的作用是 if not,即只有當資料為假時才渲染它的子節點。

def _render_inverted(self, contexts, partials):    val = self._lookup(self.value, contexts)    if val:        return EMPTYSTRING    return self._render_children(contexts, partials)

#註釋

直接跳過該子節點即可:

def _render_comments(self, contexts, partials):    return EMPTYSTRING

#Partial

Partial 的作用相當於預先儲存的模板。與其它模板語言的 include 類似,但還可以遞迴呼叫。例如:

partials: {'strong': '<strong>{{name}}</strong>'}'hello {{> strong}}' => 'hello <strong>{{name}}</strong>'

程式碼如下:

re_insert_indent = re.compile(r'(^|\n)(?=.|\n)', re.DOTALL) #①class Token():    ...    def _render_partials(self, contexts, partials):        try:            partial = partials[self.value]        except KeyError as e:            return self._escape(EMPTYSTRING)        partial = re_insert_indent.sub(r'\1' + ' '*self.indent, partial) #②        return render(partial, contexts, partials, self.delimiter)

這裡唯一值得一提的就是縮排問題②。Mustache 規定,如果一個 partial 標籤是一個“單獨行”,則需要將該標籤的縮排新增到資料的所有行,然後再進行渲染。例如:

partials: {'content': '<li>\n {{name}}\n</li>\n'}|                           |<ul>|<ul>                       |    <li>|    {{> content}}   =>     |     {{name}}|</ul>                      |    </li>|                           |</ul>

因此我們用正則表示式對 partial 的資料進行處理。①中的正則表示式,(^|\n) 用於匹配文字的開始,或換行符之後。而由於我們不匹配最後一個換行符,所以我們用了 (?=.|\n)。它要求,以任意字元結尾,而由於 . 並不匹配換行符 \n,因此用了或操作(|)。

#虛擬機器小結

綜上,我們就完成了執行語法樹的虛擬機器。是不是還挺簡單的。的確,一旦決定好了資料結構,其它的實現似乎也只是按部就班。

最後額外指出一個問題,那就是編譯器與直譯器的問題。傳統上,直譯器是指一句一句讀取原始碼並執行;而編譯器是讀取全部原始碼並編譯,生成目的碼後一次性去執行。

在我們的模板引擎中,語法樹是屬於編譯得到的結果,因為模板是固定的,因此能得到一個固定的語法樹,語法樹可以重複執行,這也有利於提高效率。但由於 Mustache 支援 partial 及 lambda,這些機制使得使用者能動態地為模板新增新的內容,所以固定的語法樹是不夠的,因此我們在渲染時用到了全域性 render 函式。它的作用就相當於直譯器,讓我們能動態地渲染模板(本質上依舊是編譯成語法樹再執行)。

有了這個虛擬機器(帶執行功能的語法樹),我們就能正常渲染模板了,那麼接下來就是如何把模板編譯成語法樹了。

#詞法分析

Mustache 的詞法較為簡單,並且要求能動態改變分隔符,所以我們用正則表示式來一個個匹配。

Mustache 標籤由左右分隔符包圍,預設的左右分隔符分別是 { {(忽略中間的空格) 和 }}

DEFAULT_DELIMITERS = ('{{', '}}')

而標籤的模式是:左分隔符 + 型別字元 + 標籤名 + (可選字元)+ 右分隔符,例如:

{{# name}} 和 {{{name}}}。其中 `#` 就代表型別,{{{name}}} 中的`}` 就是

可選的字元。

re_tag = re.compile(open_tag + r'([#^>&{/!=]?)\s*(.*?)\s*([}=]?)' + close_tag, re.DOTALL)

例如:

In [6]: re_tag = re.compile(r'{{([#^>&{/!=]?)\s*(.*?)\s*([}=]?)}}', re.DOTALL)In [7]: re_tag.search('before {{# name }} after').groups()Out[7]: ('#', 'name', '')

這樣通過這個正則表示式就能得到我們需要的型別和標籤名資訊了。

只是,由於 Mustache 支援修改分隔符,而正則表示式的 compile 過程也是挺花時間的,因此我們要做一些快取的操作來提高效率。

re_delimiters = {}def delimiters_to_re(delimiters):    # caching    delimiters = tuple(delimiters)    if delimiters in re_delimiters:        re_tag = re_delimiters[delimiters]    else:        open_tag, close_tag = delimiters        # escape ①        open_tag = ''.join([c if c.isalnum() else '\\' + c for c in open_tag])        close_tag = ''.join([c if c.isalnum() else '\\' + c for c in close_tag])        re_tag = re.compile(open_tag + r'([#^>&{/!=]?)\s*(.*?)\s*([}=]?)' + close_tag, re.DOTALL)        re_delimiters[delimiters] = re_tag    return re_tag

①:這是比較神奇的一步,主要是有一些字元的組合在正則表示式裡是有特殊含義的,為了避免它們影響了正則表示式,我們將除了字母和數字的字元進行轉義,如 '[' => '\['

#語法分析

現在的任務是把模板轉換成語法樹,首先來看看整個轉換的框架:

def compiled(template, delimiters=DEFAULT_DELIMITERS):    re_tag = delimiters_to_re(delimiters)    # variable to save states ①    tokens = []    index = 0    sections = []    tokens_stack = []    m = re_tag.search(template, index)    while m is not None:        token = None        last_literal = None        strip_space = False        if m.start() > index: #②            last_literal = Token('str', Token.LITERAL, template[index:m.start()])            tokens.append(last_literal)        prefix, name, suffix = m.groups()        # >>> TODO: convert information to AST        if token is not None: #③            tokens.append(token)        index = m.end()        if strip_space: #④            pos = is_standalone(template, m.start(), m.end())            if pos:                index = pos[1]                if last_literal: last_literal.value = last_literal.value.rstrip(spaces_not_newline)        m = re_tag.search(template, index)    tokens.append(Token('str', Token.LITERAL, template[index:]))    return Token('root', Token.ROOT, children=tokens)

可以看到,整個步驟是由一個 while 迴圈構成,迴圈不斷尋找下一個 Mustache 標籤。這意味著我的解析是線性的,但我們的目標是生成樹狀結構,這怎麼辦呢?答案是①中,我們維護了兩個棧,一個是 sections,另一個是 tokens_stack。至於怎麼使用,下文會提到。

②:由於每次 while 迴圈時,我們跳過了中間那些不是標籤的字面最,所以我們要將它們進行新增。這裡將該節點儲存在 last_literal 中是為了處理“單獨行的情形”,詳情見下文。

③:正常情況下,在迴圈末我們會將生成的節點(token)新增到 tokens 中,而有些情況下我們希望跳過這個邏輯,此時將 token 設定成 None

④:strip_space 代表該標籤需要考慮“單獨行”的情形,此時做出相應的處理,一方面將上一個字面量節點的末尾空格消除,另一方面將 index 後移至換行符。

#分隔符的修改

唯一要注意的是 Mustache 規定分隔符的修改是需要考慮“單獨行”的情形的。

if prefix == '=' and suffix == '=':    # {{=| |=}} to change delimiters    delimiters = re.split(r'\s+', name)    if len(delimiters) != 2:        raise SyntaxError('Invalid new delimiter definition: ' + m.group())    re_tag = delimiters_to_re(delimiters)    strip_space = True

#變數

在解析變數時要考慮該變數是否需要轉義,並做對應的設定。另外,末尾的可選字元 (suffix) 只能是 }=,分別都判斷過了,所以此外的情形都是語法錯誤。

elif prefix == '{' and suffix == '}':    # {{{ variable }}}    token = Token(name, Token.VARIABLE, name)elif prefix == '' and suffix == '':    # {{ name }}    token = Token(name, Token.VARIABLE, name)    token.escape = Trueelif suffix != '' and suffix != None:    raise SyntaxError('Invalid token: ' + m.group())elif prefix == '&':    # {{& escaped variable }}    token = Token(name, Token.VARIABLE, name)

#註釋

註釋是需要考慮“單獨行”的。

elif prefix == '!':    # {{! comment }}    token = Token(name, Token.COMMENT)    if len(sections) <= 0:        # considered as standalone only outside sections        strip_space = True

#Partial

一如既往,需要考慮“單獨行”,不同的是還需要儲存單獨行的縮排。

elif prefix == '>':    # {{> partial}}    token = Token(name, Token.PARTIAL, name)    strip_space = True    pos = is_standalone(template, m.start(), m.end())    if pos:        token.indent = len(template[pos[0]:m.start()])

#Section & Inverted

這是唯一需要使用到棧的兩個標籤,原理是選通過入棧記錄這是 Section 或 Inverted 的開始標籤,遇到結束標籤時再出棧即可。

由於事先將 tokens 儲存起來,因此遇到結束標籤時,tokens 中儲存的就是當前標籤的所有子節點。

elif prefix == '#' or prefix == '^':    # {{# section }} or # {{^ inverted }}    token = Token(name, Token.SECTION if prefix == '#' else Token.INVERTED, name)    token.delimiter = delimiters    tokens.append(token)    # save the tokens onto stack    token = None    tokens_stack.append(tokens)    tokens = []    sections.append((name, prefix, m.end()))    strip_space = True

#結束標籤

當遇到結束標籤時,我們需要進行對應的出棧操作。無它。

elif prefix == '/':    tag_name, sec_type, text_end = sections.pop()    if tag_name != name:        raise SyntaxError("unclosed tag: '" + name + "' Got:" + m.group())    children = tokens    tokens = tokens_stack.pop()    tokens[-1].text = template[text_end:m.start()]    tokens[-1].children = children    strip_space = Trueelse:    raise SyntaxError('Unknown tag: ' + m.group())

#語法分析小結

同樣,語法分析的內容也是按部就班,也許最難的地方就在於構思這個 while 迴圈。所以要傳下教:思考問題的時候要先把握整體的內容,即要自上而下地思考,實際編碼的時候可以從兩邊同時進行。

#最後

最後我們再實現 render 函式,用來實際執行模板的渲染。

class SyntaxError(Exception):    passdef render(template, contexts, partials={}, delimiters=None):    if not isinstance(contexts, (list, tuple)):        contexts = [contexts]    if not isinstance(partials, dict):        raise TypeError('partials should be dict, but got ' + type(partials))    delimiters = DEFAULT_DELIMITERS if delimiters is None else delimiters    parent_token = compiled(template, delimiters)    return parent_token.render(contexts, partials)

這是一個使用我們模板引擎的例子:

>>> render('Hellow {{name}}!', {'name': 'World'})'Hellow World!'

#總結

綜上,我們完成了一個完整的 mustache 模板引擎,完整的程式碼可以在 Github: pymustache 上下載。

實際測試了一下,我們的實現比 pystache 還更快,程式碼也更簡單,去掉註釋估計也就 300 行左右。

無論如何吧,我就想打個雞血:如果真正去做了,有些事情並沒有看起來那麼難。如果本文能對你有所啟發,那就是對我最大的鼓勵。