以前書いた記事 「東京電力電力供給状況監視 - Phoenix Channel」 の LiveView バージョンです。前は Phoenix Channel や Elm を使って実装しました。私の不慣れが大きいのですが、読み難いコードになってしまっています。今回はシンプルで読みやすいものを目指しました。SPA の範囲で言うと、LiveViewはとてもシンプルです。多分コードの生産性やメンテナンス性にとても優れていると感じられます。まあ、業務用アプリなどはまだ無理かもしれませんが。
【関連記事】
Phoenix LiveView と キーボードイベント - Qiita
Phoenix LiveView の JavaScript Hook - Qiita
Phoenix LiveViewの基本設定 - Qiita
Phoenix1.6の基本的な仕組み - Qiita
実行環境
Elixir 1.13.0
Phoenix 1.6.12
1.LiveViewの基本設定
まずプロジェクトを作成します。
mix phx.new liveview_denki --no-ecto --no-mailer --no-dashboard
cd liveview_chart
次に慣例に従ってliveディレクトリを作成します
mkdir lib/liveview_denki_web/live
LiveView controller を denki_live.ex という名前で作成します。
defmodule LiveviewDenkiWeb.DenkiLive do
  use LiveviewDenkiWeb, :live_view
  def mount(_params, _session, socket) do
    {:ok, socket}
  end
end
ここではrender の明示的な設定は行わず denki_live.html.heex という名前を合わせたファイルを作ることで暗示的に行います。
<h1>LiveView Denki Page</h1>
root.html.heex の body を以下のもので置き換えます。
  <body>
    <header>
      <section class="container">
        <h1>LiveView 東京電力電力供給状況監視</h1>
      </section>
    </header>
    <%= @inner_content %>
  </body>
router.ex パスを修正して、"/"パスを LiveView controller を指すようにします。
  scope "/", LiveviewChartWeb do
    pipe_through :browser
    live "/", DenkiLive
  end
基本設定が終わったのでサーバを起動します。
mix phx.server
http://localhost:4000/ にアクセスし確認します。
2.JavaScript Hook
これからは実際にchartjsを実装し、サーバから送られるイベントに従ってチャートをリアルタイムに更新していくことを考えていきます。
chart.jsをインストールします。
npm install --save --prefix assets chart.js
<style>
  #chart-wrapper {
    display: inline-block;
    position: relative;
    width: 100%;
  }
</style>
<div id="chart-wrapper">
  <canvas id="myChart" phx-hook="chart"></canvas>
</div>
import Chart from 'chart.js/auto';
const labels = [ '-45', '-40', '-35', '-30', '-25', '-20', '-15', '-10', '-5', 'now'];
const data = {
  labels: labels,
  datasets: [{
    label: '東京電力電力供給状況監視',
    backgroundColor: 'rgb(255, 99, 132)',
    borderColor: 'rgb(255, 99, 132)',
    data: [3000, 3000, 3000, 3000, 3000, 3000, 3000, 3000, 3000, 3000],
  }]
};
const config = {
  type: 'line',
  data: data,
  options: {}
};
let hooks = {}
hooks.chart = {
    mounted() {
        var ctx = this.el
        var chart = new Chart(ctx, config)
        this.handleEvent("points", ({points}) => {
            chart.data.datasets[0].data = points
            chart.update()
        })
    }
}
export default hooks
app.jsでは上で定義したhookを読み込んで、LiveSocketに設定します。
---
import chart_hooks from './chart_hook';  // 追加
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, 
    {params: {_csrf_token: csrfToken}, hooks: chart_hooks})  // 修正
---
ここで必要なライブラリをインストールします、
  defp deps do
    [
       ---
      {:httpoison, "~> 1.8"}     # 追加
    ]
  end
以下のコマンドを発行します。
mix deps.get
LiveView controller のchart_live.exを以下のように変更します。
mountは2度呼ばれますが、2度目にsocketがつながった状態で呼ばれます。この時、5分おきに自分にupdate_chartイベントを送信するタイマーを設定します。電力情報が5分おきに更新されるので。
defmodule LiveviewDenkiWeb.DenkiLive do
  use LiveviewDenkiWeb, :live_view
  @denki_url "http://tepco-usage-api.appspot.com/quick.txt"
  def mount(_params, _session, socket) do
    if connected?(socket) do
      :timer.send_interval(300000, self(), :update_chart)
    end
    init_points = dummy_points()
    socket = assign(socket, points: init_points)
    {:ok, socket}
  end
  def handle_info(:update_chart, %{assigns: assigns}=socket) do
    %{points: [_|points]} = assigns
    denki = get_denki()
    [_, d, _] = String.split(denki, ",")
    new_points = points ++ [d]
    socket = assign(socket, points: new_points)
    socket = push_event(socket, "points", %{points: new_points})
    {:noreply, socket}
  end
  defp get_denki() do
    case HTTPoison.get(@denki_url) do
      {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> body
      {:ok, %HTTPoison.Response{status_code: 404}} -> "0,0,0"
      {:error, %HTTPoison.Error{reason: reason}} -> "0,0,0"
    end
  end
  defp dummy_points, do: 1..10 |> Enum.map(fn _ -> 3000 end)
end
get_denki()の戻り値は "19:55,3116,4082" のような形で2番目の値が電力の値となっています。ここではコードをできるだけシンプルにするため、電力の値だけを使い、時刻情報などは使いません。
設定が終わったのでサーバを起動します。
mix phx.server
http://localhost:4000/ にアクセスし確認します。
3.GenServer
LiveView controller の chart_live.ex から状態管理とロジックの部分を、別プロセスの GenServer に切り出すことを考えます。両者の通信は PubSub を使います。以下のようなメリットが考えられます。
- (1) LiveView controller と GenServerの役割が分けるこどで、コードがシンプルに理解しやすいものになる。
- (2) GenServer が常に直近の45分の値を保持しているので、ブラウザ立ち上げ時にすぐにそれらの値を利用することができる。
- (3) 2つ以上のブラウザを立ち上げても、グラフの遷移を同期させることができる。
個人的には私は(1)を最重要視しています。十分シンプルじゃないとコードが正しいか理解できないからです。「コードが正しい」と思えることが全てに優先されます。私は正しいプログラマじゃないですね。
先ずは GenServer です。状態管理とロジックの仕事を担います。LiveViewとは関係のないコードになっていますので、理解しやすいです。
【GenServerの過去記事】
Elixir Concurrent Programming(2) - GenServer
defmodule LiveviewDenki.DenkiListener do
  use GenServer
  alias Phoenix.PubSub
  @denki_url "http://tepco-usage-api.appspot.com/quick.txt"
  def start_link(_) do
    GenServer.start_link(__MODULE__, [], name: __MODULE__)
  end
  @impl true
  def init(_) do
    PubSub.subscribe(LiveviewDenki.PubSub, "liveview_denki")
    :timer.send_interval(300000, self(), :update_state)
    {:ok, dummy_points}
  end
  @impl true
  def handle_info(:update_state, [_|state]) do
    denki = get_denki()
    [_, d, _] = String.split(denki, ",")
    new_state = state ++ [d]
    PubSub.broadcast(LiveviewDenki.PubSub, "liveview_denki", %{points: new_state})
    {:noreply, new_state}
  end
  @impl true
  def handle_info(%{points: points}, socket) do
    # do nothing
    {:noreply, socket}
  end
  defp dummy_points, do: 1..10 |> Enum.map(fn _ -> 3000 end)
  defp get_denki() do
    case HTTPoison.get(@denki_url) do
      {:ok, %HTTPoison.Response{status_code: 200, body: body}} -> body
      {:ok, %HTTPoison.Response{status_code: 404}} -> "0,0,0"
      {:error, %HTTPoison.Error{reason: reason}} -> "0,0,0"
    end
  end
end
次に LiveView controller からロジックや状態管理部分を追い出します。だいぶシンプルになりました。これが LiveView controller の仕事の本質と考えられます。
defmodule LiveviewDenkiWeb.DenkiLive do
  use LiveviewDenkiWeb, :live_view
  alias Phoenix.PubSub
  def mount(_params, _session, socket) do
    if connected?(socket) do
      PubSub.subscribe(LiveviewDenki.PubSub, "liveview_denki")
    end
    {:ok, socket}
  end
  def handle_info(%{points: points}, socket) do
    socket = push_event(socket, "points", %{points: points})
    {:noreply, socket}
  end
end
最後に application.ex の children に GenServer を追加して、サーバ起動時に自動的に起動されるようにします。
  @impl true
  def start(_type, _args) do
    children = [
      ---
      {LiveviewDenki.DenkiListener, nil}   # 追加
    ]
設定が終わったのでサーバを起動します。
mix phx.server
http://localhost:4000/ にアクセスし確認します。
2個のブラウザが同期してグラフを遷移させていくのが確認できます。
今回は以上です。

