在討論 Phoenix 的 web.ex 之前,我們先來聊聊 Elixir 中我最喜歡的特性,macro。熟悉 Ruby 的人,可能會知道 Ruby 帶有許多好用的 meta-programming 功能,可以把程式做為資料,在執行期操作,像是 method_missing
、instance_eval
等。
Macro 常被描述成是 Elixir 的 mata programming 功能,但其實它遠不止於此。 Elixir 語言本身的大部份,就是靠 macro 這個機制建構起來的。你所使用的 def
、 if
、+
等等,說穿了都只是個 macro 而己。我自己喜歡這樣想:作者 José Valim 在重新設計* Elixir 這個語言時,最先打造了 macro 這個抽象工具,並用它完成了這語言剩下大部份的功能。但他沒有把這個工具藏起來,而是把這工具也交給 programmer,讓你也能有改造語言本身的能力。
但在開始之前,就像所有的 meta programming 教學一樣,總是要帶個警語的。想要在 production 環境裡使用這些高階功能,請務必先默念一下蜘蛛人法則,也就是能力愈強,責任也愈大。具體來說,就是兩條規則:
所以說把什麼東西都做成其實很難用很難學的 internal DSL 顯然不是個好方向。但是亂搞是非常有趣的,從亂搞之中我們也可以學到許多很棒的知識。正如我景仰的 Gea-Suan Lin 所說:幹壞事是進步最大的原動力。
Note *: José 在幾次演講中提過,Elixir 自 2013 開始開發時並沒有規劃得很好,在進行了幾個月後遇到各種重大困難就停滯了。而在停了約半年之後他們重新思索整體的架構,把整個專案打掉重新來過。
你有沒有想過你寫在程式檔案裡的東西,例如說 1 + 2
,是怎麼跑起來的?把 iex 打開,我們這樣來試試看:
iex> quote do: 1 + 2
{:+, [context: Elixir, import: Kernel], [1, 2]}
quote/2
是 macro 中的核心語法。它會將一個 Elixir 的表達式,拆解成一個 3-tuple,第一個是用 atom 表示的運算子,也就是函式名稱,第二個參數是個 keyword list,裡面放了環境參數,而第三個也是一個串列,代表這個函式呼叫所帶上的參數。
那如果是長一點的式子呢:
iex> quote do: 1 + 2 * 3
{:+, [context: Elixir, import: Kernel],
[1, {:*, [context: Elixir, import: Kernel], [2, 3]}]}
在第三個串列,參數列表裡的第二個元素,現在也是另一個 3-tuple 了。當表達式愈複雜, 每個 3-tuple 的參數都可能包含另一個 3-tuple,這模式會一直延展下去。
當我們在文字檔案裡寫下程式碼,一直到進入 VM 執行之前,發生了四件事。首先把一個個的字元切成對這個語言有意義的元素,例如 if
、3
、end
等等,這一步叫 tokenize。接著將一個個的 token 依語法規則組成有意義的執行結構,這一步叫 parse,所產出的結果會是一張樹狀圖,叫做 Abstract Syntax Tree (AST、抽象語法樹)。再接著依 AST 產生 VM 用的執行指令,這一步叫 compile。
而 Elixir 的 macro,就是將解析完成的抽象語法樹用 3-tuple 的型式交還給你,讓你可以直接動手亂改。當你把改好的 3-tuple 遞回去,他就會幫你執行這份新的結構。我們這樣試試看:
iex> ast = {:-, [], [20, 10]} #先做一個語法樹
iex> Code.eval_quoted(ast)
{10, []}
除了手動處理 3-tuple 之外,Elixir 還提供了 defmacro
來操作 AST。我們先新增一個檔案叫 bad_math.exs
,並輸入以下的內容:
defmodule BadMath do
defmacro calc({_op, _, [x, y]}) when is_integer(x) and is_integer(y) do
{:+, [], [x, y]}
end
end
BadMath.calc/1
會幫我們把運算式解開,但不管是哪一種四則運算,我們都把它改成 +/2
。
在檔案所在的目錄下打開 iex。由於 macro 需要進行編譯及引用才能操作,所以我們要先輸入兩個指令:
iex> c "bad_math.exs"
[BadMath]
iex> require BadMath
BadMath
接著就可以來操作看看了:
iex> BadMath.calc 100 - 99
199
上面的例子雖然很酷,但是要每次手動組 AST 的 3-tuple 實在太累了。有沒有什麼語法可以把表達式直接轉成 AST 呢?等等,我們一開始不就是用了 quote/2
來做這件事嗎?
defmacro calc({_op, _, [x, y]}) when is_integer(x) and is_integer(y) do
quote do: x + y
end
但在繼續之前請先停一下,因為這樣是不能動的。這個 quote 語法實際上會產生 {:+, [], [x, y]}
,而發現找不到 x
及 y
這兩個變數。我們需要有個方式在 quote/2
裡跟它說這邊的變數是需要替換成值的,這就是 unquote/1
的功能了:
defmacro calc({_op, _, [x, y]}) when is_integer(x) and is_integer(y) do
quote do: unquote(x) + unquote(y)
end
回到 iex,我們要重新載入改好的模組:
iex> r BadMath
{:reloaded, BadMath, [BadMath]}
iex> BadMath.calc 100 - 99
199
Elixir 中的 macro 是個很大的題目,限於篇幅我們就在這裡停步了。如果你也很喜歡這個部份,可以去看 Pragmatic Bookshelf 出的 Metaprogramming Elixir ,本書作者就是 Phoenix 的作者 Chris McCord,內容非常有啟發性。
另外我自己做的 Elixir 套件 pipe_to,可以讓你把參數 pipe 到其它的位置。簡單來說,就是在 pipe 的時候,找到語法裡的 _
,推導出參數的位置,再把 _
從 AST 裡拿掉。其原始碼也是個可以參考的有趣小範例。
回到 Phoenix 裡,那些透過 use 模組名稱
,讓這個模組變成 Controller、Router、View 的機制,就是用 macro 來完成的。而這件事在 Phoenix 中被寫在一個可以客製化的檔案裡: lib/專案名稱_web/專案名稱_web.ex
。
# lib/hello_phx_web/hello_phx_web.ex
defmodule HelloPhxWeb do
@moduledoc """
#...
"""
def controller do
quote do
use Phoenix.Controller, namespace: HelloPhxWeb
import Plug.Conn
import HelloPhxWeb.Router.Helpers
import HelloPhxWeb.Gettext
end
end
def view do
#...
end
def router do
#... end
def channel do
#... end
@doc """
When used, dispatch to the appropriate controller/view/etc.
"""
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end
當你在 def controller do ... end
的 quote
區塊裡加入的任何東西,都會被嵌入每一張 controller 檔案裡。因此想要改動時要特別小心,否則發生意料之外的事,會被同事記恨的。
quote/2
、unquote/1
、defmacro/2
_web.ex
可以一次修改所有的 controller / router / view…進行到這裡,Phoenix 的部份也告一個段落了。剩下的兩天將回去介紹 Elixir 比較高階的幾個功能。
Happy hacking! 明天見。