Phoenix LiveViewで構築するリアルタイムアプリ:JavaScriptを使わないサーバーサイド状態管理

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

現実世界で起きた問題:午前2時14分のクライアント状態不整合

先月のある日の午前2時14分、PagerDutyの緊急アラートで叩き起こされました。倉庫の在庫管理ボードに「幻の引当」が表示されていたのです。2人のフォークリフト作業員が、まったく同じ瞬間にパレット #PL-8492 の確保を行っていました。この不具合により2重の積み込み指示書が発行され、第4荷受口が45分間にわたって完全に塞がれる事態となりました。

バックエンドのログを確認すると、意外な事実が判明しました。PostgreSQLの行レベルロックは正常に機能しており、RESTエンドポイントもすべて200 OKを返していたのです。

原因は、不安定な2.4GHzの倉庫Wi-Fi下でZebra社製ハンディスキャナー上で動作していた1.8MBのReactバンドルにありました。Reduxストア、独自のWebSocket再接続フック、そして楽観的UI更新(Optimistic UI)という3つのレイヤーがタブ間で衝突し合っていたのです。パケットロスが発生するにつれて、ブラウザのローカルメモリとデータベースの状態にズレが生じました。ブラウザのV8エンジン内部だけにしか存在しない「幻のレースコンディション」の解明に、私たちは3時間も費やすことになったのです。

なぜクライアントサイドの状態管理は高負荷時に破綻するのか

クライアントサイドのSPAフレームワーク(React、Vue、Svelteなど)とRESTまたはWebSocket APIを組み合わせる構成は、「エンドユーザーのブラウザ」という信頼性の低いノード上で分散合意問題を解くことを強いることになります。

1. 重複するステートエンジン

バックエンドのビジネスロジックでバリデーションルールや状態遷移を実装した上で、ボタンが遅延なく反応するようにフロントエンドのTypeScriptでもまったく同じモデルを再実装することになります。そしてネットワークの揺らぎ(ジッター)によって、これら2つの実装の間でいずれ必ず不整合が発生します。

2. 9段階におよぶ過密なイベントパイプライン

共有されるインタラクションのたびに、気の遠くなるような処理チェーンが走ります:DOMリスナー → クライアントのディスパッチ → WebSocket通信 → バックエンドコントローラー → データベースロック → PubSubブロードキャスト → リモートクライアントのリスナー → クライアントストアのレデューサー → 仮想DOM差分計算。パケットが1つでも欠落したり、楽観的UI更新のロールバック処理を誤ったりすれば、UIは即座に不正な状態へと陥ります。

3. メモリ消費とバンドルサイズの負担

現代のSPAは、UIの差分を整合させるためだけに数百キロバイトもの状態同期ライブラリを読み込みます。低スペックな業務用タフネスタブレットやモバイルデバイスでは、このオーバーヘッドがバッテリーを激しく消費させ、重要な現場作業のシフト中にフレーム落ちを引き起こします。

アーキテクチャの比較:SPA vs SSE/HTMX vs Phoenix LiveView

インシデントの振り返り(ポストモーテム)において、状態の不整合を恒久的に防止するために3つのアーキテクチャを比較検討しました:

  • アプローチA:堅牢化したReact + Socket.io
    すべてのJSONフレームに単調増加のシーケンス番号を付与し、明示的な再接続バッファと複雑な楽観的ロールバックロジックを実装する必要があります。機能はしますが、画面ごとに約400行もの壊れやすいTypeScriptコードが増加します。
  • アプローチB:HTMX + Server-Sent Events(SSE)
    読み取り主体のダッシュボードには適しています。しかし、複数作業員の即時排他ロックのような双方向のリアルタイムワークフローでは、アクションごとに個別のAJAX POSTリクエストが必要となり、体感できるレベルの往復レイテンシ(RTT)が発生します。
  • アプローチC:BEAM上のPhoenix LiveView
    接続されたクライアントごとに、サーバー上で分離された軽量プロセス(メモリ消費量40KB未満のErlang/Elixir GenServer)を実行します。ブラウザ側で動くのはごく小さなランタイム(gzip圧縮時で約35KB)のみです。ユーザーの入力は常時接続のWebSocketを介してコンパクトなバイナリメッセージとして送信され、サーバーはバイトレベルの極小なHTML差分のみを返送します。

本番環境での解決策:LiveViewによるサーバー主導の状態管理

私たちはパレット出庫管理ボードをPhoenix LiveViewへ移行しました。アセットの合計サイズは1.8MBのJavaScriptから45KB未満へと劇的に削減されました。ブラウザ側には状態(State)が一切存在しないため、状態の不整合は構造上起こり得ません。

ステップ1:最小構成のPhoenixプロジェクトを作成

LiveViewが有効化されたクリーンなPhoenixアプリケーションを生成します:

# Phoenixアーカイブジェネレーターをインストール
mix archive.install hex phx_new 1.7.14

# メーラーやデフォルトのダッシュボードを除外してプロジェクトを生成
mix phx.new warehouse_ops --no-mailer --no-dashboard
cd warehouse_ops
mix ecto.setup

ステップ2:ステートフルなLiveViewモジュールを構築

このモジュールを lib/warehouse_ops_web/live/pallet_live.ex に保存します。状態管理、並行性制御のロック、そしてマークアップが1か所に集約されます:

defmodule WarehouseOpsWeb.PalletLive do
  use WarehouseOpsWeb, :live_view

  @topic "warehouse_pallets"

  @impl true
  def mount(_params, _session, socket) do
    if connected?(socket) do
      Phoenix.PubSub.subscribe(WarehouseOps.PubSub, @topic)
    end

    initial_state = %{
      pallet_id: "PL-8492",
      assigned_to: nil,
      status: :available,
      units: 140
    }

    {:ok, assign(socket, pallet: initial_state)}
  end

  @impl true
  def handle_event("claim_pallet", %{"user" => user_name}, socket) do
    pallet = socket.assigns.pallet

    case pallet.status do
      :available ->
        updated_pallet = %{pallet | status: :claimed, assigned_to: user_name}
        Phoenix.PubSub.broadcast(WarehouseOps.PubSub, @topic, {:pallet_updated, updated_pallet})
        {:noreply, assign(socket, pallet: updated_pallet)}

      :claimed ->
        {:noreply, put_flash(socket, :error, "このパレットは既に #{pallet.assigned_to} によって確保されています")}
    end
  end

  @impl true
  def handle_info({:pallet_updated, updated_pallet}, socket) do
    {:noreply, assign(socket, pallet: updated_pallet)}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <div class="p-6 max-w-lg mx-auto bg-slate-900 text-white rounded-xl shadow-md">
      <h1 class="text-2xl font-bold mb-4">パレット出庫トラッカー</h1>
      <div class="border border-slate-700 p-4 rounded-lg mb-4">
        <p><strong>ID:</strong> <%= @pallet.pallet_id %></p>
        <p><strong>数量:</strong> <%= @pallet.units %></p>
        <p><strong>ステータス:</strong>
          <span class={if @pallet.status == :available, do: "text-green-400", else: "text-amber-400"}>
            <%= String.upcase(to_string(@pallet.status)) %>
          </span>
        </p>
        <%= if @pallet.assigned_to do %>
          <p><strong>確保者:</strong> <%= @pallet.assigned_to %></p>
        <% end %>
      </div>

      <div class="flex gap-2">
        <button
          phx-click="claim_pallet"
          phx-value-user="Operator_A"
          disabled={@pallet.status != :available}
          class="bg-blue-600 hover:bg-blue-700 disabled:opacity-40 px-4 py-2 rounded font-semibold">
          オペレーターAとして確保
        </button>
        <button
          phx-click="claim_pallet"
          phx-value-user="Operator_B"
          disabled={@pallet.status != :available}
          class="bg-emerald-600 hover:bg-emerald-700 disabled:opacity-40 px-4 py-2 rounded font-semibold">
          オペレーターBとして確保
        </button>
      </div>
    </div>
    """
  end
end

ステップ3:エンドポイントを接続

ライブコンポーネントを lib/warehouse_ops_web/router.ex にマウントします:

scope "/", WarehouseOpsWeb do
  pipe_through :browser

  live "/pallets", PalletLive
end

クリック時に内部で起きていること

  1. マイクロペイロード:オペレーターが「確保(Claim)」をクリックすると、クライアントからサーバー側のGenServerへ、90バイト未満の極小バイナリWebSocketパケットが送信されます。
  2. サーバー側の絶対的権威:handle_event/3 がメモリ内の現在の状態をチェックします。オペレーターAが4ミリ秒早くクリックしていた場合、オペレーターBのクリックは :claimed の分岐に入り、レコードを変更することなくフラッシュ警告メッセージを受け取ります。
  3. 差分のみのネットワーク転送:LiveViewは前回のレンダリングツリーと比較してテンプレートの動的部分を解析します。静的なHTMLは完全にスキップし、変更のあったキーのみを含むJSON差分(約38バイト)だけを返送します。
  4. 自動ブロードキャスト:Phoenix.PubSub を通じて、サーバーはローカルネットワーク上のすべての接続デバイスへ2ミリ秒未満で更新をプッシュ配信します。

平穏な夜を取り戻す

壊れやすいブラウザキャッシュからElixirのGenServerへと状態管理を戻すことで、クライアントサイド特有のあらゆるレースコンディションを根絶できます。シンプルなサーバーサイドコードを書くだけで、JavaScriptのペイロードを最大95%削減でき、深夜2時の緊急オンコール呼び出しに怯える日々から解放されるのです。

Share: