1. 程式人生 > 程式設計 >如何用python寫個模板引擎

如何用python寫個模板引擎

一.實現思路

  本文講解如何使用python實現一個簡單的模板引擎,支援傳入變數,使用if判斷和for迴圈語句,最終能達到下面這樣的效果:

渲染前的文字:
<h1>{{title}}</h1>
<p>十以內的奇數:</p>
<ul>
{% for i in range(10) %}
  {% if i%2==1 %}
    <li>{{i}}</li>
  {% end %}
{% end %}
</ul>


渲染後的文字,假設title="高等數學":
<h1>高等數學</h1>
<p>十以內的奇數:</p>
<ul>
<li>1</li>
<li>3</li>
<li>5</li>
<li>7</li>
<li>9</li>
</ul>

  要實現這樣的效果,第一步就應該將文字中的html程式碼和類似{% xxx %}這樣的渲染語句分別提取出來,使用下面的正則表示式可以做到:

re.split(r'(?s)({{.*?}}|{%.*?%}|{#.*?#})',html)

  用這個正則表示式處理剛才的文字,結果如下:

如何用python寫個模板引擎

  在提取文字之後,就需要執行內部的邏輯了. python自帶的exec函式可以執行字串格式的程式碼:

exec('print("hello world")') # 這條語句會輸出hello world

  因此,提取到html的渲染語句之後,可以把它改成python程式碼的格式,然後使用exec函式去執行. 但是,exec函式不能返回程式碼的執行結果,它只會返回None. 雖然如此,我們可以使用下面的方式獲取字串程式碼中的變數:

global_namespace = {}
code = """
a = 1

def func():
  pass
"""
exec(code,global_namespace)
print(global_namespace) # {'a': 1,'func': <function func at 0x00007fc61e3462a0>,'__builtins__': <module 'builtins' (built-in)>}

  因此,我們只要在code這個字串中定義一個函式,讓它能夠返回渲染後的模板,然後使用剛才的方式把這個函式從字串中提取出來並執行,就能得到結果了.

  基於上面的思路,我們最終應該把html文字轉化為下面這樣的字串:

# 這個函式不是我們寫的,是待渲染的html字串轉化過來的
def render(context: dict) -> str:
  result = []
  # 這一部分負責提取所有動態變數的值
  title = context['title']
  # 對於所有的html程式碼或者是變數,直接放入result列表中
  result.extend(['<h1>',str(title),'</h1>\n<p>十以內的奇數:</p>\n<ul>\n'])
  # 對於模板中的for和if迴圈語句,則是轉化為原生的python語句
  for i in range(10):
    if i % 2 == 1:
      result.extend(['\n    <li>',str(i),'</li>\n  '])
  result.append('\n</ul>')
  # 最後,讓函式將result列表聯結為字串返回就行,這樣就得到了渲染好的html文字
  return ''.join(result)

  如何將html文字轉化為上面這樣的程式碼,是這篇文章的關鍵. 上面的程式碼是由最開始那個html demo轉化來的,每一塊我都做了註釋. 如果沒看明白的話,就多看幾遍,不然肯定是看不懂下文的.

  總的來說,要渲染一個模板,思路如下:

如何用python寫個模板引擎

二.字串程式碼

  為了能夠方便地生成python程式碼,我們首先定義一個CodeBuilder類:

class CodeBuilder:
  INDENT_STEP = 4

  def __init__(self,indent_level: int = 0) -> None:
    self.indent_level = indent_level
    self.code = []
    self.global_namespace = None

  def start_func(self) -> None:
    self.add_line('def render(context: dict) -> str:')
    self.indent()
    self.add_line('result = []')
    self.add_line('append_result = result.append')
    self.add_line('extend_result = result.extend')
    self.add_line('to_str = str')

  def end_func(self) -> None:
    self.add_line("return ''.join(result)")
    self.dedent()

  def add_section(self) -> 'CodeBuilder':
    section = CodeBuilder(self.indent_level)
    self.code.append(section)
    return section

  def __str__(self) -> str:
    return ''.join(str(line) for line in self.code)

  def add_line(self,line: str) -> None:
    self.code.extend([' ' * self.indent_level + line + '\n'])

  def indent(self) -> None:
    self.indent_level += self.INDENT_STEP

  def dedent(self) -> None:
    self.indent_level -= self.INDENT_STEP

  def get_globals(self) -> dict:
    if self.global_namespace is None:
      self.global_namespace = {}
      python_source = str(self)
      exec(python_source,self.global_namespace)
    return self.global_namespace

  這個類作為字串程式碼的容器使用,它的本質是對字串程式碼的封裝,在字串的基礎上增加了以下的功能:

程式碼縮排
  CodeBuilder維護了一個indent_level變數,當呼叫它的add_line方法寫入新程式碼的時候,它會自動在程式碼開頭加上縮排. 另外,呼叫indent和dedent方法就能方便地增加和減少縮排.

生成函式
  由於定義這個類的目的就是在字串裡面寫一個函式,而這個函式的開頭和結尾都是固定的,所以把它直接寫到物件的方法裡面. 值得一提的是,在start_func這個方法中,我們寫了這樣三行程式碼:

append_result = result.append
extend_result = result.extend
to_str = str

  這樣做是為了提高渲染模板的效能,呼叫我們自己定義的函式,需要的時間比呼叫result.append或者str等函式的時間少. 首先對於列表的append和extend兩個方法來說,每呼叫一次,python都需要在列表中的所有方法中找一次,而直接把它繫結到我們自己定義的變數上,就能避免python重複地去列表的方法中來找. 然後是str函式,理論上,python查詢區域性變數的速度比查詢內建變數的快,因此我們使用一個區域性變數to_str,python找到它的速度就比找str要快.

  上面這段話都是我從網上看到的,實際測試了一下,在python3.7上,執行append_result需要的時間比直接呼叫result.append少了大約25%,to_str則沒有明顯的優化效果.

程式碼巢狀
  有的時候我們需要在一塊程式碼中巢狀另外一塊程式碼,這時候可以呼叫add_section方法,這個方法會建立一個新的CodeBuilder物件作為內容插入到原CodeBuilder物件裡面,這個和前端的div套div差不多.

  這個方法的好處是,你可以在一個CodeBuilder物件中預先插入一個CodeBuilder物件而不用寫入內容,相當於先佔著位置. 等條件成熟之後,再回過頭來寫入內容. 這樣就增加了字串程式碼的可編輯性.

獲取變數
  呼叫get_globals方法獲取當前字串程式碼內的所有全域性變數.

三.Template模板

  在字串程式碼的容器做好之後,我們只需要解析html文字,然後把它轉化為python程式碼放到這個容器裡面就行了. 因此,我們定義如下的Template類:

class Template:
  html_regex = re.compile(r'(?s)({{.*?}}|{%.*?%}|{#.*?#})')
  valid_name_regex = re.compile(r'[_a-zA-Z][_a-zA-Z0-9]*$')

  def __init__(self,html: str,context: dict = None) -> None:
    self.context = context or {}
    self.code = CodeBuilder()
    self.all_vars = set()
    self.loop_vars = set()
    self.code.start_func()
    vars_code = self.code.add_section()
    buffered = []

    def flush_output() -> None:

      if len(buffered) == 1:
        self.code.add_line(f'append_result({buffered[0]})')
      elif len(buffered) > 1:
        self.code.add_line(f'extend_result([{",".join(buffered)}])')
      del buffered[:]

    strings = re.split(self.html_regex,html)
    for string in strings:
      if string.startswith('{%'):
        flush_output()
        words = string[2:-2].strip().split()
        ops = words[0]
        if ops == 'if':
          if len(words) != 2:
            self._syntax_error("Don't understand if",string)
          self.code.add_line(f'if {words[1]}:')
          self.code.indent()
        elif ops == 'for':
          if len(words) != 4 or words[2] != 'in':
            self._syntax_error("Don't understand for",string)
          i = words[1]
          iter_obj = words[3]
          # 這裡被迭代的物件可以是一個變數,也可以是列表,元組或者range之類的東西,因此使用_variable來檢驗
          try:
            self._variable(iter_obj,self.all_vars)
          except TemplateSyntaxError:
            pass
          self._variable(i,self.loop_vars)
          self.code.add_line(f'for {i} in {iter_obj}:')
          self.code.indent()
        elif ops == 'end':
          if len(words) != 1:
            self._syntax_error("Don't understand end",string)
          self.code.dedent()
        else:
          self._syntax_error("Don't understand tag",ops)
      elif string.startswith('{{'):
        expr = string[2:-2].strip()
        self._variable(expr,self.all_vars)
        buffered.append(f'to_str({expr})')
      else:
        if string.strip():
          # 這裡使用repr把換行符什麼的改成/n的形式,不然插到code字串中會打亂排版
          buffered.append(repr(string))
    flush_output()
    for var_name in self.all_vars - self.loop_vars:
      vars_code.add_line(f'{var_name} = context["{var_name}"]')
    self.code.end_func()

  def _variable(self,name: str,vars_set: set) -> None:
    # 當解析html過程中出現變數,就呼叫這個函式
    # 一方面檢驗變數名是否合法,一方面記下變數名
    if not re.match(self.valid_name_regex,name):
      self._syntax_error('Not a valid name',name)
    vars_set.add(name)

  def _syntax_error(self,message: str,thing: str) -> None:
    raise TemplateSyntaxError(f'{message}: {thing}') # 這個Error類直接繼承Exception就行

  def render(self,context=None) -> str:
    render_context = dict(self.context)
    if context:
      render_context.update(context)
    return self.code.get_globals()['render'](render_context)

  首先,我們例項化了一個CodeBuilder物件作為容器使用. 在這之後,我們定義了all_vars和loop_vars兩個集合,並在CodeBuilder生成的函式開頭插了一個子容器. 這樣做的目的是,最終生成的函式應該在開頭新增類似 var_name = context['var_name']之類的語句,來提取傳入的上下文變數的值. 但是,html中有哪些需要渲染的變數,這是在渲染之後才知道的,所以先在開頭插入一個子容器,並建立all_vars這個集合,以便在渲染html之後把這些變數的賦值語句插進去. loop_vars則負責存放那些由於for迴圈產生的變數,它們不需要從上下文中提取.

  然後,我們建立一個bufferd列表. 由於在渲染html的過程中,變數和html語句是不需要直接轉為python語句的,而是應該使用類似 append_result(xxx)這樣的形式新增到程式碼中去,所以這裡使用一個bufferd列表儲存變數和html語句,等渲染到for迴圈等特殊語句時,再呼叫flush_output一次性把這些東西全寫入CodeBuilder中. 這樣做的好處是,最後生成的字串程式碼可能會少幾行.

  萬事具備之後,使用正則表示式分割html文字,然後迭代分割結果並處理就行了. 對於不同型別的字串,使用下面的方式來處理:

html程式碼塊
  只要有空格和換行符之外的內容,就放入緩衝區,等待統一寫入程式碼

帶的{{}}的變數
  只要變數合法,就記錄下變數名,然後和html程式碼塊同樣方式處理

if條件判斷 & for迴圈
  這兩個處理方法差不多,首先檢查語法有無錯誤,然後提取引數將其轉化為python語句插入,最後再增加縮排就行了. 其中for語句還需要記錄使用的變數

end語句
  這條語句意味著for迴圈或者if判斷結束,因此減少CodeBuilder的縮排就行

  在解析完html文字之後,清空bufferd的資料,為字串程式碼新增變數提取和函式返回值,這樣程式碼也就完成了.

四.結束

  最後,例項化Template物件,呼叫其render方法傳入上下文,就能得到渲染的模板了:

t = Template(html)
result = t.render({'title': '高等數學'})

以上就是如何用python寫個模板引擎的詳細內容,更多關於python寫個模板引擎的資料請關注我們其它相關文章!