Elixir元編程-第五章 建立一個HTML DSL(領域專用語言)

Elixir元編程-第五章 建立一個HTML DSL

要最大限度發揮宏的威力,莫過於構建一個 DSL了(領域專用語言)。他可讓你針對應用的專用領域,爲語言增長一個定製層。這可讓你的代碼更易編寫,對問題的解決之道也展現的更爲清晰。使用 DSL,你能夠直接將商業需求進行代碼化,能夠在一個抽象的共享層上與程序庫的調用者進行互動。javascript

咱們進一步擴展前面所學,編寫一個 HTML DSL。首先來看看 DSL 都要作什麼。而後,咱們構建一個完整的 HTML DSL,能夠經過純粹的 Elixir 代碼生成模板。構建過程當中,咱們會學到一些宏的高階特性以及運用。最後咱們回顧下什麼時候該用以及什麼時候不應用 DSL,以及如何決策。html

初探領域專用

在深刻代碼前,咱們先探討下何謂 DSL,以及元編程爲什麼實現它如此容易。在 Elixir 中,DSL 就是經過定製宏擴展的語言定義。他們是用來解決特定領域問題,而在一門語言中擴展出來的語言。在咱們的例子中,咱們的領域就是作一個 HTML 生成器。java

你可能在其餘語言中嘗試過 HTML 生成器,通常他們採用的方法是在標籤中混和源代碼的方式來生成 HTML 字符串,而後解析文件,計算結果。這個方法也可行,但你不得不使用一些徹底不一樣的模板語法,無法使用純粹的程序代碼。不便之處在於你要學習另外一套語法,並且要在不一樣語言的上下文中來回切換。web

想象一下若是你無須解析一個外部文件,直接編寫普通的 Elixir 代碼就能表達 HTML。代碼的運行結果就是生成一個完整的 HTML字符串。咱們看看用宏定義的這種 HTML DSL 應該長啥樣:編程

markup do
  div class: "row" do
    h1 do
      text title
    end
    article do
      p do: text "Welcome!"
    end
  end
  if logged_in? do
    a href: "edit.html" do
      text "Edit"
    end
  end
end
"<div class\"row\"><h1>Domain Specific Languages</h1><article><p>Welcome!</p>
</article><a href=\"edit.html\">Edit</a></div>"

因爲宏是一階特性,所以咱們能夠設想爲每一個 HTML 標記定義一個宏,而後由今生成標籤樹對應的 HTML 字符串。這個例子程序就是一個徹頭徹尾的 dsl。任何人只要掃一眼,就會立刻明白這些代碼所表達的 HTML。這個 lib 容許咱們在 Elixir 語言相同的上下文中編寫 HTML,能夠集中精力解決感興趣的問題。這也是咱們將要設計的程序庫。開始吧。跨域

從最小可行的 API 定義開始

如今咱們知道想要的 DSL 是什麼樣子了,咱們須要決定如何設計 API。HTML 標準包含 117 個有效的標籤,但咱們構建 DSL 只須要很小一部分。頗有可能你想當即打開編輯器,直接爲全部標籤編寫 117 個獨立的宏。可是有更好的方式。既然咱們要建立 DSL,要用宏定義一門迷你語言,那麼最好的方式何不爲這門語言定義一個最小規模的宏定義的集合,這些宏定義又會爲更大規模的宏定義提供基礎。與其用宏定義全部的 HTML 規範,不如先定義一組超精簡的宏,實現 HTML 的標準動做。編輯器

咱們的 HTML 庫最小的 API 包含一個 tag 宏,用來實現標籤構造,一個 text 宏用來注入文本,一個 markup 宏用來包裹生成的全部的塊。這三個宏聚焦基礎,是咱們應用的基石。有了這三個宏,咱們就能夠快速構造一個可用版本,而後不斷加強。函數式編程

重寫前面的示例代碼,這裏咱們只用這三個宏:函數

markup do
  tag :div, class: "row" do
    tag :h1 do
      text title
    end
    tag :article do
      tag :p, do: text "Welcome!"
    end
  end
  if logged_in? do
    tag :a, href: "edit.html" do
      text "Edit"
    end
  end
end
"<div class\"row\"><h1>Domain Specific Languages</h1><article><p>Welcome!</p>
</article><a href=\"edit.html\">Edit</a></div>"

構建咱們的HTML庫,首先我要確保可以支持這些最精簡的 API。這些最精簡的 API 固然不像完整的 DSL 那樣整潔,但咱們仍是可以充分表達 HTML 的意圖。一旦這幾個初始化的宏就緒,咱們就可以用 tag 宏做爲基礎,支持定義所有的 117個 HTML 標籤的宏了。如今咱們知道如何開始了,開幹吧。post

讓咱們列出最小 HTML API 的功能需求。首先要能支持 markup,tag,text 宏。其次顯而易見,在 markup 生成階段,咱們的程序庫必須可以維護輸出緩衝的狀態。由於咱們可以在 DSL 任意混合 Elixir 表達式,所以咱們必須在程序運行時存儲生成的 HTML 狀態。

要理解咱們的程序爲何須要可變狀態(mutable state,注:函數式編程通常提倡的是不可變),咱們想象一下,咱們試圖在每一次 tag 宏調用從新綁定緩衝變量時保持狀態。下面生成的模擬代碼,用 buff 變量來跟蹤緩衝狀態。

markup do # buff = ""
  div do # buff = buff <> "<div>"
    h1 do # buff = buff <> "<h1>"
      text "hello" # buff = buff <> "hello"
    end # buff = buff <> "</h1>"
  end # buff = buff <> "</div>"
end # buff

iex> buff
"<div><h1>hello</h1></div>"

每一次 tag 或 text 調用時會從新綁定 buff,這種方式適用於基本處理。在咱們介紹更簡單的解決方案前,想象一下下面的代碼,當咱們加入一個 for 語句時會發生什麼。

markup do # buff = ""
  tag :table do        # buff = buff <> "<table>
    tag :tr do         # buff = buff <> "<tr>"
      for i <- 0..3 do # >------>------->----------->
        tag :td do     # | buff = buff <> "<td>" |
          text "#{i}"  # ^ buff = buff <> "#{i}" v
        end            # | buff = buff <> "</td>" |
      end              # <------<-------<-----------<
    end                # buff = buff <> "</tr>"
  end                  # buff = buff <> "</table>"
end                    # buff

iex> buff
"<table><tr></tr></table>"

執行 for 語句前一切正常,碰到 for 語句就完了。生成的狀態並無反映在緩衝數據中,全部的 td 標籤都丟失了,由於變量做用域的緣由,內層嵌套的綁定數據是沒法釋放到外部的上下文中。即使沒有做用域的問題,這種變量的動態重綁定在 Elixir 的 for 語句裏面也是不支持的。你能夠本身試試下在 iex 裏運行 for 語句,看看能不能重綁定一個變量:

iex> buff = ""
""
iex> for i <- 1..3 do
  ...> buff = buff <> "#{i}"
  ...> IO.inspect buff
  ...> end
"1"
"2"
"3"
["1", "2", "3"]
iex> buff
""

看到沒,咱們無法用變量重綁定來保持輸出緩衝。咱們必須另謀出路,解決每次 tag 或 text 調用時更新當前緩衝的問題。幸運的是,Elixir 裏有個 Agent 模塊,可以完美解決每次 tag 生成後刷新 buffer 的問題。

使用 Agent 保持狀態

Elixir Agent 提供了一種簡單的方式用於存儲和獲取數據狀態。下面咱們看下使用 Agent 進程管理狀態有多簡單。在 iex 裏咱們試下:

iex> {:ok, buffer} = Agent.start_link fn -> [] end
{:ok, #PID<0.130.0>}

iex> Agent.get(buffer, fn state -> state end)
[]

iex> Agent.update(buffer, &["<h1>Hello</h1>" | &1])
:ok

iex> Agent.get(buffer, &(&1))
["<h1>Hello</h1>"]

iex> for i <- 1..3, do: Agent.update(buffer, &["<td><#{i}</td>" | &1])
[:ok, :ok, :ok]

iex> Agent.get(buffer, &(&1))
["<td><3</td>", "<td><2</td>", "<td><1</td>", "<h1>Hello</h1>"]

Agent模塊API很是簡單,聚焦於快速訪問狀態。上面的例子中,咱們用初始狀態[]啓動一個 Agent。而後,往 buffer 列表中寫入些字符串,而後結束更新狀態。對於 HTML DSL 的輸出緩衝咱們採用相似存儲方式。

學習了 Agent 新技能,咱們編輯文件 html_step1.exs,定義 Html 模塊,編寫 API: html/lib/html_step1.exs

defmodule Html do

  defmacro markup(do: block) do 
    quote do
      {:ok, var!(buffer, Html)} = start_buffer([])
      unquote(block)
      result = render(var!(buffer, Html))
      :ok = stop_buffer(var!(buffer, Html))
      result
    end
  end

  def start_buffer(state), do: Agent.start_link(fn -> state end) 

  def stop_buffer(buff), do: Agent.stop(buff)

  def put_buffer(buff, content), do: Agent.update(buff, &[content | &1]) 

  def render(buff), do: Agent.get(buff, &(&1)) |> Enum.reverse |> Enum.join("") 

  defmacro tag(name, do: inner) do 
    quote do
      put_buffer var!(buffer, Html), "<#{unquote(name)}>"
      unquote(inner)
      put_buffer var!(buffer, Html), "</#{unquote(name)}>"
    end
  end

  defmacro text(string) do 
    quote do: put_buffer(var!(buffer, Html), to_string(unquote(string)))
  end
end

第3行,咱們定義了 markup 宏,他用來包裹完整的 HTML 生成塊。markup 完成三個動做。首先,第13行的 start_buffer 函數開啓一個 Agent。Agent 會保存一個包含全部 tag 和 text 輸出的列表。而後咱們注入來自調用者的代碼塊,代碼塊中包含全部的 tag 和 text 宏調用。最後調用 render 函數完成 markup 代碼。第19行定義的 render 函數獲取 Agent 狀態而後將全部的 buffer 片斷組合成最終的輸出字符串。而後,咱們要在結果返回前終止 Agent 進程,它的使命也完結了。

除了 markup 代碼跟 Agent 函數,咱們還定義了 tag 和 text 宏來完成主要的宏的功能。tag 使用 put_buffer 調用將調用者的 inner 代碼塊包裹起來,它會環繞 inner contents 造成一對開閉的 HTML 標籤。下面咱們看看一系列嵌套的 tag 如何工做的:

tag :div do
  tag: span do
    Logger.info "We can mix regular Elixir code here"
    text "Nested tags are no trouble for our buffer"
  end
end

編譯時這些代碼會轉化成:

put_buffer(var!(buffer, Html), "<div>")
put_buffer(var!(buffer, Html), "<span>")
Logger.info "We can mix regular Elixir code here"
put_buffer(var!(buffer, Html), "Nested tags are no trouble for our buffer")
put_buffer(var!(buffer, Html), "</span>")
put_buffer(var!(buffer, Html), "</div>")

並不複雜,對吧?有了 Agent 來保持狀態, tag 宏只須要生成正確的 put_buffer 調用,確保任何嵌套的 block 都被一對開閉的標籤包裹就能夠了。相似的,text 宏只須要生成一個簡單的 put_buffer 調用就行,它會將傳入參數轉化爲字符串。

破壞宏衛生確實是萬惡之源。使用必定要謹慎

重要提示,要認識到咱們的模塊從頭至尾使用了 buffer 變量,這已經破壞了宏衛生。破壞宏衛生才能容許咱們在每個 quote balock 中引用派生出的 Agent 進程,由於咱們直接使用了 var! 來訪問外部的上下文。最重要的是,咱們將 Html 做爲第二個參數,所以 buffer 變量的上下文才能限制在咱們的模塊中。若是咱們不包括 Html 參數,咱們的 buffer 變量就須要暴露到調用者的上下文中,調用者也能夠任意訪問了。這個案例說明了是否破壞宏衛生須要權衡考慮。咱們能夠將狀態存儲隱藏到幕後,以免調用者的 Html 上下文中定義的 buffer 變量引起的衝突。

開始測試

讓咱們快速構建一個 Temple 模塊,來測試下這些 API 的功能。

編輯 html_step1_render.exs 文件,添加:

html/lib/html_step1_render.exs

defmodule Template do
  import Html

  def render do
    markup do
      tag :table do
        tag :tr do
          for i <- 0..5 do
            tag :td, do: text("Cell #{i}")
          end
        end
      end
      tag :div do
        text "Some Nested Content"
      end
    end
  end
end

第4行,咱們隨便定義了一個 render 函數,裏面是 markup 生成器。在 iex 裏面測試一下:

iex> c "html_step1.exs"
[Html]

iex> c "html_step1_render.exs"
[Template]

iex> Template.render
"<table><tr><td>Cell 0</td><td>Cell 1</td><td>Cell 2</td><td>Cell 3</td>
<td>Cell 4</td><td>Cell 5</td></tr></table><div>Some Nested Content</div>"

僅僅依靠 markup,tag,text 宏,咱們已經生成了 HTML 字符串,幕後是使用 buffer Agent 進行狀態存儲(對咱們都是透明的)。咱們的 DSL 開口說了他的第一句話。接下來咱們將支持完整的 HTML 規格,進一步優化它。

使用宏支持完整的 HTML 規格

開局完美,但咱們的目標是建立一個一階的 DSL。一個簡單的 tag 宏還不足以完成。讓咱們支持所有有效的 117 個 HTML 標籤。咱們還須要手工編寫上百個宏,但咱們可使用第三章的進階技術,節約時間,簡化工做。

老調重彈,咱們仍是到網上搜索下完整的 HTML 標籤清單。將其拷貝粘貼到文本文件中,最後一個標籤是行分隔符。下面摘抄文件部份內容:

html/lib/tags.txt

form
frame
frameset
h1
head
header

咱們利用這個文件生成完整的 HTML 規格。將文件保存到 Html 模塊的同一目錄下,名字改成 tags.txt。如今修改 Html 源碼,加入代碼解析 tags.txt 生成宏定義。新的文件命名爲 html_step2.exs。

html/lib/html_step2.exs

defmodule Html do

  @external_resource tags_path = Path.join([__DIR__, "tags.txt"])
  @tags (for line <- File.stream!(tags_path, [], :line) do
           line |> String.strip |> String.to_atom
         end)

  for tag <- @tags do
    defmacro unquote(tag)(do: inner) do
      tag = unquote(tag)
      quote do: tag(unquote(tag), do: unquote(inner))
    end
  end

  defmacro markup(do: block) do
    quote do
      import Kernel, except: [div: 2]
      {:ok, var!(buffer, Html)} = start_buffer([])
      unquote(block)
      result = render(var!(buffer, Html))
      :ok = stop_buffer(var!(buffer, Html))
      result
    end
  end
# ...
end

在第4行,咱們逐行讀取 tags.txt 文件,而後將其轉化爲 atom,存入到 @tags 屬性中。而後在第8行咱們使用 for 語句爲每個 tag 定義一個宏,tag 名稱就是從文件中讀取後轉爲 atom 的名稱。每個宏都只是簡單的轉發到 tag 宏。

還有一件重要的事就是在第17行,咱們排除掉 Kernel.div,禁止 import 到咱們的 markup 塊中,由於這個名稱同通用的 <div> 標籤衝突了。屏蔽掉 Kernel.div 倒還問題不大,由於實在要引用能夠加上模塊名。咱們再次使用了 @external_resource 以確保 Html 在 tags.txt 發生變化時能夠自動重編譯。

如今咱們使用新的宏來渲染一些 HTML。建立一個新的 Template 模塊,文件命名 html_step2_render.exs:

html/lib/html_step2_render.exs

defmodule Template do
  import Html

  def render do
    markup do
      table do
        tr do
          for i <- 0..5 do
            td do: text("Cell #{i}")
          end
        end
      end
      div do
        text "Some Nested Content"
      end
    end
  end
end

咱們用新生成的宏替換掉全部的 tag 調用。在 iex 裏面測試下:

iex> c "html_step2.exs"
[Html]

iex> c "html_step2_render.exs"
[Template]

iex> Template.render
"<table><tr><td>Cell 0</td><td>Cell 1</td><td>Cell 2</td><td>Cell 3</td>
<td>Cell 4</td><td>Cell 5</td></tr></table><div>Some Nested Content</div>"

工做良好,咱們利用編譯時的代碼生成技術實現了全規格的 HTML DSL。從一個具備三個宏的 DSL ,咱們擴展出一個包含上百個宏的 DSL,代碼乾淨可維護。將來一旦 HTML 標籤有了新增,咱們只需編輯 tags.txt 文件就可支持最新的規格。

構建 DSL 咱們已經走了很遠,但工做還沒有結束。讓咱們繼續支持其餘的 HTML 特性。

加強API,添加HTML屬性支持

若是咱們但願 HTML 庫具備實用價值,咱們還必須支持標籤屬性,好比 class 和 id。讓咱們擴展 DSL,以支持可選的關鍵字列表,在宏裏它會被轉化成標籤屬性。

示例,咱們的 API 應該相似以下:

div id: "main" do
  h1 class: "title", do: text("Welcome!")
  div class: "row" do
    div class: "column" do
      p "Hello!"
    end
  end
  button onclick: "javascript: history.go(-1);" do
    text "Back"
  end
end

讓咱們研究下 Html 模塊,添加標籤屬性的支持。將 tag/2 宏跟 for tag <- @tags 語句修改以下,文件存爲 html_step3.exs。

html/lib/html_step3.exs

for tag <- @tags do 
    defmacro unquote(tag)(attrs, do: inner) do
      tag = unquote(tag)
      quote do: tag(unquote(tag), unquote(attrs), do: unquote(inner))
    end
    defmacro unquote(tag)(attrs \\ []) do
      tag = unquote(tag)
      quote do: tag(unquote(tag), unquote(attrs))
    end
  end

  defmacro tag(name, attrs \\ []) do
    {inner, attrs} = Dict.pop(attrs, :do)
    quote do: tag(unquote(name), unquote(attrs), do: unquote(inner))
  end
  defmacro tag(name, attrs, do: inner) do
    quote do
      put_buffer var!(buffer, Html), open_tag(unquote_splicing([name, attrs])) 
      unquote(inner)
      put_buffer var!(buffer, Html), "</#{unquote(name)}>"
    end
  end

  def open_tag(name, []), do: "<#{name}>" 
  def open_tag(name, attrs) do
    attr_html = for {key, val} <- attrs, into: "", do: " #{key}=\"#{val}\""
    "<#{name}#{attr_html}>"
  end

第1行咱們修改 for 語句,爲每一個標籤生成多個宏的 head。這樣就能夠把屬性列表傳遞給宏。咱們還額外添加了一個 tag 宏用於處理可選屬性。在第24行,咱們定義了一個 open_tag 函數用來處理帶屬性列表的 HTML 標籤。會在原有的 tag 定義內分派到這個宏。這裏咱們也第一次使用了 unquote_splicing。

unquote_splicing 宏的行爲相似於 unquote,但它不是注入單個值,而是注入一個參數列表到 AST 中。好比,下面代碼是等價的:

quote do
  put_buffer var!(buffer), open_tag(unquote_splicing([name, attrs]))
end

quote do
  put_buffer var!(buffer), open_tag(unquote(name), unquote(attrs))
end

當你須要注入一個參數列表時使用 unquote_splicing 很是方便,特別是編譯時這些參數長度不一的話。

咱們已經能夠支持標籤屬性,讓咱們在 iex 裏測試下。更新 Template 模塊,保存爲 html_step3_render.exs。

html/lib/html_step3_render.exs

defmodule Template do
  import Html

  def render do
    markup do
      div id: "main" do
        h1 class: "title" do
          text "Welcome!"
        end
      end
      div class: "row" do
        div do
          p do: text "Hello!"
        end
      end
    end
  end
end

在 iex 里加載這些文件,渲染一下你剛剛建立的模板:

iex> c "html_step3.exs"
[Html]

iex> c "html_step3_render.exs"
[Template]

iex> Template.render
"<div id=\"main\"><h1 class=\"title\">Welcome!</h1>
</div><div class=\"row\"><div><p>Hello!</p></div></div>"

很不錯,咱們如今有了一個穩健的 HTML DSL 了,讀寫都很容易。你能夠用純 Elixir 代碼來編寫整個 web 應用的模板了,咱們的程序庫還能夠隨着 HTML 規格變化而不斷擴展。區區60餘行代碼,咱們的 DSL 已有小成,甚至能夠支持上百個宏。

但咱們不會止步於此。接下來, you’ll find out ways Elixir lets us trim this footprint down even further.

遍歷AST以生成更少的代碼

咱們的 Html 模塊清晰優雅,但咱們不得不生成上百個宏使其運做。有沒有什麼辦法可以縮減代碼,同時不損害 DSL 的表現力,並且擁有全部的標籤宏調用呢?咱們來創造奇蹟吧。

你也許以爲不生成全部的 HTML 宏,DSL 根本無法用,思考下 Elixir 給了你所有 AST 的訪問能力。設想下,咱們打開 iex ,quote 任意一段 HTML DSL 表達式,咱們看看結果。不要載入你的 Html 模塊,咱們就是要看看在脫離程序庫的狀況下原始的表達式會 quote 成啥樣:

iex> ast = quote do
...> div do
...> h1 do
...> text "Hello"
...> end
...> end
...> end
{:div, [], [[do: {:h1, [], [[do: {:text, [], ["Hello"]}]]}]]}

看上去很簡單,是否是?咱們獲得了 DSL 宏的 AST 表現形式。咱們能夠看到宏調用整齊的嵌入到一個三元組裏面。想一想看,咱們不用生成全部的 HTML 標籤宏,咱們能夠遍歷 AST,一段段地將 AST 節點,好比 {:div, [] [[do: ...]]} 轉化成 tag :div do ... 宏調用。事實上,Elixir已經有內建函數幫咱們幹這事了。

Elixir包含兩個函數 Macro.prewalk/2 和 Macro.postwalk/2 可讓咱們遍歷 AST,一個是深度優先,一個是廣度優先。讓咱們用 IO.inspect 監測當咱們遍歷 AST 時發生了什麼。

iex> Macro.postwalk ast, fn segment -> IO.inspect(segment) end
:do
:do
"Hello"
{:text, [], ["Hello"]}
{:do, {:text, [], ["Hello"]}}
[do: {:text, [], ["Hello"]}]
{:h1, [], [[do: {:text, [], ["Hello"]}]]}
{:do, {:h1, [], [[do: {:text, [], ["Hello"]}]]}}
[do: {:h1, [], [[do: {:text, [], ["Hello"]}]]}]
{:div, [], [[do: {:h1, [], [[do: {:text, [], ["Hello"]}]]}]]}
{:div, [], [[do: {:h1, [], [[do: {:text, [], ["Hello"]}]]}]]}

iex> Macro.prewalk ast, fn segment -> IO.inspect(segment) end
{:div, [], [[do: {:h1, [], [[do: {:text, [], ["Hello"]}]]}]]}
[do: {:h1, [], [[do: {:text, [], ["Hello"]}]]}]
{:do, {:h1, [], [[do: {:text, [], ["Hello"]}]]}}
:do
{:h1, [], [[do: {:text, [], ["Hello"]}]]}
[do: {:text, [], ["Hello"]}]
{:do, {:text, [], ["Hello"]}}
:do
{:text, [], ["Hello"]}
"Hello"
{:div, [], [[do: {:h1, [], [[do: {:text, [], ["Hello"]}]]}]]}

若是咱們看得夠仔細,咱們能夠看到 Macro.postwalk 和 Macro.prewalk 遍歷 AST 而後將每個 segment 發送到後面的函數。咱們也能清楚地看到在形如 {:text, [], ["Hello"]} 的 segments 中咱們的宏調用。這些函數用來加強 AST,但這裏咱們只打印下內容,而後原封不動地返回結果。

讓咱們刪除 Html 模塊中的 117 個宏。咱們生成遍歷 AST 中看到的代碼來替換它。以下更新 Html 模塊,文件保存爲 html_macro_walk.exs:

html/lib/html_macro_walk.exs

defmodule Html do

  @external_resource tags_path = Path.join([__DIR__, "tags.txt"])
  @tags (for line <- File.stream!(tags_path, [], :line) do
    line |> String.strip |> String.to_atom
  end)

  defmacro markup(do: block) do
    quote do
      {:ok, var!(buffer, Html)} = start_buffer([])
      unquote(Macro.postwalk(block, &postwalk/1)) 
      result = render(var!(buffer, Html))
      :ok = stop_buffer(var!(buffer, Html))
      result
    end
  end

  def postwalk({:text, _meta, [string]}) do 
    quote do: put_buffer(var!(buffer, Html), to_string(unquote(string)))
  end
  def postwalk({tag_name, _meta, [[do: inner]]}) when tag_name in @tags do 
    quote do: tag(unquote(tag_name), [], do: unquote(inner))
  end
  def postwalk({tag_name, _meta, [attrs, [do: inner]]}) when tag_name in @tags do 
    quote do: tag(unquote(tag_name), unquote(attrs), do: unquote(inner))
  end
  def postwalk(ast), do: ast 

  def start_buffer(state), do: Agent.start_link(fn -> state end)

  def stop_buffer(buff), do: Agent.stop(buff)

  def put_buffer(buff, content), do: Agent.update(buff, &[content | &1])

  def render(buff), do: Agent.get(buff, &(&1)) |> Enum.reverse |> Enum.join("")

  defmacro tag(name, attrs \\ [], do: inner) do
    quote do
      put_buffer var!(buffer, Html), open_tag(unquote_splicing([name, attrs]))
      unquote(postwalk(inner)) 
      put_buffer var!(buffer, Html), unquote("</#{name}>")
    end
  end

  def open_tag(name, []), do: "<#{name}>"
  def open_tag(name, attrs) do
    attr_html = for {key, val} <- attrs, into: "", do: " #{key}=\"#{val}\""
    "<#{name}#{attr_html}>"
  end
end

咱們修改第11行的 markup 定義,調用 Macro.postwalk 來遍歷調用者傳入的代碼塊。18到27行,咱們替換 for 語句,這個語句以前是用來產生 117 個標籤宏的,如今只需調用四個 postwalk 函數。這四個函數使用基本的模式匹配,抽取 AST 片斷,將其轉換成正確的 HTML 標籤。讓咱們分析下這些函數是怎麼作的。

第18行的 postwalk 函數經過模板匹配到 text 宏調用的 AST 片斷,返回一個 quoted 的 put_buffer 調用。參數被轉化成字符串,正如咱們以前步驟中的 text 宏定義。接下來,在第21行咱們咱們經過模板匹配 117 個 HTML 標籤的 AST 片斷。這裏咱們用了一個衛兵語句 when tag_name in @tags 來匹配 AST tuple 中的第一個元素。若是咱們發現了匹配 HTML tag 的片斷,咱們將其轉化成一個 tag 宏調用。最後,在第27行,咱們添加了一個 catch-all postwalk 函數用來原樣返回咱們的 DSL 無需定義的部分。咱們用前面學到的 Macro.to_string 來查看 postwalk 函數生成的代碼。

打開 iex,載入 html_macro_walk.exs 文件,輸入以下內容:

iex> c "html_macro_walk.exs"
[Html]

iex> import Html
nil

iex> ast = quote do
  ...> markup do
    ...> div do
      ...> h1 do
        ...> text "Some text"
        ...> end
      ...> end
    ...> end
  ...> end

iex> ast |> Macro.expand(__ENV__) |> Macro.to_string |> IO.puts
(
  {:ok, var!(buffer, Html)} = start_buffer([])
  tag(:div, []) do
    tag(:h1, []) do
      put_buffer(var!(buffer, Html), to_string("Some text"))
    end
  end
  result = render(var!(buffer, Html))
  :ok = stop_buffer(var!(buffer, Html))
  result
  )
:ok

咱們 quoted 了一段 markup 代碼塊,而後使用 Macro.expand 和 Macro.to_string 來窺探下咱們的 postwalk 轉換生成的代碼。咱們能夠看到 postwalk 函數正確地將 HTML 標籤轉換成了 tag 宏調用。所有使用特定模板匹配原始 AST,這是個很是高級的練習。工做原理可能一時會理解不了,也不要太擔憂。Macro.postwalk 遍歷 AST,而後轉換每個片斷,咱們能夠看到是如何匹配代碼片斷,如何對 117 個宏進行替換。你不會常常用到 Macro.postwalk 或者 Macro.prewalk,但這是兩把利器,可讓咱們無需定義 quoted 表達式中須要的每個宏,咱們只須要轉換整個 AST 就好了。

如今你的 DSL 經驗又升級了,咱們再回顧下什麼時候何地才須要 DSL。

用或不用 DSL?

DSL 確實很酷炫吧?會不會有種想用它解決全部問題的衝動,可是要當心,不少問題看似很適合用 DSL,可是用標準函數會解決得更好。不管什麼時候咱們試圖用 DSL 解決問題,都要問本身幾個問題:

  1. 解決這類問題用到的宏是否能很好的融入到 Elixir 語法中,就像 HTML 標籤同樣?
  2. 定義的 DSL 是有助於使用者聚焦於解決問題自己,仍是正好相反?
  3. 咱們程序庫的使用者是否真的願意將一大堆亂七八糟的代碼注入到本身的程序中?

對這些問題沒有統一的標準,不少時候都是模棱兩可的。爲進一步說明這些問題,咱們假設要建立一個 Emailer 庫。初看上去,一個 email 構成很簡單,無非是 from,to,subject,send。所以咱們會遇到上面的第一個問題,答案是這個問題能夠很天然的用宏進行表達。順着這個思路,咱們構想程序庫的 DSL 應該以下:

defmodule UserWelcomeEmail do
  use Emailer
  
  from "info@example.com"
  reply_to "info@example.com"
  subject "Welcome!"
  
  def deliver(to, body) do
    send_email to: to, body: body
  end
end

UserWelcomeEmail.deliver("user@example.com", "Hello there!")

還不賴,調用者使用 Emailer,而後圍繞 email 頭,如 from,reply_to 等等擁有了一套 DSL。代碼可讀性也不錯,但如今要問第二個問題了。定義的 DSL 是有助於使用者聚焦於解決問題自己,仍是正好相反?舉個例子,用戶這時候忽然想添加一個自定義的頭,好比說"X-SERVICE-ID"?由於 email 規格支持任意 header,要求很合理,可你的 DSL 當即陷入了被動。一個快速的解決方案是支持一個可選的 headers 函數,讓調用者能夠定製 headers:

defmodule UserWelcomeEmail do
  use Emailer
  
  from "info@example.com"
  reply_to "info@example.com"
  subject "Welcome!"
  
  def headers do
    %{"X-SERVICE-ID" => "myservice"}
  end
  
  def deliver(to, body) do
    send_email to: to, body: body
  end
end

這法子還行得通,能夠如今調用者必須知道 DSL 都支持哪些 headers,什麼時候須要本身再定義一個 headers map。如今咱們看下沒有 DSL 的解決方案。只須要調用者定義一個 headers 函數,而後返回一個須要的全部的 email headers 的 map 就好了。

defmodule UserWelcomeEmail do
  use Emailer
  
  def headers do
    %{"from" => "info@example.com",
      "reply-to" => "info@example.com",
      "subject" => "Welcome!",
      "X-SERVICE-ID" => "myservice"}
  end
  
  def deliver(to, body) do
    send_email to: to, body: body
  end
end

這個例子中,傳統方案明顯勝出。代碼清晰,可讀行良好,徹底不須要 DSL。

第三個問題讓咱們思考調用者是否真的想要將一大堆代碼注入到本身的模塊中。有時答案很明確,確實須要,而有時爲了實現一個小小的功能,好比發送郵件,就將一大堆宏跟代碼注入到你的模塊中,未免小題大作。這容易引起同用戶的代碼衝突,也會增長問題複雜性,這時候使用傳統的函數會是更好的解決方案。

基於這些判斷,Emailer 庫不會是一個好的 DSL。直接了當的函數更易使用,爲了發送個郵件信息,還要學套特定的 DSL 語法得不償失。DSL 確實很強大,但你要好好考慮你的特定問題是否適合用它解決。不少時候 DSL 語法很簡潔,但有時這就會變成一種限制。用不用它要具體分析,每次你想用 DSL 的時候都問下本身上面的三個問題,會讓你頭腦清醒的多。

進一步探索

使用 DSL 在語言當中定義一種語言,咱們如今元編程技能暴漲。這種解決問題的方式,將問題化解成一堆很是天然的宏,會讓你建立更富有表現力的程序庫。你看到了某些領域,好比 HTML 生成,就完美地融入到 DSL 中,而另一些就須要仔細權衡了。琢磨一下你能夠給 HTML DSL 再擴展些什麼。這有一些意見可供參考:

  • 擴展 Html ,提供格式化良好的輸入:
iex> Template.render
"<div id=\"main\">
  <h1 class=\"title\">Welcome!</h1>
</div>
<div class=\"row\">
  <div>
    <p>Hello!</p>
  </div>
</div>"
  • 去除全部的 text input 框,以防止跨域攻擊:
defmodule Template do
  import Html
  def render do
    markup do
      div id: "main" do
        text "XSS Protection <script>alert('vulnerable?');</script>"
      end
    end
  end
end

iex> Template.render
"<div id=\"main\">
XSS Protection &lt;script&gt;alert(&#39;vulnerable?&#39;);&lt;/script&gt;
</div>"
相關文章
相關標籤/搜索