Elixir の import/2 は最後のものが優先される

2020/03/26 12:00am

この記事は以前に Qiita で公開した記事です。

Elixir の import/2 を同じモジュールにたいして複数回実行すると、最後に実行したものが優先される。

iex(1)> import List
List
iex(2)> first([1])
1
iex(3)> import List, only: [flatten: 1]
List
iex(4)> first([1])                     
** (CompileError) iex:4: undefined function first/1

上記の例では、iex(1)import List したときには参照できていた List.first/1 が、iex(4)import List, only: [flatten: 1] のあとでは参照できなくなっている。

この挙動を知らずに失敗した話

Phoenix のコントローラーで「認証済みのユーザーが conn にあれば処理を続行、そうでなければエラー」という処理が頻発するので、次のようなモジュールを書いた。

defmodule AuthUserAction do
  defmacro __using__(_opts) do
    quote do
      import Plug.Conn
      import Phoenix.Controller, only: [json: 2, action_name: 1]

      def action(conn, _) do
        with {:ok, user_id} <- Map.fetch(conn.private, :user_id),
             %User{} = user <- Repo.get(User, user_id) do
          apply(__MODULE__, action_name(conn), [conn, conn.params, user])
        else
          _ ->
            conn
            |> put_status(:forbidden)
            |> json(%{
              error: "forbidden"
            })
        end
      end
    end
  end
end

これを使うコントローラーでは、毎回、

use Phoenix.Controller

def my_action(conn, params) do
  with {:ok, user_id} <- Map.fetch(conn.private, :user_id),
       %User{} = user <- Repo.get(User, user_id) do
    ...
  end
end

のように書いていた処理を、use AuthUserAction を追加するだけで、

use Phoenix.Controller
use AuthUserAction

def my_action(conn, params, user) do
  ...
end

このように簡略化できる…はずだった。

コンパイル・エラー

しかし、実装してコンパイルしてみると、以下のようなエラーが出るようになってしまった。

== Compilation error in file lib/my_app/controllers/my_controller.ex ==
** (CompileError) lib/my_app/controllers/my_controller.ex:1: undefined function put_new_layout/2
    (elixir 1.10.1) src/elixir_locals.erl:114: anonymous fn/3 in :elixir_locals.ensure_no_undefined_local/3
    (stdlib 3.11.2) erl_eval.erl:680: :erl_eval.do_apply/6
    (elixir 1.10.1) lib/kernel/parallel_compiler.ex:233: anonymous fn/4 in Kernel.ParallelCompiler.spawn_workers/7

何故こうなるか、というと、最初に説明した通り、マクロ中の

import Phoenix.Controller, only: [json: 2, action_name: 1]

が、コントローラー側の use Phoenix.Controllerimport されてる put_new_layout/2 を上書きしてしまうからだ。

解決

Elixir の import/2 はレキシカル・スコープなので、import を関数内に移動するだけで解決できる。

defmodule AuthUserAction do
  defmacro __using__(_opts) do
    quote do
      def action(conn, _) do
        import Plug.Conn
        import Phoenix.Controller, only: [json: 2, action_name: 1]

        ...
      end
    end
  end
end

実際には、コードサイズを減らすためにも、action/2 の実装をマクロの外に出した。

defmodule AuthUserAction do
  import Plug.Conn
  import Phoenix.Controller, only: [json: 2, action_name: 1]

  defmacro __using__(_opts) do
    quote do
      def action(conn, _) do
        AuthUserAction.action(conn, __MODULE__)
      end
    end
  end

  def action(conn, module) do
    with {:ok, user_id} <- Map.fetch(conn.private, :user_id),
         %User{} = user <- Repo.get(User, user_id) do
      apply(module, action_name(conn), [conn, conn.params, user])
    else
      _ ->
        conn
        |> put_status(:forbidden)
        |> json(%{
          error: "forbidden"
        })
    end
  end
end