Plug

前提: このガイドでは、入門ガイドの内容を理解し、Phoenixアプリケーションを起動していることを前提としています。

前提: リクエストライフサイクルのガイドを前提としています

PlugはPhoenixのHTTPレイヤー中心にあり、PhoenixはPlugを中心に置いています。エンドポイント、ルーター、コントローラーなどのPhoenixのコアコンポーネントは、内部的にはすべてPlugです。Plugの特徴を確認してみましょう。

Plugは、ウェブアプリケーション間のモジュールを構成するための仕様です。また、異なるWebサーバーのコネクションアダプターの抽象化レイヤーでもあります。Plugの基本的な考え方は、私たちが操作する「コネクション」という概念を統一することです。これは、ミドルウェアスタックの中でリクエストとレスポンスが分離されているRackなどの他のHTTPミドルウェア層とは異なります。

もっとも簡単なレベルでは、Plugの仕様には2つの種類があります。関数PlugモジュールPlug です。

関数plug

plugとして動作させるためには、次のことが必要です。

  1. 第1引数にコネクション構造体(%Plug.Conn{})を、第2引数にオプションを受け取る
  2. コネクション構造体を返す

これらの基準を満たす関数であれば、どのような関数でも動作します。以下に例を示します。

def introspect(conn, _opts) do
  IO.puts """
  Verb: #{inspect(conn.method)}
  Host: #{inspect(conn.host)}
  Headers: #{inspect(conn.req_headers)}
  """

  conn
end

この関数は以下のようなことを行います。

  1. コネクションとオプション(今回は使用しません)を受け取ります
  2. ターミナルへコネクション情報を表示します
  3. コネクションを返します

とってもシンプルでしょう?それでは、この関数を lib/hello_web/endpoint.ex のエンドポイントに追加して、実際に動かしてみましょう。この関数はどこにでも追加することができるので、リクエストをルーターに委ねる直前に plug :introspect を挿入してみましょう。

defmodule HelloWeb.Endpoint do
  ...

  plug :introspect
  plug HelloWeb.Router

  def introspect(conn, _opts) do
    IO.puts """
    Verb: #{inspect(conn.method)}
    Host: #{inspect(conn.host)}
    Headers: #{inspect(conn.req_headers)}
    """

    conn
  end
end

関数plugは、関数名をアトムとして渡すことで組み込むことができます。plugを試すには、ブラウザに戻って http://localhost:4000 へアクセスします。ターミナルにはこのような表示が出てくるはずです。

Verb: "GET"
Host: "localhost"
Headers: [...]

私たちのplugは、単にコネクションからの情報をプリントするだけのものです。初期のPlugは非常にシンプルですが、Plugの中では事実上何でもできるようになっています。コネクションで利用可能なすべてのフィールドとそれに関連するすべての機能については、 Plug.Connのドキュメントを参照してください。

では、続いてplugの別のバリエーションであるモジュールPlugを見てみましょう。

モジュールplug

モジュールplugは、モジュール内のコネクション変換を定義するためのplugの別のタイプです。モジュールは2つの関数を実装するだけです。

  • init/1で、call/2に渡す引数やオプションを初期化します。
  • call/2で、コネクションの変換を行います。call/2は、先ほど見た関数プラグです

これを実際に見るために、:locale のキーと値をコネクションのassignに入れるモジュールPlugを書いてみましょう。上記の内容を lib/hello_web/plugs/locale.ex というファイルに記述します。

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
end

試しに、このモジュールplugをルーターに追加してみましょう。lib/hello_web/router.ex:browser パイプラインに plug HelloWeb.Plugs.Locale, "en" を追加します。

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/1 コールバックでは、パラメーターにロケールが存在しない場合に使用するデフォルトのロケールを渡します。また、パターンマッチを使って複数の call/2 関数を定義し、パラメーターのロケールを検証し、マッチしない場合は "en" にフォールバックします。assign/3Plug.Conn モジュールの一部で、conn データ構造に値を格納します。

assignの動作を見るには、lib/hello_web/templates/layout/app.html.heex 内のレイアウトに移動し、メインのコンテナー近くに以下を追加します。

<main class="container">
  <p>Locale: <%= @locale %></p>

http://localhost:4000/にアクセスすると、ロケールが表示されているはずです。http://localhost:4000/?locale=frにアクセスすると、アサインが "fr" に変更されているのがわかります。この情報をGettextと一緒に使えば、完全に国際化されたWebアプリケーションを提供できます。

Plugが行うのはこれだけです。Phoenixは隅から隅まで、合成可能な変換のPlugデザインを採用しています。いくつかの例を見てみましょう

組み込める場所

Phoenixのエンドポイント、ルーター、コントローラーはPlugを受け入れます。

エンドポイントPlug

エンドポイントは、すべてのリクエストに共通するすべてのPlugを整理し、カスタムパイプラインでルーターへディスパッチする前に適用します。このようにエンドポイントにPlugを追加しました。

defmodule HelloWeb.Endpoint do
  ...

  plug :introspect
  plug HelloWeb.Router

デフォルトのエンドポイントplugはかなり多くの作業を行います。ここでは順を追って説明します。

  • Plug.Static - 静的アセットを提供します。このplugはロガーの前に来るので、静的アセットの提供はログに記録されません。

  • Phoenix.LiveDashboard.RequestLogger - Phoenix LiveDashboardの Request Logger を設定します。これにより、リクエストログをストリームするためのクエリパラメーターを渡すか、リクエストログをストリームするクッキーを有効/無効にするかのオプションが可能になります。

  • Plug.RequestId - 各リクエストに対して一意のリクエストIDを生成します。

  • Plug.Telemetry - 測定ポイントを追加し、Phoenixがデフォルトでリクエストパス、ステータスコード、リクエスト時間をログに記録できるようにします。

  • Plug.Parsers - 既知のパーサーが利用可能な場合に、リクエストの本文をパースします。デフォルトでは、Plug.Parsers はURL-encoded, multipart, json (Jason にて)をパースします。リクエストのcontent-typeが解析できない場合、リクエストボディはそのままになります。

  • Plug.MethodOverride - 有効な _method パラメーターを持つPOSTリクエストに対して、リクエストメソッドをPUT, PATCH, DELETEに変換します

  • Plug.Head - HEADリクエストをGETリクエストに変換し、レスポンスボディを削除します

  • Plug.Session - セッション管理を設定するplugです。このplugはセッションの取得方法を設定するだけなので、セッションを使う前に fetch_session/2 が明示的に呼ばれなければならないことに注意してください。

エンドポイントの途中には、条件付きブロックもあります。

  if code_reloading? do
    socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
    plug Phoenix.LiveReloader
    plug Phoenix.CodeReloader
    plug Phoenix.Ecto.CheckRepoStatus, otp_app: :hello
  end

このブロックは開発時にのみ実行されます。このブロックは以下を実現します。

  • ライブリローディング - CSSファイルを変更すると、ページを更新することなく、ブラウザ上で更新されます。
  • code reloading - サーバーを再起動することなく、アプリケーションの変更を確認できます。
  • check repo status - データベースが最新であることを確認し、そうでない場合は読み取り可能でアクション可能なエラーを発生させます。

ルーターPlug

ルーターでは、パイプライン内でPlugを宣言できます。

defmodule HelloWeb.Router do
  use HelloWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, {HelloWeb.LayoutView, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug HelloWeb.Plugs.Locale, "en"
  end

  scope "/", HelloWeb do
    pipe_through :browser

    get "/", PageController, :index
  end

ルートはスコープ内で定義され、スコープは複数のパイプラインを通過できます。ルートが一致すると、Phoenixはそのルートに関連付けられたすべてのパイプラインで定義されたすべてのPlugを呼び出します。たとえば、"/" にアクセスすると :browser パイプラインを通過し、その結果、すべてのPlugが呼び出されます。

ルーティングガイドで触れますが、パイプライン自体がplugです。ここでは :browser パイプラインのすべてのPlugについても説明します。

コントローラーPlug

最後に、コントローラーもPlugなので、次のようにできます:

defmodule HelloWeb.PageController do
  use HelloWeb, :controller

  plug HelloWeb.Plugs.Locale, "en"

とくに、コントローラーPlugは、特定のアクション内でのみPlugを実行できる機能を提供しています。たとえば、次のようなことができます。

defmodule HelloWeb.PageController do
  use HelloWeb, :controller

  plug HelloWeb.Plugs.Locale, "en" when action in [:index]

この場合、Plugは index アクションに対してのみ実行されます。

構成としてのPlug

Plugの取り決めを遵守することで、アプリケーションのリクエストを一連の明示的に変換します。これで終わりではありません。Plugの設計がどれほど効果的かを実際に見るために、一連の条件をチェックして、条件が失敗した場合にリダイレクトするか停止する必要があるシナリオを想像してみましょう。Plugがなければ、次のようになるでしょう。

defmodule HelloWeb.MessageController do
  use HelloWeb, :controller

  def show(conn, params) do
    case Authenticator.find_user(conn) do
      {:ok, user} ->
        case find_message(params["id"]) do
          nil ->
            conn |> put_flash(:info, "That message wasn't found") |> redirect(to: "/")
          message ->
            if Authorizer.can_access?(user, message) do
              render(conn, :show, page: message)
            else
              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

認証と認可のわずか数ステップで、複雑な入れ子と重複を必要とすることにお気づきでしょうか?これをいくつかの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: conn.assigns[:message])
  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

これをすべて動作させるために、入れ子になったコードブロックを変換し、失敗パスへ到達するたびに halt(conn) を使用しています。ここでは halt(conn) の機能が不可欠です: 次のPlugを呼び出すべきではないことをPlugに伝えます。

要するに、入れ子になったコードのブロックをフラット化された一連のPlug変換に置き換えることで、同じ機能をより構成しやすく、明確で、再利用可能な方法で実現できます。

plugの詳細については、多くの組み込みplugや機能を提供しているPlugプロジェクトのドキュメントを参照してください。