Elixir でモジュール属性を使って関数定義をデコレーションする

  • 6
    Like
  • 0
    Comment

Elixir: 1.5.1, Phoenix: 1.3.0


Python ではメソッドの前に @hoge を書いてメソッド定義の挙動を変えられる。

class Foo:
    def i_am_instance_method(self):
        print('hi')

    @staticmethod
    def i_am_static_method():
        print('yo')

Elixir で似たようなことをやるにはモジュール属性と @on_definition をうまく使う。

defmodule OnDefinition do
  def on_definition(env, _kind, _name, _args, _guards, _body) do
    Module.put_attribute(env.module, :my_func_attr, nil)
  end
end

defmodule Foo do
  # Foo モジュール内で関数を定義したタイミングで呼ばれるフックを指定する。
  @on_definition {OnDefinition, :on_definition}

  # これをモジュール定義の最初のほうで実行すると、
  # 以降このモジュール内に出現した `@my_func_attr expr` が記録されていく。
  # このモジュールのコンパイルフェーズ中に限り、
  # `Module.get_attribute(Foo, :my_func_attr)` マクロでリストとして取り出せる。
  # なお `@foo` と書くと `Module.get_attribute(__MODULE__, :foo)` に展開される。
  Module.register_attribute(Foo, :my_func_attr, accumulate: true)

  # 初期値として nil を入れておく。
  @my_func_attr nil

  ###

  def fn1() do
    # @my_func_attr 属性のリストから直前に定義されたものを取り出す。
    # 属性はリストの先頭に追加されていくため、直前のものを取り出すには List.first を呼ぶ。
    IO.puts inspect(unquote(@my_func_attr) |> List.first) # => nil
  end

  @my_func_attr "hello"
  def fn2() do
    # 直前に定義した属性の値を取り出せる。
    IO.puts inspect(unquote(@my_func_attr) |> List.first) # => "hello"
  end

  def fn3() do
    # fn3 の定義の前には @my_func_attr を置いていないが、
    # 上で fn2 を定義したタイミングで呼ばれる on_definition フックが
    # fn2 の直後に `@my_func_attr nil` を挿入しているため、ここでは nil が取り出される。
    IO.puts inspect(unquote(@my_func_attr) |> List.first) # => nil
  end
end

応用例

Phoenix のコントローラのアクション関数をデコレーションし、ログイン中のみアクセスできるページを実装できる。

defmodule MyApp.Auth do
  # ログイン状態でなければログインページにリダイレクトする plug。
  @spec require_authorization(Plug.Conn.t, Plug.opts) :: Plug.Conn.t
  def require_authorization(conn, _opts) do
    if MyApp.Session.signed_in?(conn) do
      conn
    else
      conn
      |> Phoenix.Controller.redirect(to: "/login")
      |> Plug.Conn.halt()
    end
  end

  # コントローラで関数を定義するたびに呼ばれる。関数の直前に `@protect true` があれば、
  # その関数にルーティングする前にログイン状態をチェックする plug を差し込む。
  def put_require_authorization_plug(env, _kind, name, _args, _guards, _body) do
    attrs = Module.get_attribute(env.module, :protect)

    protect_by_default? = List.last(attrs)
    protected? = List.first(attrs)

    if protected? do
      plug = {:require_authorization, [], quote(do: var!(action) == unquote(name))}
      Module.put_attribute(env.module, :plugs, plug)
    end

    Module.put_attribute(env.module, :protect, protect_by_default?)
  end

  @spec __using__([protect_by_default: boolean]) :: term
  defmacro __using__([protect_by_default: protect_by_default?]) do
    quote do
      import Worklog.Auth

      Module.register_attribute(__MODULE__, :protect, accumulate: true)

      @protect unquote(protect_by_default?)
      @on_definition {MyApp.Auth, :put_require_authorization_plug}
    end
  end
end

defmodule MyApp.FooController do
  use MyAppWeb, :controller
  use MyApp.Auth, protect_by_default: false

  # `@protect true` をつけていないので、アクセスにログインを要求しない。
  def index(conn, _params) do
    # ...
  end

  # `@protect true` をつけてあるので、アクセスにログインを要求する。
  # セッションにログイン状態が記録されていなければログインページにリダイレクトする。
  @protect true
  def edit(conn, params) do
    # ...
  end
end