技術メモ

プログラミングとか電子工作とか

Phoenix入門 (第5章 Plug)

f:id:ysmn_deus:20190219130922p:plain

どうも、靖宗です。
お次はPlug。というかPhoenixの説明でPlugPlug言われてたので先にこの章からやれば良かった。
今まで結構でてきてるところから分かるとおり、PlugはPhoenixのコアな部分です。PhoenixのHTTPレイヤーなどを担当しているっぽいのでもうちょっと理解しておきたいです。

EndpointsやRouters、Controllersは内部的にはPlugになってるそうです。
一応番外編としてPlugのドキュメンテーションを触っていますが、そのときはCowboy?を利用してウェブサーバーを立ち上げていました。
ヘッダーの情報などを付与したり、ルーティングしたりという役割を果たしていましたが、Plugの基本的な発想は私たちがハンドリングする接続のコンセプトを統一する、というものだそうです。おそらくヘッダー付けたりルーティングしたりってことを指してるんでしょうが、よくわかんないので先に進みます。

Function Plugs

これはPlugの番外編でも出てた気がしますが、関数のプラグです。
コネクションの構造(マップ、いわゆる%Plug.Conn{})とオプションを貰うだけの単純な関数で表されます。

def put_headers(conn, key_values) do
  Enum.reduce key_values, conn, fn {k, v}, conn ->
    Plug.Conn.put_resp_header(conn, to_string(k), v)
  end
end

key_valuesの各要素毎にPlug.Conn.put_resp_header(conn, to_string(k), v)が実行され、ヘッダー情報が追加される関数です。
この関数を定義しておけば、Phoenixでは下記のように取り扱えるようです。

defmodule HelloWeb.MessageController do
  use HelloWeb, :controller

  plug :put_headers, %{content_encoding: "gzip", cache_control: "max-age=3600"}
  plug :put_layout, "bare.html"

  ...
end

なるほど、なんとなく理解できてきました。
plug :hogehogehogehogeという関数プラグをリクエストに対して実行して次のplug :fugafugaに渡していく、という感じでしょうか。 このplugという仕組みはWebリクエストに対する処理を徹底的にシンプルにする仕組みのようです。

認証処理のリダイレクトなどのエラー処理をPlugを用いないで書くとどうなるか、丁寧にもサンプルを載せてくれています。

defmodule HelloWeb.MessageController do
  use HelloWeb, :controller

  def show(conn, params) do
    case authenticate(conn) do
      {:ok, user} ->
        case find_message(params["id"]) do
          nil ->
            conn |> put_flash(:info, "That message wasn't found") |> redirect(to: "/")
          message ->
            case authorize_message(conn, params["id"]) do
              :ok ->
                render(conn, :show, page: find_message(params["id"]))
              :error ->
                conn |> put_flash(:info, "You can't access that page") |> redirect(to: "/")
            end
        end
      :error ->
        conn |> put_flash(:info, "You must be logged in") |> redirect(to: "/")
    end
  end
end

つまりElixirでこういう不格好なコードは書くなよ?という威圧感を感じます。ああこわい・・・
実際こういうコードを書いていてはコーディングにミスが多発しそうです。

Plugは認証処理なんかもシンプルにしてくれます。上記のネストだらけのコードをPlugを使ってシンプルにします。

defmodule HelloWeb.MessageController do
  use HelloWeb, :controller

  plug :authenticate
  plug :fetch_message
  plug :authorize_message

  def show(conn, params) do
    render(conn, :show, page: find_message(params["id"]))
  end

  defp authenticate(conn, _) do
    case Authenticator.find_user(conn) do
      {:ok, user} ->
        assign(conn, :user, user)
      :error ->
        conn |> put_flash(:info, "You must be logged in") |> redirect(to: "/") |> halt()
    end
  end

  defp fetch_message(conn, _) do
    case find_message(conn.params["id"]) do
      nil ->
        conn |> put_flash(:info, "That message wasn't found") |> redirect(to: "/") |> halt()
      message ->
        assign(conn, :message, message)
    end
  end

  defp authorize_message(conn, _) do
    if Authorizer.can_access?(conn.assigns[:user], conn.assigns[:message]) do
      conn
    else
      conn |> put_flash(:info, "You can't access that page") |> redirect(to: "/") |> halt()
    end
  end
end

かなりスッキリしました。
これだけでもPlugという方法でWebリクエストを処理するメリットを感じます。

Module Plugs

上記の関数プラグに加えてモジュールでもPlugを定義できます。

defmodule HelloWeb.Plugs.Locale do
  import Plug.Conn

  @locales ["en", "fr", "de"]

  def init(default), do: default

  def call(%Plug.Conn{params: %{"locale" => loc}} = conn, _default) when loc in @locales do
    assign(conn, :locale, loc)
  end
  def call(conn, default), do: assign(conn, :locale, default)
end

defmodule HelloWeb.Router do
  use HelloWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_flash
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug HelloWeb.Plugs.Locale, "en"
  end
  ...

モジュールプラグとして利用するモジュールでは、init/1call/2というコールバック関数を定義する必要があります。
init/1は文字通り初期化用のコールバック関数でplug HelloWeb.Plugs.Locale, "en"の箇所で呼び出されているようです。今回ではdefaultという変数に"en"を代入する役割を果たしています。
call/2はリクエストの度に呼び出される関数で、第一引数に接続情報、第二引数にオプションを指定する関数のようです。

今回のモジュールプラグはcall/2関数でパターンマッチングで条件分岐しており、モジュールが抱えているlocalesというリストの中に通過するリクエストのパラメータ内のlocaleが該当するか否かを判定しています。ある場合はそのlocaleを、無ければデフォルトの"en"アサインするような処理になっています。
内部にモジュール属性で変数を持たせて条件分岐したいときなどはモジュールプラグの方がいいのかもしれません。

リクエストに対して毎回行う処理などはPlugを駆使するのがPhoenixの流儀っぽいです。
若干まだ慣れてない感はありますが、ウェブ上で公開されているコードなども参考にしてどう扱うか吟味していきたいところです。