PhoenixによるWeb開発 - LiveViewを使う -

## はじめに

久々にPhoenixのWeb開発を直近行ったので、その報告がてら執筆したいと思います。今回は主にLiveView周りの話です。

## 開発した機能

現在、開発中のシステムの全体絵はこちら→[Webアプリケーションの設計案](https://qiita.com/himrock922/items/46a74bc8ff4b37668c31#web%E3%82%A2%E3%83%97%E3%83%AA%E3%82%B1%E3%83%BC%E3%82%B7%E3%83%A7%E3%83%B3%E3%81%AE%E8%A8%AD%E8%A8%88%E6%A1%88)

そして、本記事の主に設計部分は下記の日報作成部分になります。

![関係性.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/144991/21997538-8af3-c918-4043-4d6205fd4f0d.png)

実際に日報に書く項目は"仕事"という対象に紐づく"作業項目"になります。そして、更にその作業項目で具体的に何をしたのかを記す"作業内容"を初めて日報という報告書に書くというプロセスとなっております。この作業内容は1つの日報に複数紐付いてもいいようにしたいので、実際に日報を書くフォームは`動的に作業内容を書く項目を増やしたり減らしたりすることができる`ようにします。
それを実装するために、今回はLiveViewを採用する事にしました。

## 中間層を1対多のアソシエーションにする

まずは、プロジェクト/スポンサーに紐づく作業項目のモデルをhas_manyで紐付けたいと思います。

```bash
mix phx.gen.html Propositions Proposition propositions name:string description:text
```

```elixir:lib/we_reports/groups/group.ex
defmodule WeReports.Groups.Group do
  (中略)
  schema "groups" do
    field :description, :string
    field :name, :string
    field :type_name, :string
    many_to_many :users, WeReports.UserManager.User, join_through: "groups_users", on_replace: :delete, on_delete: :delete_all
    has_many :propositions, WeReports.Propositions.Proposition, on_delete: :delete_all
    timestamps()
  end
end
```

```elixir:lib/we_reports/propositions/proposition.ex
defmodule WeReports.Propositions.Proposition do
  @moduledoc false
  use Ecto.Schema
  import Ecto.Changeset
  schema "propositions" do
    field :description, :string
    field :name, :string
    belongs_to :group, WeReports.Groups.Group
    timestamps()
  end
  @doc false
  def changeset(proposition, attrs) do
    proposition
    |> cast(attrs, [:name, :description, :group_id])
    |> validate_required([:name, :description, :group_id])
  end
end
```

newアクションについては以下のように変更しました。

```elixir:lib/we_reports_web/controllers/session_controller.ex
  def new(conn, %{"group_id" => group_id}) do
    group = Groups.get_group!(group_id)
    changeset =
      Ecto.build_assoc(group, :propositions)
      |> WeReports.Propositions.Proposition.changeset(%{})
    render(conn, "new.html", changeset: changeset, group: group)
  rescue
    Ecto.NoResultsError ->
      put_status(conn, :not_found)
      |> put_layout(false)
      |> put_view(WeReportsWeb.ErrorView)
      |> render("404.html", %{})
  end
```

デフォルトで作成されるcontrollerの記述には`changeset`というメソッドでモデルを用意します。
changesetはgen.htmlでリソースを生成した際に用意されるEcto.Changesetでモデルを生成するための雛形メソッドです。
Ecto.Changesetはモデル構造体を定義する際にvalidationやfilteringをかけたりすることが出来るメソッドが提供されています。
デフォルトではこのchangesetメソッドを利用してモデル構造体を生成しますが、今回外部キーとして`group_id`を必ず入れています。そのため、デフォルトのchangesetではcastの後にvalidation_requriedメソッドが走ってしまうため、group_idだけ用意したモデルの生成を行うことができません。
そこでnewアクション側でbuild_assocでモデルを生成することとしました。

なお、デフォルトで用意されるchangesetメソッドはあくまでも雛形であるため、これを使ってモデル構造体を生成しようとするのがそもそも良くないという意見もあります(validationをかけるメソッドと生成メソッドと別々に切り分けるとか)。
一先ずは、build_assocでモデルを生成する案を取りたいと思います。

### エラーページのカスタマイズ

因みに、上記のURLで書かれているコードはdeprectedなコードなため、公式のサイトを参考に今時な書き方に変更しました。

```elixir:lib/we_reports_web/controllers/proposition_controller.ex
  def index(conn, %{"group_id" => group_id}) do
    try do
      group = Propositions.list_propositions(group_id)
      render(conn, "index.html", group: group)
    rescue
      Ecto.NoResultsError ->
        put_status(conn, :not_found)
        |> put_layout(false)
        |> put_view(WeReportsWeb.ErrorView)
        |> render("404.html", %{})
    end
  end
```

![スクリーンショット 2020-03-29 22.31.29.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/144991/8d01917b-af06-d975-b3c6-d224f2cf2aea.png)


## 最下層の作業内容のリソースを日報作成時に同時に作れるようにしたい

### LiveViewについて

[https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html)

LiveViewとは、リアルタイムにサーバレンダリングを提供できるPhoenixの機能の一つです。
Phoenixのような、サーバサイドとフロントエンドが密結合なフレームワークの中で、リアルタイム通信とレンダリングのPushが一つの言語で実装可能なこの機能は、正に自分が開発したいアプリケーションに適している事でしょう。
とは言え、PhoenixもLiveViewも中々、参考文献を探すのが難しい部分がありますので一先ずは下記のチュートリアルを参考にLiveViewに対応したフォームを作ることにしました。

* [https://github.com/andreaseriksson/tutorials](https://github.com/andreaseriksson/tutorials)

```elixir:lib/we_reports/daily_reports/daily_report.ex
defmodule WeReports.DailyReports.DailyReport do
  @moduledoc false
  use Ecto.Schema
  import Ecto.Changeset

  schema "daily_reports" do
    field :reporting_date, :date
    field :memo, :string
    field :summary, :string
    has_many :articles, WeReports.Articles.Article, on_delete: :delete_all
    belongs_to :user, WeReports.UserManager.User
    timestamps()
  end

  @doc false
  def changeset(daily_report, attrs) do
    daily_report
    |> cast(attrs, [:reporting_date, :memo, :summary, :user_id])
    |> cast_assoc(:articles)
    |> validate_required([:user_id])
  end
end
```

```elixir:lib/we_reports/articles/article.ex
defmodule WeReports.Articles.Article do
  @moduledoc false
  use Ecto.Schema
  import Ecto.Changeset

  schema "articles" do
    field :title, :string
    field :body, :string
    field :work_time, :string
    field :delete, :boolean, virtual: true
    field :tmp_id, :string, virtual: true
    belongs_to :daily_report, WeReports.DailyReports.DailyReport
    timestamps()
  end

  @doc false
  def changeset(article, attrs) do
    article
    |> Map.put(:tmp_id, (article.tmp_id || attrs["tmp_id"]))
    |> cast(attrs, [:title, :body, :work_time, :delete])
    |> maybe_mark_for_deletion
  end

  defp maybe_mark_for_deletion(%{data: %{id: nil}} = changeset), do: changeset
  defp maybe_mark_for_deletion(changeset) do
    if get_change(changeset, :delete) do
      %{changeset | action: :delete}
    else
      changeset
    end
  end
end
```

モデルの定義はこれでOKです。特徴として、動的フォームでArticle(作業内容モデル)を削除するために、tmp_id,deleteというvirtual(DBには保存しない仮のフィールド)を定義しています。これにより、フロントエンドでは、`見た目上フォームが削除されたように見せかける`ようにしています。

### 特定のユーザに紐づく日報を作成する

話を簡単にするためにPOSTリクエスト(新規作成アクションのみ)だけのコードを添付します。

```elixir:lib/we_reports_web/controllers/daily_reports_controller.ex
defmodule WeReportsWeb.DailyReportController do
  use WeReportsWeb, :controller

  alias WeReports.DailyReports
  alias WeReports.DailyReports.DailyReport
  alias WeReports.UserManager.Guardian

  def index(conn, _params) do
    user = Guardian.current_user(conn)
    daily_reports = DailyReports.list_daily_reports
    render(conn, "index.html", daily_reports: daily_reports, user: user)
  end

  def new(conn, _params) do
    changeset = DailyReports.change_daily_report(%DailyReport{})
    render(conn, "new.html", changeset: changeset)
  end

  def create(conn, %{"daily_report" => daily_report_params}) do
    case DailyReports.create_daily_report(daily_report_params) do
      {:ok, daily_report} ->
        conn
        |> put_flash(:success, "日報の作成に成功しました。")
        |> redirect(to: Routes.daily_report_path(conn, :show, daily_report))

      {:error, %Ecto.Changeset{} = changeset} ->
        render(conn, "new.html", changeset: changeset)
    end
  end
end
```

```elixir:lib/we_reports_web/templates/daily_reports/new.html.eex
<div class="container">
  <h1>新規日報作成</h1>
    <%= live_render @conn, WeReportsWeb.DailyReportFormLive,
      session: %{
        "action" => Routes.daily_report_path(@conn, :create),
        "csrf_token" => Plug.CSRFProtection.get_csrf_token(),
        "current_user_id" => WeReports.UserManager.Guardian.current_user(@conn).id
      }
    %>
  <span><%= link "日報一覧に戻る", class: "btn btn-outline-secondary", to: Routes.daily_report_path(@conn, :index) %></span>
</div>
```

```elixir:lib/we_reports_web/templates/daily_reports/form.html.eex
<%= form_for @changeset, @action, [phx_change: :validate, class: "block", csrf_token: @csrf_token], fn f -> %>
  <%= hidden_input f, :user_id %>
  <div class="form-group">
    <%= label(f, :reporting_date, "作業実施日") %>
    <div class="input-group date" id="reporting_date" data-target-input="nearest">
      <%= text_input f, :reporting_date, class: "form-control datetimepicker-input" %>
      <div class="input-group-append" data-target="#reporting_date" data-toggle="datetimepicker">
        <div class="input-group-text"><i class="fa fa-calendar"></i></div>
      </div>
    </div>
    <div class="form-group">
      <%= label(f, :memo, "メモ") %>
      <%= textarea f, :memo, class: "form-control" %>
    </div>
    <div class="form-group">
      <%= label(f, :memo, "まとめ・感想") %>
      <%= textarea f, :summary, class: "form-control" %>
    </div>
  </div>
  <%= inputs_for f, :articles, fn v -> %>
    <div class="form-group">
      <%= label(v, :title, "案件タイトル") %>
      <%= select v, :title, Enum.flat_map(@user.groups, fn(g) -> Enum.map(g.propositions, fn(p) -> {"#{g.name} / #{p.name}", "#{g.name} / #{p.name}"} end) end), class: "form-control" %>
    </div>
    <div class="form-group">
      <%= label(v, :body, "作業内容") %>
      <%= textarea v, :body, class: "form-control" %>
    </div>
    <div class="form-group">
      <%= label(v, :work_time, "作業時間") %>
      <%= time_input v, :work_time, class: "form-control" %>
    </div>
    <div class="form-group">
      <%= label v, :delete, "削除" %><br>
      <%= if is_nil(v.data.tmp_id) do %>
        <%= checkbox v, :delete %>
      <% else %>
        <%= hidden_input v, :temp_id %>
        <a href="#" phx-click="remove-article" phx-value-remove="<%= v.data.tmp_id %>">&times</a>
      <% end %>
    </div>

  <% end %>
  <div class="form-group">
    <a href="#" phx-click="add_article" class="btn btn-success">新規案件の追加</a>
  </div>
  <%= submit "提出", class: "btn btn-primary" %>
<% end %>
```

```elixir:lib/we_reports_web/live/daily_reports/daily_report_form_live.ex
defmodule WeReportsWeb.DailyReportFormLive do
  @moduledoc false
  use Phoenix.LiveView
  alias WeReports.Repo
  alias WeReports.{Articles.Article, DailyReports, DailyReports.DailyReport, UserManager}

  @impl true
  def mount(params, session, socket) do
    case UserManager.get_user_groups(session["current_user_id"]) do
      user ->
        daily_report = get_daily_report(session, user)
        changeset =
          DailyReports.change_daily_report(daily_report)
          |> Ecto.Changeset.put_assoc(:articles, daily_report.articles)
        assigns = [
          action: session["action"],
          csrf_token: session["csrf_token"],
          changeset: changeset,
          daily_report: daily_report,
          user: user
        ]
        {:ok, assign(socket, assigns)}
    end
  end

  def render(assigns) do
    WeReportsWeb.DailyReportView.render("form.html", assigns)
  end

  @impl true
  def handle_event("add_article", _params, socket) do
    existing_articles = Map.get(socket.assigns.changeset.changes, :articles, socket.assigns.daily_report.articles)
    articles =
      existing_articles
      |> Enum.concat([
        DailyReports.change_article(%Article{tmp_id: get_tmp_id()})
      ])
    changeset =
      socket.assigns.changeset
      |> Ecto.Changeset.put_assoc(:articles, articles)
    {:noreply, assign(socket, changeset: changeset)}
  end

  def handle_event("remove-article", %{"remove" => remove_id}, socket) do
    articles =
      socket.assigns.changeset.changes.articles
      |> Enum.reject(fn %{data: article} ->
        article.tmp_id == remove_id
      end)
    changeset =
      socket.assigns.changeset
      |> Ecto.Changeset.put_assoc(:articles, articles)
    {:noreply, assign(socket, changeset: changeset)}
  end

  def handle_event("validate", %{"daily_report" => params}, socket) do
    changeset =
    socket.assigns.daily_report
    |> DailyReport.changeset(params)
    |> Map.put(:action, :insert)
    {:noreply, assign(socket, changeset: changeset)}
  end

  defp get_daily_report(%{"id" => id} = _daily_report_params, user), do: DailyReports.get_daily_report!(id)
  defp get_daily_report(_daily_report_params, user), do: %DailyReport{user_id: user.id, articles: []}
  defp get_tmp_id, do: :crypto.strong_rand_bytes(5) |> Base.url_encode64 |> binary_part(0, 5)
end
```

要は最初のnewアクション時のレンダリングはnew.html.eexで行い、その中で`live_render`というメソッドを使い、LiveView用のHTMLテンプレートをレンダリングさせています。リアルタイムに変更が加わった場合はこのlive_renderの部分で提供されたHTMLテンプレートに変更が加えられます。そしてそこにcurrent_user_idの変数をもたせることにより、`どのユーザに対しての日報を作るのか`という事をサーバサイドが認識できるようにしています。

最初は、このlive_renderをlive_redirectにしており、新規作成のリンクを押すと、そこで表現されるフォームは全てLiveView対応にすることを検討していました。

[https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.Helpers.html#live_redirect/2](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.Helpers.html#live_redirect/2)

しかし、この実装方法には一つ問題があり、LiveViewによるinitializeを行うために、必ず`mount`メソッドが実行されるのですが、実はこのmountメソッド`initializeの時とLiveViewによるSocketとのコネクション確立時の二回実行される`という仕様の模様です。

* [https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#c:mount/3](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#c:mount/3)

そうなると、live_redirectでユーザIDのようなリソースを持たせると一回目のmountメソッド実行にはユーザ検索ができるのですが、二回目のmountメソッド実行時には、live_redirectで渡したユーザリソースが分からなくなり、`コネクション確立できずに失敗してしまう問題がありました。`

仕方なしにコントローラ側で一部のリソースの取得を固定させ、live_render時にユーザリソースを渡すようにしました。これであれば、二回目のmountメソッド時にもユーザ検索ができるようにしています(他にもGETパラメータでuser_id持たせる方法とかしていましたが、流石にこれはGETパラメータ弄られたらどうしようもないので辞めました)。

## 動作画面

動作画面です。

![日報作成.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/144991/3e97d205-3420-18ce-42dc-1e6f535f43d1.gif)

無事に作成できていますね。

## 終わりに

LiveViewに関しては、ブランクもあり実装(検討時間含む)には、かなり難航していましたが、とりあえず仕様とできることをちゃんと見極めれば、これほどリアルタイム通信の実装が楽にできるものはないんじゃないかなとか思っていたりします。

そして、ここまでの実装で一先ず最低限の事はできるようになったので、リポジトリ公開しておきます。

[https://github.com/himrock922/we_reports](https://github.com/himrock922/we_reports)

今回のアプリケーションもサンプルとして外部公開する予定なので、それまでお楽しみに。
計画としてはGCPに構築したjaistingのコンテナホスティングでwe_reportsのコンテナを建てようと思います。

jaistingについてはこちら→[https://note.com/himrock922/n/n0a1b7d8fc59f](https://note.com/himrock922/n/n0a1b7d8fc59f)

それでは。

## おまけ

### Phoenixのアップグレード(1.4から1.5にアップグレード)

まず最初に、新しいphxのバージョンで新しいPhoenixアプリケーションのプロジェクトが生成できるようにarchive.installコマンドでPhoenix1.5系のアーカイブファイルをインストールします。

```bash
$ mix archive.uninstall phx_new
$ mix archive.install hex phx_new 1.5.1
```

```elixir:mix.exs
  defp deps do
    [
      {:phoenix, "~> 1.5.0"},
      {:phoenix_pubsub, "~> 2.0"},
      {:plug_cowboy, "~> 2.1"},
      ...
    ]
```

### PubSub 2.0系による変更

```elixir:config/config.exs
config :we_reports, WeReportsWeb.Endpoint,
  ...
  pubsub_server: WeReportsWeb.PubSub
```

```elixir:lib/we_reports/application.ex
  def start(_type, _args) do
    # List all child processes to be supervised
    children = [
      # Start the PubSub system
      {Phoenix.PubSub, name: WeReports.PubSub},
      ...
```

### レイアウト関係でdeprecatedになったコードの置き換え

```elixir:
<%= render(@view_module, @view_template, assigns) %>
```

この書き方がdeprecatedになったため、以下のように置き換えます。

```elixir:
<%= @inner_content %>
```

### テスト関係でdeprecatedになったコードを置き換え

```elixir:test/scupport/conn_case.ex
  using do
    quote do
      # Import conveniences for testing with connections
-      use Phoenix.ConnTest
+      import Plug.Conn
+      import Phoenix.ConnTest
```

## 参考文献

* [https://elixirschool.com/ja/lessons/ecto/associations/#1%E5%AF%BE%E5%A4%9A%E3%82%B9%E3%82%AD%E3%83%BC%E3%83%9E](https://elixirschool.com/ja/lessons/ecto/associations/#1%E5%AF%BE%E5%A4%9A%E3%82%B9%E3%82%AD%E3%83%BC%E3%83%9E)
* [https://hexdocs.pm/ecto/Ecto.Schema.html#has_many/3](https://hexdocs.pm/ecto/Ecto.Schema.html#has_many/3)
* [https://elixirforum.com/t/how-to-render-an-errorview-without-fallbackcontroller/14260](https://elixirforum.com/t/how-to-render-an-errorview-without-fallbackcontroller/14260)
* [https://hexdocs.pm/phoenix/Phoenix.Controller.html#render/4](https://hexdocs.pm/phoenix/Phoenix.Controller.html#render/4)
* [https://hexdocs.pm/ecto/Ecto.Changeset.html](https://hexdocs.pm/ecto/Ecto.Changeset.html)
* [https://hexdocs.pm/phoenix_live_view](https://hexdocs.pm/phoenix_live_view)
* [https://gist.github.com/chrismccord/e53e79ef8b34adf5d8122a47db44d22f](https://gist.github.com/chrismccord/e53e79ef8b34adf5d8122a47db44d22f)
* [https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#c:mount/3](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html#c:mount/3)
* [https://github.com/andreaseriksson/tutorials](https://github.com/andreaseriksson/tutorials)