技術メモ

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

Phoenix入門 (番外編 LiveView その1 `mix phx.new`からThermostatが動くまで)

f:id:ysmn_deus:20190219130922p:plain

どうも、靖宗です。 なにやらLiveViewなる機能がちらほらツイッターで見かけまして、一応触っておこうと思いました。
たぶん未履修分野には被らないはず・・・

下記サイトを参考にしております。

dockyard.com

github.com

LiveView

名前からも感じますが、どうやらLiveViewというのはリアルタイムの双方向性のあるレンダリング機能のようです。via WebSocketsとあるのでWebSocketでうまいこと実装されているんでしょう。
JavaScriptでフロント+バックエンド(Phoenix)やと複雑やしメンテナンスたいへんやろ?というモチベーションのようです。

主な機能としては

  • リアルタイムで値をアップデートする
  • クライアント側でのデータのバリデーション(バックエンドでの評価が要らない)
  • 入力のオートコンプリート
  • リアルタイム性のあるゲーム

JavaScript無しでできるそうです。まじか。

Programming Model

書き方は大まかにはController+Viewと通常のHTMLレンダリングに近いようです。
おそらく今までレンダリングしていたテンプレートがモジュールと化し、そのモジュールで定義する関数が様々なイベントのコールバック関数になるイメージでしょう。

プロジェクト作成

なにはともあれ動かしてみます。新しいプロジェクトでやってみましょう。
新しいプロジェクト作る練習にもなります。名前はhello_liveviewにしました。

PS > mix phx.new hello_liveview

hello_liveview/config/dev.exsを編集します。

...
# Configure your database
config :hello_liveview, HelloLiveview.Repo,
  username: "elixir",
  password: "elixir",
  database: "hello_liveview_dev",
  hostname: "localhost",
  pool_size: 10

データベースのユーザー情報などは適宜合わせて下さい。
再起動してコンテナが落ちてたので起動。

PS > cd hello_liveview
PS \hello_liveview> docker start ph_psql
ph_psql

mix ecto.createでデータ作成。

PS \hello_liveview> mix ecto.create
The database for HelloLiveview.Repo has been created

これで起動まではいく筈。とりあえず起動。

PS \hello_liveview> mix phx.server
...

問題ナシ。
参考にしたブログでもPageControllerを編集してるのでそれに則る。

LiveViewの機能を有効にする

Phoenix側の依存関係を追記する

LiveViewは標準機能ではなくオプションとして提供されています。
ですので依存関係をmix.exsに追記してmix deps.getしてやる必要があります。

...
  # Specifies your project dependencies.
  #
  # Type `mix help deps` for examples and options.
  defp deps do
    [
...
      {:plug_cowboy, "~> 2.0"},
      {:phoenix_live_view, github: "phoenixframework/phoenix_live_view"} #コレを追記
    ]
  end
...

mix deps.getする。

PS \hello_liveview> mix deps.get
* Getting phoenix_live_view (https://github.com/phoenixframework/phoenix_live_view.git)
...

ソケット通信の設定

もうちょっと設定が必要なよう。エンドポイントにsigning_saltなるものが必要だそうで。mix phx.gen.secret 32を実行して得られたお塩(salt)をconfig.exsのエンドポイントの設定に追記してやります。

PS \hello_liveview> mix phx.gen.secret 32
SECRET_SALT(なんか変な文字列がでます)

config.exsのエンドポイントの設定に追記します。

...
# Configures the endpoint
config :hello_liveview, HelloLiveviewWeb.Endpoint,
...
  pubsub: [name: HelloLiveview.PubSub, adapter: Phoenix.PubSub.PG2], # ここのカンマつけ忘れてエラーでてた・・・
  live_view: [
    signing_salt: SECRET_SALT(さっき出力した文字列)
  ]
...

config :hello_liveview,句の一番最後に追記しました。

テンプレートファイルを有効にする

ついでにLiveViewのテンプレートは.leexになるそうなので、この設定も同じconfig.exsに書いときます。

...
config :phoenix,
  template_engines: [leex: Phoenix.LiveView.Engine]

一番最後に追記しました。

router.exのパイプラインに設定を追加する

お次にパイプラインに設定を追加しておきます。lib/hello_liveview_web/router.ex:browserパイプラインを編集します。

defmodule HelloLiveviewWeb.Router do
  use HelloLiveviewWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_flash
    plug Phoenix.LiveView.Flash
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug :put_layout, {HelloLiveviewWeb.LayoutView, :app}
  end
...

レイアウトに当てはめるplugを明示的に記載していますが、後述するコントローラからLiveViewを呼び出す場合は要らないと思います。

hello_liveview_web.exのviewとrouterでインポートの設定を追記

次にlib/hello_liveview_web.exのviewとrouterの箇所も編集します。

def view do
  quote do
    ...
    import Phoenix.LiveView, only: [live_render: 2, live_render: 3]
  end
end

def router do
  quote do
    ...
    import Phoenix.LiveView.Router
  end
end

LiveViewのレンダリング関数とルーティング機能のインポートを追記しました。
これは全てのViewでインポートされてしまうのでいちいちLiveViewの方で読み込んでも良いのかもしれませんが、LiveView多めのアプリケーションならこの手法で良いと思います。

Endpointの設定

まだ設定は続きます。(かなり手間なのでそのうちこの辺はmix phx.newのオプションとかになるんじゃないでしょうか?)
lib/hello_liveview_web/endpoint.exにLiveViewに使用されるソケット通信を追記します。

defmodule HelloLiveviewWeb.Endpoint do
  use Phoenix.Endpoint, otp_app: :hello_liveview

  socket "/live", Phoenix.LiveView.Socket,
    websocket: true
...

PhoenixGitHubではotp_app: :hello_liveviewは無いですが、Phoenixのデフォルトなので一応書いておきます。

LiveViewを入れとくフォルダの作成+ホットリロードの設定

LiveViewを入れておくフォルダlib/hello_liveview_web/liveを作成しておき、ホットリロードを有効にするためにconfig/dev.exsに追記しておきます。

...
# Watch static and templates for browser reloading.
config :hello_liveview, HelloLiveviewWeb.Endpoint,
  live_reload: [
    patterns: [
      ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$},
      ~r{priv/gettext/.*(po)$},
      ~r{lib/hello_liveview_web/views/.*(ex)$},
      ~r{lib/hello_liveview_web/templates/.*(eex)$},
      ~r{lib/hello_liveview_web/live/.*(ex)$} #これ
    ]
  ]
...

JavaScript側の依存関係を追記する

以上でおそらくPhoenix自体の初期設定は終わりました。仕上げにレンダリングされるJavaScript周りに手を加えます。
バックで動いているJSの依存関係を編集します。assets/package.jsonを編集します。

...
  "dependencies": {
    "phoenix": "file:../deps/phoenix",
    "phoenix_html": "file:../deps/phoenix_html",
    "phoenix_live_view": "file:../deps/phoenix_live_view"
  },
...

上記依存関係を追記したらnpm installしておいて下さい。単純な事ですが僕はコレでハマりました(笑)

PS \hello_liveview> cd .\assets\
PS \hello_liveview\assets> npm isntall

ソケット通信開始の箇所を追記する

レンダリングされるJavaScript側からPhoenixへの通信を開始する箇所を追記します。
assets/js/app.jsの最後に下記を追記します。

import LiveSocket from "phoenix_live_view"

let liveSocket = new LiveSocket("/live")
liveSocket.connect()

これで初期設定は大体OKなはずです。

ThermostatViewの実装

試しにサンプルのThermostatViewを実装してみます。

追加の依存関係

ThermostatViewはcalendarというライブラリを使用しています。mix.exs:calendarの依存関係を追記しておきます。

...
  defp deps do
    [
      {:phoenix, "~> 1.4.1"},
      {:phoenix_pubsub, "~> 1.1"},
      {:phoenix_ecto, "~> 4.0"},
      {:ecto_sql, "~> 3.0"},
      {:postgrex, ">= 0.0.0"},
      {:phoenix_html, "~> 2.11"},
      {:phoenix_live_reload, "~> 1.2", only: :dev},
      {:gettext, "~> 0.11"},
      {:jason, "~> 1.0"},
      {:plug_cowboy, "~> 2.0"},
      {:phoenix_live_view, github: "phoenixframework/phoenix_live_view"},
      {:calendar, "~> 0.17.4"}
    ]
  end
...

mix deps.getをお忘れ無きよう。(忘れててもmixが教えてくれますが。)

CSSの追加

ThermostatのCSSを追加します。この編は素直にassets/cssに保存しておきます。
ただ、レイアウトが違うのかちょっと位置が微妙なのでassets/css/thermostat.cssのmargin-topだけいじります。

...
.thermostat {
...
  margin-top: 250px;
}
...

assets/css/app.cssでインポートしておきます。

@import "./phoenix.css";
@import "./thermostat.css";

LiveViewのルーティング方法は2種類

LiveViewのルーティング方法は2種類あります。
1個目はrouter.exからコントローラを介さずに直接レンダリングする方法で、liveマクロを利用します。

...
  scope "/", HelloLiveviewWeb do
    pipe_through :browser

    get "/", PageController, :index
    live "/thermostat", ThermostatView
  end
...

2個目は従来通りコントローラを介してレンダリングします。

...
  scope "/", HelloLiveviewWeb do
    pipe_through :browser

    get "/", PageController, :index
    get "/thermostat", PageController, :thermo
  end
...

たぶんコントローラを介してレンダリングする方が柔軟性があると思いますのでそちらで実装していきたいと思います。
故に:brouserパイプラインのplug :put_layout, {HelloLiveviewWeb.LayoutView, :app}は不要ですので消していただいても問題無いかと思います。

ルーティング設定

上記に従ってlib/hello_liveview_web/router.exを編集します。

defmodule HelloLiveviewWeb.Router do
  use HelloLiveviewWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_flash
    plug Phoenix.LiveView.Flash
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", HelloLiveviewWeb do
    pipe_through :browser

    get "/", PageController, :index
    get "/thermostat", PageController, :thermo
  end
end

コントローラの設定

めんどくさいのでlib/hello_liveview_web//controllers/page_controller.exを編集して使います。

defmodule HelloLiveviewWeb.PageController do
  use HelloLiveviewWeb, :controller
  alias Phoenix.LiveView

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

  def thermo(conn, _params) do
    LiveView.Controller.live_render(conn, HelloLiveviewWeb.ThermostatView, session: %{})
  end
end

live_renderlive_render/2もインポートしてるので最後のマップは要らなさそうな気もするんですがなんか渡さないと怒られたのでlive_render/3を利用しています。
ちょっとこの辺は後ほど再確認したいところです・・・

index/2thermo/2を比較すると、関数名こそ違えどほぼ同じ様な構造になっているので抵抗感無く使えるかと思います。

LiveViewの追加

lib/hello_liveview_web/live/thermostat_live.exを作成し、LiveViewを作って行きます。

defmodule HelloLiveviewWeb.ThermostatView do
    use Phoenix.LiveView
    import Calendar.Strftime
  
    def render(assigns) do
      ~L"""
      <div class="thermostat">
  
        <div class="bar <%= @mode %>">
          <a phx-click="toggle-mode"><%= @mode %></a>
          <span><%= strftime!(@time, "%r") %></span>
        </div>
        <div class="controls">
          <span class="reading"><%= @val %></span>
          <button phx-click="dec" class="minus">-</button>
          <button phx-click="inc" class="plus">+</button>
        </div>
      </div>
      """
    end
  
    def mount(_session, socket) do
      if connected?(socket), do: Process.send_after(self(), :tick, 1000)
      {:ok, assign(socket, val: 72, mode: :cooling, time: :calendar.local_time())}
    end
  
    def handle_info(:tick, socket) do
      Process.send_after(self(), :tick, 1000)
      {:noreply, assign(socket, time: :calendar.local_time())}
    end
  
    def handle_event("inc", _, socket) do
      {:noreply, update(socket, :val, &(&1 + 1))}
    end
  
    def handle_event("dec", _, socket) do
      {:noreply, update(socket, :val, &(&1 - 1))}
    end
  
    def handle_event("toggle-mode", _, socket) do
      {:noreply,
       update(socket, :mode, fn
         :cooling -> :heating
         :heating -> :cooling
       end)}
    end  
  end

render/1の所は通常のViewと同様にテンプレートを置いておけば書かなくても良いのかもしれませんが、とりあえずサンプル通りにしておきます。
上記の挙動から鑑みるに、下記のタイミングで再レンダリングがかかっています。

  • メッセージを受けた時
  • update/3を使用した時

厳密にはソケットのステートが変更された際(Phoenix側でsocketに値がアサインされた時)に再レンダリングするようです。

Thermostatの例では、マウント時に1秒後に:tickというメッセージを自分宛に発行しています。これを利用して:tickというメッセージを受けた際にhandle_info/2が呼び出される(このへんはGenServerを参照してください)ので、そのコールバック関数の中でソケットに値をアサインしています。(コレを1秒毎に繰り返す)

クライアントサイドではphx-click="hoge"というディレクティブを要素に追加するとクリック時にイベントが発生し、handle_event/3で受けれるといった仕組みのようです。
それぞれ呼び出されるイベント毎にソケットに値をアサインしてレンダリングを実行しているようです。

所感

実際にやってることとしては他のフレームワークとそう大差ない(通常のフレームワークであればおそらくソケット通信よりも非同期通信で処理するのが一般的だとは思いますが)ですが、裏を返せば他のフレームワークを学習しなくてもデータバインディングコンポーネントの再レンダリングができるので、ラピッドプロトタイピングがかなり捗りそうです。
既存の資産に縁が無い(初心者やバックエンドしかやったことのない人)なら、これはかなり有り難いんじゃ無いでしょうか?僕は有り難いです!

でももうちょっとすんなり入るようになってほしい・・・(´゚'ω゚`)