寫一個 Mustache 模板引擎
前幾天在伯樂在線上看到 介紹 mustache.js 的文章。Mustache 是一種模板語言,語法簡單,功能強大,已經有各個語言下的實現。那麼我們今天就用 python 來一步步實現它吧!
#前言
What I cannot create I do not understand.
要理解一個事物最有效的方式就是動手創造一個,而真正動手創造的時候,你會發現,事情並沒有相像中的困難。
首先要說說什麼是編譯器,它就像是一個翻譯,將一種語言 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), |
將輸出如下的文字:
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
- 若
邏輯簡單,易於理解。下面就讓我們來實現它吧!
#模板引擎的結構
如前文所述,我們實現的模板引擎需要包括一個編譯器,以及一個虛擬機器,我們選擇抽象語法樹作為中間表示。下圖是一個圖示:
學過編譯原理的話,你可能知道編譯器包括了詞法分析器、語法分析器及目的碼的生成。但是我們不會單獨實現它們,而是一起實現原因有兩個:
- 模板引擎的語法通常要簡單一些,Mustache 的語法比其它引擎比起來更是如此。
- 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
:只對SECTION
和INVERTED
有用,即儲存包含的文字children
:SECTION
、INVERTED
及ROOT
型別使用,儲存子節點escape
:輸出是否要轉義,例如 {{name}} 是預設轉義的,而{{{name}}}預設不轉義delimiter
:與lambda
的支援有關。Mustache 要求,若SECTION
的變數是一個函式,則先呼叫該函式,返回時的文字用當前的分隔符解釋,但在編譯期間這些文字是不可獲取的,因此需要事先儲存。indent
是PARTIAL
型別使用,後面會提到。
可以看到,語法樹的型別、結構和 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)文字。
另外每個“渲染函式”都有兩個引數,即上下文棧contexts
和 partials
。
partials
是一個字典型別。它的作用是當我們在模板中遇見如 {{> 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>
轉義成 <b>
。
另一個函式是查詢(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 |
這裡有兩點特殊的地方:
- 若變數名為
.
,則返回當前上下文棧中棧頂的變數。這是 Mustache 的特殊語法。 - 支援諸如以
.
號為分隔符的層級訪問,如 {{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.text
與
self.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 行左右。
無論如何吧,我就想打個雞血:如果真正去做了,有些事情並沒有看起來那麼難。如果本文能對你有所啟發,那就是對我最大的鼓勵。