Posted at

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

More than 1 year has passed since last update.

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