歡迎光臨
每天分享高質量文章

手把手教你用500行 Python 程式碼實現模板引擎

引言  

大多數程式包含大量的邏輯,以及少量文字資料。程式語言被設計成適合這種型別的程式設計。但是一些程式設計任務只涉及一點邏輯,以及大量的文字資料。
對於這些任務,我們希望有一個更適合這些問題的工具。模板引擎就是這樣一種工具。在本章中,我們將構建一個簡單的模板引擎。

最常見的一個以文字為主的任務是在 web 應用程式。任何 web 應用程式的一個重要工序是生成用於瀏覽器顯示的 HTML。
很少有 HTML 頁面是完全靜態的:它們至少包含少量的動態資料,比如使用者名稱。通常,它們包含大量的動態資料:產品串列、好友的新聞更新等等。

與此同時,每個HTML頁面都包含大量的靜態文字。這些頁面很大,包含成千上萬個位元組的文字。
web 應用程式開發人員有一個問題要解決:如何最好地生成包含靜態和動態資料混合的大段字串?另一個問題是:
靜態文字實際上是由團隊的另一個成員、前端設計人員編寫的 HTML 標記,他們希望能夠以熟悉的方式使用它。

為了便於說明,假設我們想要生成這個 HTML:

這裡,使用者的名字將是動態的,就像產品的名稱和價格一樣。甚至產品的數量也不是固定不變的:有時可能會有更多或更少的產品展示出來。

構造這個 HTML 的一種方法是在我們的程式碼中將字串常量們合併到一起來生成頁面。動態資料將插入以替換某些字串。我們的一些動態資料是重覆的,就像我們的產品串列一樣。
這意味著我們將會有大量重覆的 HTML,因此這些內容必須單獨處理,並與頁面的其他部分合併。

比如,我們的 demo 頁面像這樣:

這是可行的,但是有點亂。HTML 是嵌入在我們的程式碼中的多個字串常量。頁面的邏輯很難看到,因為靜態文字被拆分為獨立的部分。如何格式化資料的細節隱藏在 Python 程式碼中。為了修改 HTML 頁面,我們的前端設計人員需要能夠編輯 Python 程式碼。想象一下,如果頁面是10(或者100)倍的複雜,程式碼會是什麼樣子。它很快就會變得無法維護。

模板  

生成 HTML 頁面的更好方法是使用模板。HTML 頁面是作為模板編寫的,這意味著該檔案主要是靜態的 HTML,其中嵌入了使用特殊符號標記的動態片段。我們的 demo 頁面模板可以像這樣:

這裡的重點是 HTML 文字,其中嵌入了一些邏輯。將這種以檔案為中心的方法與上面的以邏輯為中心的程式碼進行對比。前面的程式主要是 Python 程式碼,HTML 嵌入在 Python 邏輯中。這裡我們的程式主要是靜態 HTML 標記。

要在我們的程式中使用 HTML 模板,我們需要一個模板引擎:一個使用靜態模板來描述頁面的結構和靜態內容的函式,以及提供動態資料插入模板的動態背景關係。模板引擎將模板和背景關係結合起來生成完整的 HTML 字串。模板引擎的工作是解釋模板,用真實資料替換動態片段。

支援的語法  

模板引擎在它們支援的語法中有所不同。我們的模板語法基於 Django,一個流行的 web 框架。既然我們在 Python 中實現了我們的引擎,那麼一些 Python 概念將出現在我們的語法中。在我們的 demo 示例中,我們已經看到了這一章的一些語法,下麵是我們將要實現的所有語法:

使用雙花括號插入背景關係中的資料:

當模板被呈現時,模板中可用的資料將提供給背景關係。稍後將進行更詳細的討論。

模板引擎通常使用簡化的、輕鬆的語法來訪問資料中的元素。在 Python 中,這些運算式有不同的效果:

在我們的模板語法中,所有這些操作都用點來表示:

點符號將訪問物件屬性或字典值,如果結果值是可呼叫的,它將自動呼叫。這與 Python 程式碼不同,您需要使用不同的語法來執行這些操作。這就產生了更簡單的模板語法:

您可以使用過濾器函式來修改值,透過管道字元呼叫:

構建好玩的頁面通常需要少量的決策,所以條件陳述句也是可用的:

迴圈允許我們在頁面中包含資料集合:

與其他程式語言一樣,條件陳述句和迴圈可以巢狀來構建複雜的邏輯結構。

最後,註釋也不能少:

實現方法

總的來說,模板引擎有兩個主要的工作:解析模板,渲染模板。

渲染模板具體涉及:

  • 管理動態背景關係,資料的來源

  • 執行邏輯元素

  • 實現點訪問和篩選執行

從解析階段傳遞什麼到呈現階段是關鍵。

解析可以提供什麼?有兩種選擇:我們稱它們為解釋和編譯。

在解釋模型中,解析生成一個表示模板結構的資料結構。呈現階段將根據所找到的指令對資料結構進行處理,並將結果文字組合起來。Django 模板引擎使用這種方法。

在編譯模型中,解析生成某種形式的可直接執行的程式碼。呈現階段執行該程式碼,生成結果。Jinja2 和 Mako 是使用編譯方法的模板引擎的兩個例子。

我們的引擎的實現使用編譯模型:我們將模板編譯成 Python 程式碼。當它執行時,組裝成結果。
模板被編譯成 Python 程式碼,程式將執行得更快,因為即使編譯過程稍微複雜一些,但它只需要執行一次。
將模板編譯為 Python 要稍微複雜一些,但它並沒有您想象的那麼糟糕。而且,正如任何開發人員都能告訴你的那樣,編寫一個會編寫程式的程式比編寫程式要有趣得多!

編譯程式碼  

在我們瞭解模板引擎的程式碼之前,讓我們看看它要生成的程式碼。解析階段將把模板轉換為 Python 函式。這是我們的模板:

針對上面的模板,我們最後想得到編譯後的 Python 程式碼如下所示:

幾點說明:

  • 透過快取了一些函式到區域性變數來對程式碼進行了最佳化(比如 append_result = result.append 等)

  • 點符號操作被轉化成了 do_dots 函式

  • 邏輯程式碼被轉化成了 python 程式碼和迴圈

編寫模板引擎  

模板類

可以使用模板的文字構造了 Templite 物件,然後您可以使用它來呈現一個特定的背景關係,即資料字典:

在建立物件時,我們會傳遞模板的文字,這樣我們就可以只執行一次編譯步驟,然後呼叫多次來重用編譯後的結果。

建構式還受一個字典引數,一個初始背景關係。這些儲存在Templite物件中,當模板稍後呈現時將可用。這些都有利於定義我們想要在任何地方都可用的函式或常量,比如上一個例子中的upper。

在討論實現 Templite 之前,讓我們先搞定一個工具類: CodeBuilder

CodeBuilder

引擎中的大部分工作是解析模板並生成 Python 程式碼。為了幫助生成 Python,我們建立了 CodeBuilder 類,它幫我們新增程式碼行,管理縮排,最後從編譯的 Python 中給出結果。

CodeBuilder 物件儲存了一個字串串列,這些字串將一起作為最終的 Python 程式碼。它需要的另一個狀態是當前的縮排級別:

CodeBuilder 做的事並不多。add_line添加了一個新的程式碼行,它會自動將文字縮排到當前的縮排級別,並提供一條新行:

indentdedent 提高或減少縮排級別:

add_section 由另一個 CodeBuilder 物件管理。這讓我們可以在程式碼中預留一個位置,隨後再新增文字。self.code 串列主要是字串串列,但也會保留對這些 section 的取用:

__str__ 使用所有程式碼生成一個字串,將 self.code 中的所有字串連線在一起。註意,因為 self.code 可以包含 sections,這可能會遞迴呼叫其他 CodeBuilder 物件:

get_globals 透過執行程式碼生成最終值。他將物件字串化,然後執行,並傳回結果值:

最後一個方法利用了 Python 的一些奇異特性。exec 函式執行包含 Python 程式碼的字串。exec 的第二個引數是一個字典,它將收集由程式碼定義的全域性變數。舉個例子,如果我們這樣做:

global_namespace['SEVENTEEN'] 是 17,global_namespace['three'] 傳回函式 three

雖然我們只使用 CodeBuilder 來生成一個函式,但是這裡沒有限制它只能做這些。這使得類更易於實現,也更容易理解。CodeBuilder 允許我們建立一大塊 Python 原始碼,並且不需要瞭解我們的模板引擎相關知識。get_globals 會傳回一個字典,使程式碼更加模組化,因為它不需要知道我們定義的函式的名稱。無論我們在 Python 原始碼中定義了什麼函式名,我們都可以從 get_globals 傳回的物件中檢索該名稱。
現在,我們可以進入 Templite 類本身的實現,看看 CodeBuilder 是如何使用的以及在哪裡使用。

實現模板類  

編譯

將模板編譯成 Python 函式的所有工作都發生在 Templite 建構式中。首先,傳入的背景關係被儲存:

這裡,使用了 python 的可變引數,可以傳入多個背景關係,且後面傳入的會改寫前面傳入的。

我們用集合 all_vars 來記錄模板中用到的變數,用 loop_vars 記錄模板迴圈體中用到的變數:

稍後我們將看到這些如何被用來幫助建構式的程式碼。首先,我們將使用前面編寫的 CodeBuilder 類來構建我們的編譯函式:

在這裡,我們構造了 CodeBuilder 物件,並開始編寫程式碼行。我們的 Python 函式將被稱為 render_function,它將接受兩個引數:背景關係是它應該使用的資料字典,而 do_dots 是實現點屬性訪問的函式。

我們建立一個名為 vars_code 的部分。稍後我們將把變數提取行寫到這一部分中。vars_code 物件讓我們在函式中儲存一個位置,當我們有需要的資訊時,它可以被填充。

然後快取了 list 的兩個方法及 str 到本地變數,正如上面所說的,這樣可以提高程式碼的效能。

接下來,我們定義一個內部函式來幫助我們緩衝輸出字串:

當我們建立大量程式碼到編譯函式中時,我們需要將它們轉換為 append 函式呼叫。我們希望將重覆的 append 呼叫合併到一個 extend 呼叫中,這是一個最佳化點。為了使這成為可能,我們緩衝了這些塊。

緩衝串列包含尚未寫入到我們的函式原始碼的字串。在我們的模板編譯過程中,我們將附加字串緩衝,當我們到達控制流點時,比如 if 陳述句,或迴圈的開始或結束時,將它們掃清到函式程式碼。

flush_output 函式是一個閉包。這簡化了我們對函式的呼叫:我們不必告訴 flush_output 要掃清什麼緩衝區,或者在哪裡掃清它;它清楚地知道所有這些。

如果只緩衝了一個字串,則使用 append_result 將其新增到結果中。如果有多個緩衝,那麼將使用 extend_result 將它們新增到結果中。

回到我們的 Templite 類。在解析控制結構時,我們希望檢查它們語法是否正確。需要用到棧結構 ops_stack:

例如,當我們遇到控制陳述句 \{\% if \%\},我們入棧 if。當我們遇到 \{\% endif \%\}時,出棧並檢查出棧元素是否為if

現在真正的解析開始了。我們使用正則運算式將模板文字拆分為多個 token。這是我們的正則運算式:

split 函式將使用正則運算式拆分一個字串。我們的樣式是圓括號,因此匹配將用於分割字串,也將作為分隔串列中的片段傳回。

(?s) 為單行樣式,意味著一個點應該匹配換行符。接下來是匹配運算式/控制結構/註釋,都為非貪婪匹配。

拆分的結果是字串串列。例如,該模板文字:

會被分隔為:

將文字拆分為這樣的 tokens 之後,我們可以對這些 tokens 進行迴圈,並依次處理它們。根據他們的型別劃分,我們可以分別處理每種型別。
編譯程式碼是對這些 tokens 的迴圈:

有幾點需要註意:

  • 使用 repr 來給文字加上引號,否則生成的程式碼會像這樣:

  • 使用 if token: 來去掉空字串,避免生成不必要的空行程式碼

迴圈結束後,需要檢查 ops_stack 是否為空,不為空說明控制陳述句格式有問題:

前面我們透過 vars_code = code.add_section() 建立了一個 section,它的作用是將傳入的背景關係解構為渲染函式的區域性變數。

迴圈完後,我們收集到了所有的變數,現在可以新增這一部分的程式碼了,以下麵的模板為例:

這裡有三個變數 user_name product_list productall_vars 集合會包含它們,因為它們被用在運算式和控制陳述句之中。

但是,最後只有 user_name product_list 會被解構成區域性變數,因為 product 是迴圈體內的區域性變數:

到此,我們程式碼就都加入到 result 中了,最後將他們連線成字串就大功告成了:

透過 get_globals  我們可以得到所建立的渲染函式,並將它儲存到 _render_function 上:

運算式

現在讓我們來仔細的分析下運算式的編譯過程。

我們的運算式可以簡單到只有一個變數名:

也可以很複雜:

這些情況, _expr_code 都會進行處理。同其他語言中的運算式一樣,我們的運算式是遞迴構建的:大運算式由更小的運算式組成。一個完整的運算式是由管道分隔的,其中第一個部分是由逗號分開的,等等。所以我們的函式自然是遞迴的形式:

第一種情形是運算式中有 |
這種情況會以 | 做為分隔符進行分隔,並將第一部分傳給 _expr_code 繼續求值。
剩下的每一部分都是一個函式,我們可以迭代求值,即前面函式的結果作為後面函式的輸入。同樣,這裡要收集函式變數名以便後面進行解構。

我們的渲染函式中的變數都加了c_字首,下同

第二種情況是運算式中沒有 |,但是有 .
則以 . 作為分隔符分隔,第一部分傳給 _expr_code 求值,所得結果作為 do_dots 的第一個引數。
剩下的部分都作為 do_dots 的不定引數。

比如, x.y.z 會被解析成函式呼叫 do_dots(x, 'y', 'z')

最後一種情況是什麼都不包含。這種比較簡單,直接傳回帶字首的變數:

工具函式  

  • 錯誤處理

  • 變數收集

渲染  

前面我們已經將模板編譯成了 python 程式碼,渲染過程就很簡單了。我們要做的就是得到背景關係,呼叫編譯後的函式:

render 函式首先將初始傳入的資料和引數進行合併得到最後的背景關係資料,最後透過呼叫 _render_function 來得到最後的結果。
最後,再來分析一下 _do_dots

前面說過,運算式 x.y.z 會被編譯成 do_dots(x, 'y', 'z')。 下麵以此為例:
首先,將 y 作為物件 x 的一個屬性嘗試求值。如果失敗,則將其作為一個鍵求值。最後,如果 y 是可呼叫的,則進行呼叫。
然後,以得到的 value 作為物件繼續進行後面的相同操作。

TODO  

為了保持程式碼的精簡,我們還有很多功能有待實現:

  • 模板繼承和包含

  • 自定義標簽

  • 自動轉義

  • 過濾器引數

  • 複雜的控制邏輯如 else 和 elif

  • 超過一個迴圈變數的迴圈體

  • 空格控制

作者:Aaaaaaaaaaayou
連結:https://juejin.im/post/5a52e87f5188257340261417



————近期開班————

馬哥聯合BAT、豆瓣等一線網際網路Python開發達人,根據目前企業需求的Python開發人才進行了深度定製,加入了大量一線網際網路公司:大眾點評、餓了麼、騰訊等生產環境真是專案,課程由淺入深,從Python基礎到Python高階,讓你融匯貫通Python基礎理論,手把手教學讓你具備Python自動化開發需要的前端介面開發、Web框架、大監控系統、CMDB系統、認證堡壘機、自動化流程平臺六大實戰能力,讓你從0開始蛻變成Hold住年薪20萬的Python自動化開發人才

10期面授班:2018年03月05號(北京)

09期網路班:騰訊課堂隨到隨學網路

掃描二維碼領取學習資料

更多Python好文請點選【閱讀原文】哦

↓↓↓

贊(0)

分享創造快樂

© 2024 知識星球   網站地圖