Chapter 09

コントローラー

koga1020
koga1020
2021.03.02に更新

コントローラー

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

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

Phoenixコントローラーは、中間モジュールとして機能します。アクションと呼ばれる機能は、HTTPリクエストに応答してルーターから呼び出されます。アクションは必要なデータをすべて収集し、ビューレイヤーを呼び出してテンプレートをレンダリングしたり、JSONレスポンスを返したりする前に、必要なすべてのステップを実行します。

Phoenixのコントローラーもまた、プラグパッケージをベースにしており、それ自体がプラグです。コントローラーは、アクションで必要なことをほとんどすべて行うための機能を提供します。Phoenixコントローラーが提供していないものを探していることに気がついた場合は、プラグ自体の中に探しているものがあるかもしれません。詳細については、プラグガイドまたはプラグのドキュメントを参照してください。

新しく生成されたPhoenixアプリには、単一のコントローラーである PageController があり、lib/hello_web/controllers/page_controller.ex にあります。

defmodule HelloWeb.PageController do
  use HelloWeb, :controller

  def index(conn, _params) do
    render(conn, "index.html")
  end
end

モジュール定義の下の最初の行では、HelloWeb モジュールの __using__/1 マクロを呼び出しており、いくつかの便利なモジュールをインポートしています。

PageController は、Phoenixがルーターで定義したデフォルトルートに関連付けられたPhoenixのウェルカムページを表示するための index アクションを提供します。

アクション

コントローラーのアクションはただの関数です。Elixirの命名規則に従う限り、好きな名前をつけることができます。唯一満たさなければならない要件は、アクション名がルーターで定義されたルートと一致することです。

たとえば、lib/hello_web/router.ex では、新しいアプリでPhoenixが与えてくれるデフォルトのルートのアクション名をindexから変更できます。

get "/", PageController, :index

これを:testに変更できます。

get "/", PageController, :test

同様に PageController のアクション名を test に変更すれば、ウェルカムページは以前と同じように読み込まれます。

defmodule HelloWeb.PageController do
  ...

  def test(conn, _params) do
    render(conn, "index.html")
  end
end

アクションには好きな名前をつけることができますが、可能な限り従うべきアクション名の規約があります。ルーティングで説明しましたが、ここでも簡単に見てみましょう。

  • index - 与えられたリソースタイプの全アイテムのリストを表示します
  • show - IDを元に個々のアイテムを表示します
  • new - 新しいアイテムを作成するためのフォームをレンダリングします
  • create - 新しいアイテムのパラメーターを受け取り、それをデータストアに保存します
  • edit - 個々のアイテムをIDで取得し、編集用のフォームに表示します
  • update - 編集されたアイテムのパラメーターを受け取り、データストアに保存します
  • delete - 削除するアイテムのIDを受け取り、データストアから削除します

これらのアクションにはそれぞれ2つのパラメーターが必要で、これはPhoenixが裏で提供するものです。

最初のパラメーターは常に conn で、ホスト、パス要素、ポート、クエリ文字列などのリクエストに関する情報を保持する構造体です。conn は、Elixirのプラグミドルウェアフレームワークを介してPhoenixに提供されます。conn の詳細についてはプラグのドキュメントを参照してください。

2番目のパラメーターは params です。驚くことではありませんが、これはHTTPリクエストで渡されたすべてのパラメーターを保持するマップです。レンダリングに渡すことができるシンプルなパッケージのデータを提供するために、関数のシグネチャでparamsとパターンマッチするのは良い習慣です。これは、lib/hello_web/controllers/hello_controller.exshow ルートにmessengerパラメーターを追加したときに、リクエストライフサイクルガイドで見ました。

defmodule HelloWeb.HelloController do
  ...

  def show(conn, %{"messenger" => messenger}) do
    render(conn, "show.html", messenger: messenger)
  end
end

いくつかのケース、たとえば index アクションでは、動作がパラメーターに依存しないため、パラメーターを気にしないことがよくあります。そのような場合には、入力されるパラメーターを使用せず、単に変数名の前にアンダースコアを付けて _params とします。これにより、正しいアリティを維持しつつ、コンパイラが未使用の変数について文句を言わないようになります。

レンダリング

コントローラーには、コンテンツをレンダリングするいくつかの方法があります。もっとも単純なのは、Phoenixが提供する text/2 関数を使ってプレーンテキストをレンダリングすることです。

試しに、PageControllershow アクションをテキストを返すように書き換えてみましょう。そのためには、次のようにします。

def show(conn, %{"messenger" => messenger}) do
  text(conn, "From messenger #{messenger}")
end

これで /hello/FrankFrom messenger Frank をHTMLなしのプレーンテキストとして表示するようになりました。

この先のステップは json/2 関数を使って純粋なJSONをレンダリングすることです。JasonライブラリがJSONにデコードできるもの、たとえばmapのようなものを渡す必要があります。(JasonはPhoenixの依存関係の1つです)

def show(conn, %{"messenger" => messenger}) do
  json(conn, %{id: messenger})
end

ブラウザで /hello/Frank に再度アクセスすると、キー id が文字列 "Frank" にマップされたJSONのブロックが表示されるはずです。

{"id": "Frank"}

PhoenixのコントローラーはビューなしでHTMLをレンダリングすることもできます。すでにご存じかもしれませんが、html/2 関数がそれを実現しています。今回は、このように show アクションを実装します。

def show(conn, %{"messenger" => messenger}) do
  html(conn, """
   <html>
     <head>
        <title>Passing a Messenger</title>
     </head>
     <body>
       <p>From messenger #{Plug.HTML.html_escape(messenger)}</p>
     </body>
   </html>
  """)
end

これで /hello/Frank を入力すると、show アクションで定義したHTML文字列がレンダリングされます。アクションで書いたものは eex テンプレートではないことに注意してください。これは複数行の文字列なので、この <%= messenger %> の代わりに #{Plug.HTML.html_escape(messenger)} のように messenger 変数を補間します。

text/2json/2html/2 関数はPhoenixビューもテンプレートも必要としないことは注目に値します。

json/2 関数はAPIを書くのに便利で、他の2つは便利ですが、ほとんどの場合、レスポンスを構築する際はPhoenixのビューを使用します。このために、Phoenixは render/3 関数を提供します。

show アクションをリクエストライフサイクルガイドで書いたものにロールバックしてみましょう。

defmodule HelloWeb.HelloController do
  use HelloWeb, :controller

  def show(conn, %{"messenger" => messenger}) do
    render(conn, "show.html", messenger: messenger)
  end
end

render/3 関数が動作するためには、コントローラーとビューは show.html.eex テンプレートが存在するテンプレートディレクトリと同じルート名でなければなりません。言い換えれば、HelloControllerHelloView を必要とし、HelloViewlib/hello_web/templates/hello ディレクトリの存在を必要とし、そのディレクトリには show.html.eex テンプレートが含まれていなければなりません。

render/3messenger 変数をViewで利用するために、show アクションがパラメーターから受け取った値を渡します。

render を使用する際にテンプレートに値を渡す必要がある場合は、それは簡単です。messenger: messenger で見たようにキーワードリストを渡すこともできますし、Plug.Conn.assign/3 を使って便利に conn を返すこともできます。

  def show(conn, %{"messenger" => messenger}) do
    conn
    |> Plug.Conn.assign(:messenger, messenger)
    |> render("show.html")
  end

注意: Phoenix.Controller をuseすると Plug.Conn がimportされるため、assign/3 の呼び出しを短くしても問題ありません。

複数の値をテンプレートに渡すのは、assign/3 関数を繋げても簡単にできます。

  def show(conn, %{"messenger" => messenger}) do
    conn
    |> assign(:messenger, messenger)
    |> assign(:receiver, "Dweezil")
    |> render("show.html")
  end

一般的に言えば、すべての割り当てが設定されたら、ビューレイヤーを呼び出します。その後、ビューレイヤーはレイアウトと一緒に "show.html" をレンダリングし、レスポンスをブラウザに送り返します。

ビューとテンプレートには独自のガイドがあるので、ここではあまり時間をかけません。これから見ていくのは、コントローラーアクションの内部から、異なるレイアウトを割り当てたり、まったく割り当てなかったりする方法です。

レイアウトを割り当てる

レイアウトはテンプレートの特別なサブセットにすぎません。これらは lib/hello_web/templates/layout にあります。Phoenixはアプリを生成したときに、私たちのために1つ作成してくれました。デフォルトのレイアウトは app.html.eex と呼ばれ、デフォルトではすべてのテンプレートがレンダリングされるレイアウトです。

レイアウトは本当にただのテンプレートなので、それらをレンダリングするためのビューが必要です。これは lib/hello_web/views/layout_view.ex で定義されている LayoutView モジュールです。Phoenixがこのビューを生成してくれたので、レンダリングしたいレイアウトを lib/hello_web/templates/layout ディレクトリに置いておけば、新しいビューを作る必要はありません。

しかし、新しいレイアウトを作成する前に、可能な限り単純なことをして、レイアウトのないテンプレートをレンダリングしてみましょう。

Phoenix.Controller モジュールには、レイアウトを切り替えるための put_layout/2 関数が用意されています。これは conn を第1引数にとり、レンダリングしたいレイアウトのベース名を文字列で指定します。また、レイアウトを完全に無効にするには false を渡します。

PageController モジュール lib/hello_web/controllers/page_controller.exindex アクションを次のように編集します。

def index(conn, _params) do
  conn
  |> put_layout(false)
  |> render("index.html")
end

http://localhost:4000/を再読み込みすると、タイトル、ロゴ画像、CSSのスタイルがまったくない、まったく別のページが表示されるはずです。

では、実際に別のレイアウトを作成して、indexテンプレートをレンダリングしてみましょう。例として、アプリケーションの管理セクションのために、ロゴ画像を持たない別のレイアウトがあったとします。これを行うには、既存の app.html.eex を同じディレクトリ lib/hello_web/templates/layout にある新しいファイル admin.html.eex へコピーします。次に、admin.html.eex の中のロゴを表示している行を削除してみましょう。

<span class="logo"></span> <!-- remove this line -->

次に、lib/hello_web/controllers/page_controller.exindex アクションの put_layout/2 に新しいレイアウトのベースネームを渡します。

def index(conn, _params) do
  conn
  |> put_layout("admin.html")
  |> render("index.html")
end

ページを読み込んだときに、ロゴのない管理画面レイアウトをレンダリングしているはずです。

レンダリング形式のオーバーライド

テンプレートを使ってHTMLをレンダリングするのは良いのですが、その場でレンダリング形式を変更する必要がある場合はどうでしょうか?HTMLが必要な時もあれば、プレーンテキストが必要な時もあり、JSONが必要な時もあるとしましょう。その場合はどうすればいいのでしょうか?

Phoenixでは、_format クエリ文字列パラメーターを使用して、その場でフォーマットを変更できます。これを実現するために、Phoenixは適切なディレクトリに適切な名前のビューと適切な名前のテンプレートを必要とします。

例として、新しく生成されたアプリの PageController のindexアクションを見てみましょう。このアクションには、適切なビューPageView、適切なテンプレートディレクトリ lib/hello_web/templates/page、HTMLをレンダリングするための適切なテンプレート index.html.eex が含まれています。

def index(conn, _params) do
  render(conn, "index.html")
end

これにないのは、テキストをレンダリングするための代替テンプレートです。lib/hello_web/templates/page/index.text.eex にテンプレートを追加してみましょう。以下に index.text.eex テンプレートの例を示します。

OMG, this is actually some text.

これを動作させるには、もう少しやるべきことがあります。ルーターに text 形式を受け入れるように指示する必要があります。これを行うには、:browser パイプラインの受け入れ可能なフォーマットのリストに text を追加します。lib/hello_web/router.ex を開き、plug:acceptshtml と同様に text を含めるように変更してみましょう。

defmodule HelloWeb.Router do
  use HelloWeb, :router

  pipeline :browser do
    plug :accepts, ["html", "text"]
    plug :fetch_session
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end
...

また、Phoenix.Controller.get_format/1 が返すテンプレートと同じフォーマットのテンプレートをレンダリングするようにコントローラーに指示する必要があります。テンプレート名 "index.html" をアトムバージョン :index で代入します。

def index(conn, _params) do
  render(conn, :index)
end

http://localhost:4000/?_format=textにアクセスすると、"OMG, this is actually some text." が表示されます。

レスポンスを直接送信する

上記のレンダリングオプションのどれもニーズに合っていない場合は、Plugが提供する関数を使用して独自のレンダリングオプションを作成できます。たとえば、ステータスが "201" で、ボディが何もないレスポンスを送信したいとします。これは Plug.Conn.send_resp/3 関数を使えば簡単にできます。

PageController モジュール lib/hello_web/controllers/page_controller.exindex アクションを次のように編集してください。

def index(conn, _params) do
  conn
  |> send_resp(201, "")
end

http://localhost:4000をリロードすると、真っ白なページが表示されるはずです。ブラウザの開発者ツールのネットワークタブには「201」という応答ステータスが表示されているはずです。

コンテンツの種類を細かく指定したい場合は、put_resp_content_type/2send_resp/3 を組み合わせて使うことができます。

def index(conn, _params) do
  conn
  |> put_resp_content_type("text/plain")
  |> send_resp(201, "")
end

このようにPlug関数を使用することで、必要なレスポンスを作成できます。

コンテンツタイプを設定する

クエリ文字列パラメーター _format と同様に、HTTP Content-Typeヘッダーを修正して適切なテンプレートを提供することで、任意の種類のフォーマットをレンダリングできます。

index アクションのxmlバージョンをレンダリングしたい場合、lib/hello_web/page_controller.ex に次のようなアクションを実装するでしょう。

def index(conn, _params) do
  conn
  |> put_resp_content_type("text/xml")
  |> render("index.xml", content: some_xml_content)
end

あとは、有効なxmlを作成した index.xml.eex テンプレートを提供する必要があります。

有効なMIMEタイプのリストについては、mimeタイプライブラリのmime.typesのドキュメントを参照してください。

HTTPステータスを設定する

レスポンスのHTTPステータスコードもコンテンツタイプを設定するのと同じように設定できます。すべてのコントローラーにインポートされている Plug.Conn モジュールには、これを行うための put_status/2 関数があります。

Plug.Conn.put_status/2 は最初のパラメーターして conn を受け取り、2番目のパラメーターは設定したいステータスコードのアトムとして、整数か "フレンドリな名前" を指定します。ステータスコードのアトム表現のリストは Plug.Conn.Status.code/1 のドキュメントを参照してください。

PageControllerindex アクションのステータスを変更してみましょう。

def index(conn, _params) do
  conn
  |> put_status(202)
  |> render("index.html")
end

提供するステータスコードは有効な数値でなければなりません。

リダイレクト

リクエストの途中で新しいURLにリダイレクトする必要がよくあります。たとえば、create アクションが成功した場合、通常は作成したばかりのリソースへアクセスするため、show アクションにリダイレクトします。別の方法として、同じ型のすべてのリソースを表示するために index アクションへリダイレクトすることもできます。リダイレクトが有用なケースは他にもたくさんあります。

どのような状況であっても、Phoenixコントローラーには便利な redirect/2 関数があり、リダイレクトを簡単に行うことができます。Phoenixでは、アプリケーション内のパスへのリダイレクトと、アプリケーション内または外部のURLへのリダイレクトを区別しています。

redirect/2 を試すために、lib/hello_web/router.ex に新しいルートを作成してみましょう。

defmodule HelloWeb.Router do
  use HelloWeb, :router
  ...

  scope "/", HelloWeb do
    ...
    get "/", PageController, :index
    get "/redirect_test", PageController, :redirect_test
  end
end

次に、index アクションを変更して、ただ新しいルートにリダイレクトするだけにします。

def index(conn, _params) do
  redirect(conn, to: "/redirect_test")
end

最後に、リダイレクト先のアクションを同じファイルに定義してみましょう。これは単にindexをレンダリングしますが、別のアドレスとなります。

def redirect_test(conn, _params) do
  render(conn, "index.html")
end

ウェルカムページをリロードすると、オリジナルのウェルカムページを表示する /redirect_test にリダイレクトされていることがわかります。うまくいきました。

もし気になったら、開発者ツールを開いてネットワークタブをクリックして、/ ルートに再度アクセスしてみましょう。このページには2つの主要なリクエストがあります - ステータスが 302/ へのアクセスと、ステータスが 200/redirect_test へのアクセスです。

リダイレクト関数は conn とアプリケーション内の相対パスを表す文字列を受け取ることに注目してください。セキュリティ上の理由から、:to ヘルパーはアプリケーション内のパスのみをリダイレクトできます。完全修飾されたパスや外部のURLにリダイレクトしたい場合は、代わりに :external を使うべきです。

def index(conn, _params) do
  redirect(conn, external: "https://elixir-lang.org/")
end

また、ルーティングガイドで学んだパスヘルパーを活用することもできます。

defmodule HelloWeb.PageController do
  use HelloWeb, :controller

  def index(conn, _params) do
    redirect(conn, to: Routes.page_path(conn, :redirect_test))
  end
end

ルートヘルパーを使用することは、アプリケーション内の任意のページにリンクするための好ましいアプローチです。

フラッシュメッセージ

アクションの途中でユーザーとコミュニケーションを取る必要がある場合があります。スキーマを更新する際にエラーが発生したかもしれません。アプリケーションに戻ってきたユーザーを歓迎したいのかもしれません。このために、フラッシュメッセージがあります。

Phoenix.Controller モジュールは put_flash/3get_flash/2 関数を提供しており、フラッシュメッセージをキー値のペアとして設定したり取得したりするのに役立ちます。それでは、HelloWeb.PageController に2つのフラッシュメッセージを設定してみましょう。

そのためには、index アクションを次のように変更します。

defmodule HelloWeb.PageController do
  ...
  def index(conn, _params) do
    conn
    |> put_flash(:info, "Welcome to Phoenix, from flash info!")
    |> put_flash(:error, "Let's pretend we have an error.")
    |> render("index.html")
  end
end

フラッシュメッセージを表示するためには、それらを取得してテンプレート/レイアウトで表示できるようにする必要があります。最初の部分を行う方法の1つが get_flash/2 で、これは conn と関心があるキーを取得します。そして、そのキーの値を返します。

幸いなことに、私たちのアプリケーションレイアウト lib/hello_web/templates/layout/app.html.eex には、フラッシュメッセージを表示するためのマークアップがすでに用意されています。

<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p>
<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p>

ウェルカムページをリロードすると、"Welcome to Phoenix!" のすぐ上にメッセージが表示されるはずです。

フラッシュ機能は、リダイレクトと組み合わせると便利です。おそらく、追加情報のあるページにリダイレクトしたいと思います。先ほどのリダイレクトアクションを再利用すれば、次のように実現できます。

  def index(conn, _params) do
    conn
    |> put_flash(:info, "Welcome to Phoenix, from flash info!")
    |> put_flash(:error, "Let's pretend we have an error.")
    |> redirect(to: Routes.page_path(conn, :redirect_test))
  end

これでウェルカムページをリロードするとリダイレクトされ、フラッシュメッセージがもう一度表示されるようになりました。

Phoenix.Controller モジュールには、put_flash/3get_flash/2 の他にも知っておくと便利な関数があります。clear_flash/1conn のみを受け取り、セッションに保存されている可能性のあるフラッシュメッセージを削除します。

Phoenixは、どのキーがフラッシュに保存されているかを強制しません。内部的に一貫している限り、すべてうまくいきます。しかし、:info:error は一般的なものであり、テンプレートではデフォルトで処理されます。

アクションフォールバック

アクションフォールバックにより、コントローラーアクションが %Plug.Conn{} 構造体を返すのに失敗したときに呼び出されるプラグ内のエラー処理コードを一元化できます。これらのプラグは、元々コントローラーアクションに渡された conn とアクションの戻り値の両方を受け取ります。

たとえば、with を使ってブログ記事を取得し、現在のユーザーにそのブログ記事の閲覧を許可する show アクションがあるとしましょう。この例では、fetch_post/1 は記事が見つからなかった場合に {:error, :not_found} を返し、Authorizer.authorize/3 はユーザーが権限を持っていない場合に {:error, :unauthorized} を返すと期待できます。Phoenixが新しいアプリケーションごとに生成する ErrorView を使用して、これらのエラーパスを適切に処理できます。

defmodule HelloWeb.MyController do
  use Phoenix.Controller

  def show(conn, %{"id" => id}, current_user) do
    with {:ok, post} <- fetch_post(id),
         :ok <- authorize_user(current_user, :view, post) do
      render(conn, "show.json", post: post)
    else
      {:error, :not_found} ->
        conn
        |> put_status(:not_found)
        |> put_view(HelloWeb.ErrorView)
        |> render(:"404")

      {:error, :unauthorized} ->
        conn
        |> put_status(403)
        |> put_view(HelloWeb.ErrorView)
        |> render(:"403")
    end
  end
end

次に、APIで処理されるすべてのコントローラーやアクションに対して、同様のロジックを実装する必要があると想像してみてください。これは多くの繰り返しになります。

その代わりに、これらのエラーケースの処理方法を知っているモジュールプラグを定義できます。コントローラーモジュールプラグなので、プラグをコントローラーとして定義してみましょう。

defmodule HelloWeb.MyFallbackController do
  use Phoenix.Controller

  def call(conn, {:error, :not_found}) do
    conn
    |> put_status(:not_found)
    |> put_view(HelloWeb.ErrorView)
    |> render(:"404")
  end

  def call(conn, {:error, :unauthorized}) do
    conn
    |> put_status(403)
    |> put_view(HelloWeb.ErrorView)
    |> render(:"403")
  end
end

そして、新しいコントローラーを action_fallback として参照し、単に with から else ブロックを削除するだけです。

defmodule HelloWeb.MyController do
  use Phoenix.Controller

  action_fallback HelloWeb.MyFallbackController

  def show(conn, %{"id" => id}, current_user) do
    with {:ok, post} <- fetch_post(id),
         :ok <- authorize_user(current_user, :view, post) do
      render(conn, "show.json", post: post)
    end
  end
end

with の条件が一致しない場合、 HelloWeb.MyFallbackController は元の conn とアクションの結果を受け取り、適切に応答します。