Phoenixエラー処理①：Web CRUD基礎編（Prod環境構築付き）

[fukuoka.ex／kokura.ex](https://fukuokaex.connpass.com/)のpiacereです
ご覧いただいて、ありがとうございます :bow:

Phoenixのエラーハンドリングは、あまり文献も多く無くて、いざ実際のPJでいじろうとしたとき、どう手を付けていけば良いか、悩む方も多いのでは無いかと思います

そこで、Phoenixのエラー処理について、基礎的なところを解説しておきます

今回は、「phx.gen.html」で構築できる、Web CRUDに集中した基礎編です（他には、API CRUD基礎編やLiveView基礎編があります）

# 本コラムの検証環境

本コラムは、以下環境で検証しています（Windowsで実施していますが、Linuxやmacでも動作する想定です）

- Windows 10
- Elixir 1.9.1　 　　　　 ※[<b>最新版のインストール手順はコチラ</b>](https://qiita.com/piacere_ex/items/6714e1440e3f25fb46a1#%E4%BA%8B%E5%89%8D%E6%BA%96%E5%82%99elixir%E3%81%AE%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%AB)
- Phoenix 1.4.11　　 　※[<b>最新版のインストール手順はコチラ</b>](https://qiita.com/piacerex/items/b7787580fce5f148242f#phoenix%E3%81%AE%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%AB)
- PostgreSQL 12.1 　　 ※[<b>最新版のインストール手順はコチラ</b>](https://qiita.com/piacerex/items/a7558adc6856e3577dc6#postgresql%E3%81%AE%E3%82%A4%E3%83%B3%E3%82%B9%E3%83%88%E3%83%BC%E3%83%AB)

# Phoenixデフォルト状態のエラー処理

まずは、Phoenixデフォルト状態のエラー処理を確認します

PhoenixプロジェクトをDB利用有で作るところからです

```
mix phx.new either
Fetch and install dependencies? [Yn] y
cd either
mix ecto.create
mix phx.gen.html Api Post posts title:string body:text
```

router.exにエンドポイント追加します

```elixir:lib/either_web/router.ex
defmodule EitherWeb.Router do
…
  scope "/", EitherWeb do
    pipe_through :browser

    get "/", PageController, :index
    resources "/posts", PostController  # <- add here
  end
…
```

マイグレートし、Phoenixを起動します

```
mix ecto.migrate
iex -S mix phx.server
```

マイグレートしたページを、ブラウザから``http://localhost:4000/posts``で表示します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/82585e8d-9453-c2d8-1ab7-c4d04e01f983.png)

このページのコード（コントローラ）は下記です

```elixir:lib/either_web/controllers/post_controller.ex
defmodule EitherWeb.PostController do
…
  def index(conn, _params) do
    posts = Api.list_posts()
    render(conn, "index.html", posts: posts)
  end
…
```

このコントローラは、DBアクセスをしているので、テーブルをリネームすると、エラーを起こします

psqlから下記でテーブルをリネームしてみましょう

```
\c either_dev
alter table posts rename to ppposts;
```

エラー表示は、dev環境とProd環境で異なります

## dev環境でのエラー表示／定義

ブラウザで``http://localhost:4000/posts``を再度アクセスすると、下記エラーデバッグ画面が表示されます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/b4e692b5-adb9-06ca-2cd1-3f16b53abd99.png)

このエラーデバッグ画面は、下記に定義されています

```erb:deps/plug/lib/plug/templates/debugger.html.eex
…
<body>
    <div class="top-details">
        <%= if @style.logo do %>
        <aside class="exception-logo"></aside>
        <% end %>

        <header class="exception-info">
            <% [headline | details] = String.split(@message, "\n\n") %>
            <h5 class="struct">
                <%= h @title %>
                <small>at <%= h method(@conn) %></small>
                <small class="path"><%= h @conn.request_path %></small>
            </h5>
            <h1 class="title"><%= h headline %></h1>
            <%= for detail <- details do %>
            <p class="detail"><%= h detail %></p>
            <% end %>
        </header>

    <%= if @banner do %>
        </div>
        <div class="banner"><%= @banner %></div>
        <div class="top-details">
    <% end %>
…
```

上記ファイルを変更し、下記コマンドでビルドし直せば、この画面を直接変更することもできます（なお、通常は後述する方法で変更するので、この方法は使いません）

```
mix deps.compile
```

デバッグ画面のスタイルのカスタマイズをしたい場合は、@ngmr_mo さんの下記コラムが役立つと思います

<b>Plug.ErrorHandlerとPlug.DebuggerでPhoenixの標準のエラーをカスタマイズする</b>
https://qiita.com/ngmr_mo/items/7d03ac5d616aa8a42488

## Prod環境でのエラー表示／定義

Prod環境への切り替えは、Ctrl＋Cで抜けた後、以下で行います

```
set MIX_ENV=Prod
set DATABASE_URL=ecto://postgres:postgres@localhost/either_dev
set SECRET_KEY_BASE=dummy
mix phx.gen.secret

fnVgWnXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

set SECRET_KEY_BASE=fnVgWnXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX  # 上記シークレットを貼り付け
iex -S mix phx.server
```

Prod環境では、下記エラーが表示されます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/4e82975e-2321-6ea8-5686-ede0fa49c9fa.png)

このエラー画面は、下記に定義されています

```elixir:deps/plug/lib/plug/conn/status.ex
defmodule Plug.Conn.Status do
…
  statuses = %{
    100 => "Continue",
    101 => "Switching Protocols",
    …
    500 => "Internal Server Error",
…
```

上記ファイルを変更し、mix deps.compileでビルドし直せば、やはりこの画面を直接変更できますが、通常は、この方法は使いません

## dev環境のエラーデバッグを解除する

Ctrl＋Cで抜けた後、以下でdev環境に戻します

```
set MIX_ENV=
iex -S mix phx.server
```

エラー画面も下記デバッグ表示に戻ったことを、ブラウザをリロードして確認します
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/b4e692b5-adb9-06ca-2cd1-3f16b53abd99.png)

ここから、dev.exsのdebug_errorsを無効にします

```elixir:config/dev.exs
use Mix.Config
…
config :either, EitherWeb.Endpoint,
  http: [port: 4000],
  debug_errors: false,  # <- change here from true
…
```

Ctrl＋Cで抜けて、Phoenixを再起動します

```
iex -S mix phx.server
```

ブラウザをリロードすると、Prod環境と同じエラー画面になります
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/4e82975e-2321-6ea8-5686-ede0fa49c9fa.png)

こうすることで、dev環境とProd環境で同様のエラー処理を共有できます

# Phoenixのエラー処理カスタマイズ

Phoenixのエラー処理は、以下2つの方法で、カスタマイズできます

- ErrorViewでエラー画面レンダリング関数を追加
- FallbackControllerの追加

## ①ErrorViewでエラー画面レンダリング関数を追加

ErrorViewを使えば、ステータスコード毎のエラー画面レンダリングを、関数パターンマッチで追加できます

カスタマイズ前のErrorViewは、以下の通りです

```elixir:lib/either_web/views/error_view.ex　※カスタマイズ前
defmodule EitherWeb.ErrorView do
  use EitherWeb, :view

  # If you want to customize a particular status code
  # for a certain format, you may uncomment below.
  # def render("500.html", _assigns) do
  #   "Internal Server Error"
  # end

  # By default, Phoenix returns the status message from
  # the template name. For example, "404.html" becomes
  # "Not Found".
  def template_not_found(template, _assigns) do
    Phoenix.Controller.status_message_from_template(template)
  end
end
```

これを下記のように書き換えます

```elixir:lib/either_web/views/error_view.ex　※カスタマイズ後
defmodule EitherWeb.ErrorView do
  use EitherWeb, :view

  # If you want to customize a particular status code
  # for a certain format, you may uncomment below.
  def render( "500.html", assigns ) do
    raw( "<h1>こんな感じでカスタマイズできます</h1><h2>raw()を使えば生HTMLも使えます</h2><code>#{ inspect( assigns ) }</code>" )
  end

  # By default, Phoenix returns the status message from
  # the template name. For example, "404.html" becomes
  # "Not Found".
  def template_not_found(template, _assigns) do
    Phoenix.Controller.status_message_from_template(template)
  end
end
```

deps配下では無い通常のソースコード変更なので、recompileで反映できます

```
iex> recompile
```

ブラウザをリロードすると、下記のように、カスタマイズされたエラー画面が表示されます
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/347a9493-a895-8480-90b8-83faca24fe96.png)

エラー詳細は、assigns内に全て入っているので、関数パターンマッチ、もしくは関数内でエラー内容に応じてエラー画面を切り替えることもできます（recompileで反映できます）

```elixir:lib/either_web/views/error_view.ex　※テーブル未存在エラーは関数パターンマッチで切り替え
defmodule EitherWeb.ErrorView do
  use EitherWeb, :view

  # If you want to customize a particular status code
  # for a certain format, you may uncomment below.
  def render( "500.html", %{ kind: :error, reason: %Postgrex.Error{ postgres: %{ code: :undefined_table } } } ) do
    "テーブル未存在"
  end
  def render( "500.html", assigns ) do
    raw( "<h1>こんな感じでカスタマイズできます</h1><h2>raw()を使えば生HTMLも使えます</h2><code>#{ inspect( assigns ) }</code>" )
  end

  # By default, Phoenix returns the status message from
  # the template name. For example, "404.html" becomes
  # "Not Found".
  def template_not_found(template, _assigns) do
    Phoenix.Controller.status_message_from_template(template)
  end
end
```

## ②FallbackControllerを追加

FallbackControllerを追加しておくと、コントローラでrender()が行われなかった場合、FallbackController.call()が呼ばれるため、これを使って、エラー処理ができます

まず、FallbackControllerを追加します（エラーを見分けやすくするため、故意に400エラーとしています）

エラー詳細は、ErrorView同様、call()の第2引数にしているassigns内に全て入っています（ここでは、IO.inspectしています）

```elixir:lib/either_web/controllers/fallback_controller.ex
defmodule EitherWeb.FallbackController do
	use EitherWeb, :controller
	alias EitherWeb.ErrorView

	def call( conn, assigns ) do
		IO.puts "=================================="
		IO.inspect assigns
		IO.puts "=================================="

		status = 400
		conn
		|> put_status( status )
		|> put_view( ErrorView )
		|> render( "#{ status }.html" )
		
	end
end
```

それから、コントローラ側にFallbackControllerをaction_fallback()で紐付けます

なお、テーブル未存在時は、Api.list_posts()が例外を吐くため、try／rescueで囲む必要があります

```elixir:lib/either_web/controllers/post_controller.ex
defmodule EitherWeb.PostController do
  use EitherWeb, :controller
	alias EitherWeb.ErrorView

  alias Either.Api
  alias Either.Api.Post

  action_fallback( EitherWeb.FallbackController )  # <- add here

  def index(conn, _params) do
    try do
      posts = Api.list_posts()
      render(conn, "index.html", posts: posts)
    rescue
      e -> e
    end
  end
…
```

recompileして、リロードすると、FallbackControllerで設定した400エラー画面が表示されました
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/6ce5898a-7b75-232b-aa5a-8408b7510129.png)

なお、IO.inspectしているエラー詳細は、コンソール側に出ます

```elixir
iex> ==================================
iex> %Postgrex.Error{
  connection_id: 3892,
  message: nil,
  postgres: %{
    code: :undefined_table,
    file: "X:\\pginstaller_12.auto\\postgres.windows-x64\\src\\backend\\parser\\parse_relation.c",
    line: "1194",
    message: "リレーション\"posts\"は存在しません",
    pg_code: "42P01",
    position: "79",
    routine: "parserOpenTable",
    severity: "ERROR",
    unknown: "ERROR"
  },
  query: "SELECT p0.\"id\", p0.\"body\", p0.\"title\", p0.\"inserted_at\", p0.\"updated_at\" FROM \"posts\" AS p0"
}
iex> ==================================
```

FallbackControllerも、エラー内容に応じて、関数パターンマッチ、もしくは関数内でエラー画面を切り替えれるのはErrorView同様です

テーブル未存在時は、[昨年末、Go界隈やNode.js界隈でザワザワしていた](https://qiita.com/flano_yuki/items/ea251ac6b53bc54677d2)、ステータスコード418「I’m a teapot」エラー画面を返すようにしてみましょう（あくまで例ですよｗ）

```elixir:lib/either_web/controllers/fallback_controller.ex
defmodule EitherWeb.FallbackController do
	use EitherWeb, :controller
	alias EitherWeb.ErrorView

	def call( conn, %Postgrex.Error{ postgres: %{ code: :undefined_table } } ) do
		status = 418
		conn
		|> put_status( status )
		|> put_view( ErrorView )
		|> render( "#{ status }.html" )
	end

	def call( conn, assigns ) do
		IO.puts "=================================="
		IO.inspect assigns
		IO.puts "=================================="

		status = 400
		conn
		|> put_status( status )
		|> put_view( ErrorView )
		|> render( "#{ status }.html" )
		
	end
end
```

recompileして、リロードすると、「I’m a teapot」エラー画面に切り替わりました
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155423/ff862c7e-2503-ae8f-31bd-71289c21a197.png)

## エラーハンドリングの優先順位など

なお、ErrorViewとFallbackControllerが同時に設定されている場合、FallbackControllerが勝ちます

ただし、FallbackControllerでマッチする関数が無かった場合や、エラーを起こした場合は、ErrorView側が呼び出されます

# 終わり

ここまでが、Phoenixにおけるエラーハンドリングの基本になります

ここから、実際のPJでエラー処理を作り込んでいく場合、以下のようなトピックが出てきます

- エラー画面の高度なカスタマイズ（専用ViewによるHTMLテンプレート化）
- ステータスコードやアプリ固有エラーの網羅
- try／rescueの扱い方（≒アプリ全体のエラー設計）

これらについては、別途コラム化する予定です

# p.s.「いいね」よろしくお願いします

ページ左上の![image.png](https://qiita-image-store.s3.amazonaws.com/0/155423/4d515047-cc48-382e-c2b1-3ad0cc50dbbf.png)や![image.png](https://qiita-image-store.s3.amazonaws.com/0/155423/a4e3da58-70a3-4197-95a2-6a6906650d01.png) のクリックを、どうぞよろしくお願いします:bow:
ここの数字が増えると、書き手としては「ウケている」という感覚が得られ、連載を更に進化させていくモチベーションになりますので、もっとElixirネタを見たいというあなた、私達と一緒に盛り上げてください！:tada: