Phoenix のコントローラでは render(conn, "index.html", foos: foos)
のように render/3
を呼ぶことでビューをレンダリングすることができる。もろもろの事情があって、この処理の途中にフックして引数の値を書き換えてみたいと思った。
というわけで、コントローラで render/3
を呼んだら何がどうなって HTML がレンダリングされるのか追いかけてみた。
ネタバレ:この記事を読んで得られること
この記事では、生成されたコードで使われているもののどこで定義されているか分からない関数から始まって、マクロで生成された関数定義に辿り着くまでを順を追って書いていきます。マクロを駆使した Elixir のコードにおいて、関数がどこで定義されているかを探す方法や考え方のヒントを得られる……はずです。分かりづらい箇所があればお気軽にコメントをください。
準備:プロジェクトを作る
(ここは読み飛ばしてもOK)
説明用に新規アプリケーション my_app
を作る。 mix phoenix.gen.html
コマンドで Foo
モデル(と関連するビューとコントローラ他)を生成する。
mix phoenix.new my_app --no-brunch
cd my_app
mix do deps.get, compile
mix phoenix.gen.html Foo foos bar:string
出発:FooController
出発点は MyApp
アプリケーションの FooController
。 index/2
の中で render/3
を呼んでいる。
defmodule MyApp.FooController do
use MyApp.Web, :controller
alias MyApp.Foo
plug :scrub_params, "foo" when action in [:create, :update]
def index(conn, _params) do
foos = Repo.all(Foo)
render(conn, "index.html", foos: foos) # ← ここから出発
end
# ...
end
この render/3
は何者だろう? どこで定義されているのだろうか?
関数はどこから来るか
Elixir のモジュール内で関数を呼んでいるとき、その関数がどこで定義されているかは4つの可能性に絞られる:
- そのモジュール内の
def
またはdefp
で定義している - そのモジュール内の
import OtherModule
で他のモジュールからインポートしている - そのモジュール内の
use OtherModule
が、def
かdefp
かimport
かuse
か@before_compile
に展開される - そのモジュール内の
@before_compile
属性で指定したマクロが、def
かdefp
かimport
かuse
に展開される
def
または defp
で定義しているのでなければ、 import
または use
しているモジュールのソースを見てみる。その中でもまた import
か use
しているかもしれないが、先を追っていくといずれ def
または defp
に辿りつくはずだ。
補足
(ここは読み飛ばしてもOK)
関数の定義の場所について、厳密には他の可能性もある。ひとつは Elixir の組み込み関数として Kernel
モジュールに定義されている可能性だが、これは今回は考えなくていい。
また、動的に関数を定義する手段として eval 系の関数がある:
defmodule MyModule do
Code.eval_string("def answer, do: 42", [], __ENV__)
end
# MyModule.answer => 42
とはいえ、 Elixir はマクロが十分にパワフルなので eval を目にする機会はほとんどないだろう。今回のプロジェクトで grep eval
してみると、 eval が使われているのは ecto/lib/ecto/schema.ex で Module.eval_quoted/4
を呼んでいる1箇所だけだった。
use MyApp.Web, :controller
さて、 MyApp.FooController
に def render
は見当たらない。とすれば render/3
はモジュールの外から来ていることになる。
怪しいのは use MyApp.Web, :controller
だ。
defmodule MyApp.FooController do
use MyApp.Web, :controller # ←これ
# ...
end
MyApp.Web
は、各コントローラやビューに共通する定義をまとめたモジュールだ。各コントローラの先頭に use MyApp.Web, :controller
と1行書けば複数の use
や import
に展開される。
MyApp.Web
の中身を見てみよう。
defmodule MyApp.Web do
# ...
def controller do
quote do
use Phoenix.Controller
alias MyApp.Repo
import Ecto.Model
import Ecto.Query, only: [from: 2]
import MyApp.Router.Helpers
end
end
# ...
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
__using__/1
マクロが定義されている。別のモジュールで use MyApp.Web, :controller
すると MyApp.Web.controller/0
を呼ぶ仕掛けだ。その返り値の quote されたコード片は、結局 use
した側のモジュールに埋め込まれる。
controller/0
が返すコード片にはいくつかの use
と import
があって、他のモジュールから関数を取り込んでいる。
ようするに、ここの use
と import
が取り込んだ関数は MyApp.FooController
内に埋め込まれるということだ。この中のどれかが render/3
を取り込んでいるのだろうか?
この記事ではそれぞれのモジュールを調べる過程は省略しよう。マニュアルやコードを調べた結果、探している関数 render/3
は use Phoenix.Controller
から来ていることが分かった。
use Phoenix.Controller
ここからは Phoenix のコードを追っていく。 Phoenix の関数を調べるときは API ドキュメント http://hexdocs.pm/phoenix/ を読むといいだろう。ドキュメント中の “Source” をクリックすると対応するコードを GitHub で開くこともできる。
ドキュメントのサイドバーで render
を検索すると何件か候補が出てくる。 Phoenix.Controller.render
のどれかが正解だ。
Phoenix.Controller.render
Phoenix.Controller.render
には arity が2, 3, 4とあるが、ソースを読むとそのどれもプライベート関数 do_render/4
を呼んでいる。 do_render/4
はこうなっている:
defp do_render(conn, template, format, assigns) do
assigns = to_map(assigns)
content_type = Plug.MIME.type(format)
conn =
conn
|> put_private(:phoenix_template, template)
|> prepare_assigns(assigns, format)
view = Map.get(conn.private, :phoenix_view) ||
raise "a view module was not specified, set one with put_view/2"
data = Phoenix.View.render_to_iodata(view, template,
Map.put(conn.assigns, :conn, conn))
send_resp(conn, conn.status || 200, content_type, data)
end
( https://github.com/phoenixframework/phoenix/blob/v0.15.0/lib/phoenix/controller.ex#L571-L584 )
引数について補足しておくと、 conn
は Plug.Conn
構造体だ。 template
はテンプレートの名前。ひとつ飛ばしてassigns
は Dict
な値。これらは一番最初の render/3
に渡したものと同じ。ここでは template = "index.html"
, assigns = [foos: foos]
になっている。
残る format
引数はテンプレートのフォーマット。テンプレート名 "index.html"
に対応するテンプレートファイルは web/templates/page/index.html.eex だが、この最後の拡張子がフォーマットになる。つまり format = "eex"
だ。
また、関数内で view = Map.get(conn.private, :phoenix_view)
としているが、ここで view
は MyApp.FooView
になる。
この関数はどうやら Phoenix.View.render_to_iodata/3
を呼んでテンプレートをレンダリングしているようだ。
Phoenix.View.render_to_iodata/3
def render_to_iodata(module, template, assign) do
render(module, template, assign) |> encode(template)
end
( https://github.com/phoenixframework/phoenix/blob/v0.15.0/lib/phoenix/view.ex#L431-L433 )
render_to_iodata/3
は render/3
を呼んでいる。 それを encode/2
に渡して iodata なるものに変換しているようだが、そっちは措いておこう。 render/3
を見てみる。
Phoenix.View.render/3
def render(module, template, assigns) do
assigns
|> to_map()
|> Map.pop(:layout, false)
|> render_within(module, template)
end
( https://github.com/phoenixframework/phoenix/blob/v0.15.0/lib/phoenix/view.ex#L188-L193 )
若干ややこしいことになっているのでコメントをつけてみた。
def render(module, template, assigns) do
assigns # == %{conn: conn, foos: foos}
# 元々は [foos: foos] だったが、
# `do_render/4` によって `conn` が追加され、
# さらに `render_to_iodata/3` の中で
# `to_map(assigns)` によって Map になっている。
|> to_map() # …のでこれは noop
|> Map.pop(:layout, false) # assigns から :layout キーの値を取り出す。
# ここでは存在しないので {false, assigns} が返る
|> render_within(module, template)
end
最後には render_within({false, assigns}, module, template)
を呼んでいる。これは Phoenix.View
のプライベート関数だ。
Phoenix.View.render_within/3
defp render_within({{layout_mod, layout_tpl}, assigns}, inner_mod, template) do
# ...
end
defp render_within({false, assigns}, module, template) do
template
|> module.render(assigns)
end
( https://github.com/phoenixframework/phoenix/blob/v0.15.0/lib/phoenix/view.ex#L195-L209 )
候補が2つあるが、第1引数に {false, assigns}
を渡してマッチするのは2つ目のほうだ。 template |> module.render(assigns)
とあるが、 module
は何だろう?
答えはこの記事を Phoenix.Controller.render
まで遡ったところにある。 view = MyApp.FooView
とあり、それがここへきて module
にセットされている。
つまりここでは MyApp.FooView.render(template, assigns)
を呼んでいるということだ。
MyApp.FooView.render/2
また MyApp
のコードに戻ってきた。 FooView
はどうなっているかというと……
defmodule MyApp.FooView do
use MyApp.Web, :view
end
これだけ。 render/2
は use MyApp.Web, :view
から来ているに違いない。
MyApp.Web
はいくつかの use
や import
を束ねるモジュールだったのを覚えているだろうか。中身を読む過程は一度やったので省略しよう。 render/2
を提供しているのは、そのうちの use Phoenix.View, root: "web/templates"
だ。
use Phoenix.View
use Phoenix.View
すると Phoenix.View.__using__/1
マクロが呼ばれる。
defmacro __using__(options) do
if root = Keyword.get(options, :root) do
namespace =
# ...
quote do
import Phoenix.View
use Phoenix.Template, root:
Path.join(unquote(root),
Phoenix.Template.module_to_template_root(__MODULE__, unquote(namespace), "View"))
# ...
end
else
raise "expected :root to be given as an option"
end
end
( https://github.com/phoenixframework/phoenix/blob/v0.15.0/lib/phoenix/view.ex#L120-L147 )
ここには render/2
の定義はない。とすれば use Phoenix.Template, root: ...
の中だ。
use Phoenix.Template
defmacro __using__(options) do
root = Dict.fetch! options, :root
quote do
@template_root Path.relative_to_cwd(unquote(root))
@before_compile unquote(__MODULE__)
@doc """
Renders the given template locally.
"""
def render(template, assigns \\ %{})
def render(template, assigns) when is_list(assigns) do
render(template, Enum.into(assigns, %{}))
end
def render(module, template) when is_atom(module) do
Phoenix.View.render(module, template, %{})
end
# ...
end
end
( https://github.com/phoenixframework/phoenix/blob/v0.15.0/lib/phoenix/template.ex#L110-L147 )
ついに render/2
の定義が出てきた!
だが、よく見ると1つ目には本体がなく、2つ目にはガードがついている。3つ目にもガードがあるうえに引数が (module, template)
と他とは違っている。
この時点で、 render/2
の引数は (template, assigns)
になっているはずだ(思い出せなければこの記事を MyApp.FooView.render(template, assigns)
で検索)。さらに assigns
は Map になっているので、これら3つの render/2
のどれにもマッチしない。
とすれば、残るは @before_compile unquote(__MODULE__)
が怪しい。
この属性がここにあると、 use Phoenix.Template
した側のモジュールに Phoenix.Template.__before_compile__/1
マクロが返すコード片が埋め込まれることになる(理由は Elixir の Module のドキュメントを参照 http://elixir-lang.org/docs/v1.0/elixir/Module.html )。
そのコード片の中に求める render/2
があるはずだ。
Phoenix.Template.__before_compile__/1
以下が __before_compile__/1
のコード。少し長いが重要な箇所なので省略せずに掲載する。後半の quote line: -1 do ... end
が返り値となるコード片だ。
defmacro __before_compile__(env) do
root = Module.get_attribute(env.module, :template_root)
pairs = for path <- find_all(root) do
compile(path, root)
end
names = Enum.map(pairs, &elem(&1, 0))
codes = Enum.map(pairs, &elem(&1, 1))
# We are using line -1 because we don't want warnings coming from
# render/2 to be reported in case the user has defined a catch all
# render/2 clause.
quote line: -1 do
unquote(codes)
def render(tpl, %{render_existing: {__MODULE__, tpl}}) do
nil
end
def render(template, assigns) do
template_not_found(template, assigns)
end
@doc """
Returns the template root alongside all templates.
"""
def __templates__ do
{@template_root, unquote(names)}
end
@doc """
Returns true whenever the list of templates changes in the filesystem.
"""
def __phoenix_recompile__? do
unquote(hash(root)) != Template.hash(@template_root)
end
end
end
( https://github.com/phoenixframework/phoenix/blob/v0.15.0/lib/phoenix/template.ex#L150-L187 )
この中で render/2
の定義は2箇所。繰り返すが、ここで render/2
に与える引数は (template, assigns)
だ。そしてこのとき assigns = %{conn: conn, foos: foos}
となっている。
とすると、1つ目の def render(tpl, %{render_existing: {__MODULE__, tpl}})
は第2引数のパターンにマッチしない。
残る2つ目には無事マッチするが、2つ目は template_not_found(template, assigns)
という不穏な関数を呼んでいる。そしてこの関数は見た目どおり例外を発生させる!
ここまで来て行き詰まってしまった。冒頭で挙げた関数定義の4つのパターンのどれにも当てはまらない。 def render
はあるがレンダリング処理ではなく例外を投げている。 use
も import
も見あたらないし @before_compile
もない。
気を取り直してもう一度コードを見てみよう。この中で一見してそれと分からない render/2
の定義が現れる余地があるとすれば、それは quote line: -1 do
直後の unquote(codes)
だ。仮にここで render/2
が定義されていれば、その下に続く def render
よりも先にマッチすることになる。
codes
は何かというと、 compile(path, root)
を何回も呼び、その返り値を集めて作られている。 compile/2
は同じモジュール内で定義されたプライベート関数だ。見てみよう。
Phoenix.Template.compile/2
defp compile(path, root) do
name = template_path_to_name(path, root)
defp = String.to_atom(name)
ext = Path.extname(path) |> String.lstrip(?.) |> String.to_atom
engine = Map.fetch!(engines(), ext)
quoted = engine.compile(path, name)
{name, quote do
@file unquote(path)
@external_resource unquote(path)
defp unquote(defp)(var!(assigns)) do
_ = var!(assigns)
unquote(quoted)
end
def render(unquote(name), assigns) do
unquote(defp)(assigns)
end
end}
end
( https://github.com/phoenixframework/phoenix/blob/v0.15.0/lib/phoenix/template.ex#L315-L335 )
def render(unquote(name), assigns)
がある! この name
の値を求めるにも紆余曲折あるのだが、すっ飛ばして答えを書いてしまうと MyApp.FooView
に関連づけられたテンプレートの名前のどれかになる。 "index.html"
や "edit.html"
などだ。
compile/2
を何回も呼んでいるとすぐ上で書いたが、正確に言えば MyApp.FooView
に関連するテンプレートファイルのパスすべてを順に引数にして compile/2
を呼んでいるのだ。
とすると、この def render
は次のように展開される:
def render("index.html", assigns) do
quote(defp)(assigns)
end
# 注意: quote(defp) は :"index.html" に展開されるが、
# 実際のコードで文字通り :"index.html"(assigns) と書くと構文エラーになる。
# quote do ... end の中でだけこういう書き方が許される。
# 実際のコードで記号を含む名前の関数を呼び出すには MyModule."foo.bar"() のようにする。
無事に render/2
の定義が現れた。この先を辿るとテンプレートエンジンまで行き着くが、今回はここまでにしておこう。
この render/2
の定義は @before_compile
のはたらきで MyApp.View
に埋め込まれることになる。
まとめ:何がどうなっていたのか
Phoenix の MyApp.FooController
で render(conn, "index.html", foos: foos)
を呼ぶと、 MyApp.FooView.render/2
の呼び出しに行き着く。この render/2
はマクロによって次のように定義されている:
defmodule MyApp.FooView do
use MyApp.Web, :view
# ---- ↓マクロによって挿入される
def render("index.html", assigns) do
# テンプレートエンジンを呼ぶ
end
def render("edit.html", assigns) do
# テンプレートエンジンを呼ぶ
end
# ...
# ----
end
もしも MyApp.FooView.render/2
の処理にフックしたいなら、 MayApp.FooView
に render/2
を定義すればいい。ただし最終的にはマクロによって挿入された render/2
を呼ばなければならないのでパターンマッチを工夫すること。さもないと自分で定義したほうの関数が再帰的に呼ばれることになる。
defmodule MyApp.FooView do
use MyApp.Web, :view
def render("index.html", %{foos: foos} = assigns) do
assigns = assigns
|> Map.drop([:foos]) # 以降この render/2 にマッチさせない
|> Map.put(:new_foos, ...)
render("index.html", assigns) # マクロによって挿入されたほうの render/2 を呼ぶ
end
# ---- ↓マクロによって挿入される
# 略
# ----
end