Phoenix のコントローラで render/3 を呼んでからのレンダリング処理の流れを追う

  • 32
    Like
  • 0
    Comment
More than 1 year has passed since last update.

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 アプリケーションの FooControllerindex/2 の中で render/3 を呼んでいる。

web/controllers/foo_controller.ex
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 が、 defdefpimportuse@before_compile に展開される
  • そのモジュール内の @before_compile 属性で指定したマクロが、 defdefpimportuse に展開される

def または defp で定義しているのでなければ、 import または use しているモジュールのソースを見てみる。その中でもまた importuse しているかもしれないが、先を追っていくといずれ 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.FooControllerdef render は見当たらない。とすれば render/3 はモジュールの外から来ていることになる。

怪しいのは use MyApp.Web, :controller だ。

web/controllers/foo_controller.ex
defmodule MyApp.FooController do
  use MyApp.Web, :controller  # ←これ
  # ...
end

MyApp.Web は、各コントローラやビューに共通する定義をまとめたモジュールだ。各コントローラの先頭に use MyApp.Web, :controller と1行書けば複数の useimport に展開される。

MyApp.Web の中身を見てみよう。

web/web.ex
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 が返すコード片にはいくつかの useimport があって、他のモジュールから関数を取り込んでいる。

ようするに、ここの useimport が取り込んだ関数は MyApp.FooController 内に埋め込まれるということだ。この中のどれかが render/3 を取り込んでいるのだろうか?

この記事ではそれぞれのモジュールを調べる過程は省略しよう。マニュアルやコードを調べた結果、探している関数 render/3use 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 はこうなっている:

phoenix/controller.ex
  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 )

引数について補足しておくと、 connPlug.Conn 構造体だ。 template はテンプレートの名前。ひとつ飛ばしてassignsDict な値。これらは一番最初の 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) としているが、ここで viewMyApp.FooView になる。

この関数はどうやら Phoenix.View.render_to_iodata/3 を呼んでテンプレートをレンダリングしているようだ。

Phoenix.View.render_to_iodata/3

phoenix/view.ex
  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/3render/3 を呼んでいる。 それを encode/2 に渡して iodata なるものに変換しているようだが、そっちは措いておこう。 render/3 を見てみる。

Phoenix.View.render/3

phoenix/view.ex
  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

phoenix/view.ex
  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 はどうなっているかというと……

web/views/foo_view.ex
defmodule MyApp.FooView do
  use MyApp.Web, :view
end

これだけ。 render/2use MyApp.Web, :view から来ているに違いない。

MyApp.Web はいくつかの useimport を束ねるモジュールだったのを覚えているだろうか。中身を読む過程は一度やったので省略しよう。 render/2 を提供しているのは、そのうちの use Phoenix.View, root: "web/templates" だ。

use Phoenix.View

use Phoenix.View すると Phoenix.View.__using__/1 マクロが呼ばれる。

phoenix/view.ex
  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

phoenix/view.ex
  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 が返り値となるコード片だ。

phoenix/template.ex
  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 はあるがレンダリング処理ではなく例外を投げている。 useimport も見あたらないし @before_compile もない。

気を取り直してもう一度コードを見てみよう。この中で一見してそれと分からない render/2 の定義が現れる余地があるとすれば、それは quote line: -1 do 直後の unquote(codes) だ。仮にここで render/2 が定義されていれば、その下に続く def render よりも先にマッチすることになる。

codes は何かというと、 compile(path, root) を何回も呼び、その返り値を集めて作られている。 compile/2 は同じモジュール内で定義されたプライベート関数だ。見てみよう。

Phoenix.Template.compile/2

phoenix/template.ex
  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.FooControllerrender(conn, "index.html", foos: foos) を呼ぶと、 MyApp.FooView.render/2 の呼び出しに行き着く。この render/2 はマクロによって次のように定義されている:

web/views/foo_view.ex
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.FooViewrender/2 を定義すればいい。ただし最終的にはマクロによって挿入された render/2 を呼ばなければならないのでパターンマッチを工夫すること。さもないと自分で定義したほうの関数が再帰的に呼ばれることになる。

web/views/foo_view.ex
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