Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: データベース変更をリアルタイム&低予算でユーザーにブロードキャストする(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

Rails: データベース変更をリアルタイム&低予算でユーザーにブロードキャストする(翻訳)

私がBeckyの筋トレビジネス用のアプリ(まだリリースしていません)を構築しているときには、アプリをサーバーサイドとクライアントサイドに分けずに動的なフロントエンドらしい振る舞いを得るためにHotwireTurboStimulusの組み合せを大いに活用しました(なお、これらはRailsがなくても使えるのですが、率直に言うとそうしている人はほとんど見かけません)。

Hotwireというライブラリスイートが提供する幅広い機能についてご存じない方のために、ざっと紹介しておきます。

Rails Request.js
伝統的なHTTPリクエストをJavaScriptから送信し、Railsで期待されるX-CSRF-Tokenヘッダーなどを自動処理します。
Turbo Streams
HTMLのスニペットをネットワーク(フェッチ/XHRまたはAction Cableソケット)経由で送信する形でページを部分的に再レンダリングできます。さらに最近カスタムアクションもサポートされ、ネットワーク経由(over-the-wire)であらゆるものを送信できるようになりました1
turbo-rails gem
データベース内のモデル更新をブロードキャストしてAction Cableソケット接続経由でサブスクライバにTurbo Streamsをレンダリングする、実に便利なグルーコードをいくつか追加します。
Stimulusのvalues
Stiumulusの値は、Stimulusコントローラを記述しているdata-*属性としてDOMと同期します。オブジェクトの同期は(おそらくご想像のとおり)JSONシリアライズ経由でサポートされています。Stimulusコントローラは仕様上「あまり出しゃばらない」ようになっていますが、DOMを監視することで、コントローラの値に対応するdata-*属性が変更されているかどうかをチェックします。

早くも頭がぐるぐるしてきましたか?ですよね。

先週私が直面した設計上の制限は、以下のようなものでした。

  1. フルスクリーンかつ高度なインタラクティブ操作を可能にするページを表示すること。
    これはバックグラウンドでビルドするには比較的コストがかさみます(例: 筋トレプログラムをユーザー向けに一連のブロックに変換する、サポートしている筋トレ動画アセットなどを組み立てる)。

  2. ワークアウトはユーザーによって「完了」「スキップ」のどちらかにマーキングされるので、それをどこかに保存してページリフレッシュ時にも維持する必要があります。
    (「筋トレプログラムのどの運動を置き換えたか」「セットごとのユーザーメモ」などについても細かな状態を維持する必要が生じたりしました)

  3. ユーザーが別のデバイスからワークアウトに参加したときも、同じワークアウトが同じ進捗で表示される必要があります。
    (ユーザーは1度に1つのワークアウトにしか参加できない仕様のため)

  4. ユーザーがあるデバイスでワークアウトを完了したら、ユーザーがログインしている他のすべてのデバイスにも、ページを更新せずに完了を反映する必要がああります。
    (スマホで完了済みのセットをiPadで再び実行するよう指示されてユーザーが混乱するのを防ぐため)

  5. 私は本質主義者です(要するにケチ)。
    ユーザーがワークアウトを行うたびにデータベースに数百行を生成するようなアプリにはしたくありません。ユーザーが何かをクリックするたびにクエリを数十件も投げたり、不必要に多いHTMLをネット越しに投げつけたり、そうした無駄をキャッシュ層で軽減しようと試みたりすることもしたくありません。

これが、アプリのような外観を持つWebサイトというものです!ユーザーはWebサイトにもネイティブアプリのような操作性を期待するようになってきました。そして実際のネイティブアプリがやっているのは、バックグラウンドでデバイス間をこっそり同期することです。


以上を実現するために私が行ったのは以下のとおりです。

  1. 「ユーザーの進捗」「運動の差し替え」「ユーザーのメモ」はWorkoutLogの単一レコードにJSONBカラムとして保存することにし、各ユーザーが行う1つのワークアウトで1行だけを作成すれば済むようにした(ステップ1)。

  2. 最初のレンダリングでは、これらのJSONBカラムをdata-*属性経由でそのページのプライマリStimulusコントローラに設定する。
    ユーザーがボタン(セットの「完了」など)をクリックすると、Stimulusのprogress値が更新されます。値が変更されるたびに、HTTPのPATCHリクエストで値が送信されます。送信先の使い捨てコントローラアクションは、そのレコードのそのカラムだけを更新します(ステップ2)。

  3. WorkoutLogモデルのafter_update_commitフックを活用して、saved_changesハッシュをチェックする形でJSONBカラムが変更されたかどうかを調べる。
    変更ありの場合は、broadcast_render_toまたはbroadcast_render_later_toを呼び出して、Turbo Streamの使い捨てパーシャルテンプレートをレンダリングします(ステップ3)。

  4. そのテンプレートで、私が書いたTurbo Streamカスタムアクションを呼び出す。
    このカスタムアクションはHTMLテンプレートをまったくレンダリングせずに、プライマリStimulusコントローラをバインドした要素のdata-*属性に近いJSONBカラムを更新します(ステップ4)。

  5. ワークアウトのページからビューテンプレート経由で同じAction Cableサブスクリプションに接続する形で全体を組み立てる(ステップ5)。

何やら盛りだくさんですね。可動部分がたくさんありますし、セットアップのためにさまざまなコンテキストを切り替える必要もあります。これらの機能はまだ新しく、ドキュメントも充実しているとは限りません。

しかし私は、この作業を一通り終えてみて、これは「マジですごすぎる」と思わずにはいられませんでした。さらに、この素晴らしい機能を達成するための差分が実に少ないことに気づけば、皆さんも「これは見事だ」という気持ちになるでしょう。同じブラウザウィンドウを2つ開いて横に並べてから一方を変更すると他方も瞬時に更新されるのを見れば、まるで魔法のように感じるでしょう。

同じような機能を実現するのに役立ついくつかのスニペットを、上の5つのステップごとに紹介します。

🔗 1: モデルをセットアップする

特に変わったことはしていません。マイグレーションでJSONBカラムを1つ、JSONB[]カラムを2つ指定しているだけです。

class AddStateToWorkoutLogs < ActiveRecord::Migration[7.2]
  def change
    change_table :workout_logs do |t|
      t.jsonb :progress, default: {}, null: false
      t.jsonb :movement_substitutions, array: true, default: [], null: false
      t.jsonb :experiences, array: true, default: [], null: false,
    end
  end
end
=

対応するモデルは以下です。

class WorkoutLog < ApplicationRecord
  belongs_to :user

  # 属性の設定やバリデーションなどは略
end

なお、このモデル自身については、Railsのカスタム属性を活用して、シンボルキーを持つ小さなJSONオブジェクトをPostgreSQLのarrayカラムでうまく扱えるようにしていますが、これについては別の機会にします。

ここで説明するリアルタイムブロードキャスト機能に必要な材料は、上記に示したものですべて事足ります。

🔗 2: StimulusのJSONオブジェクトの変更を処理する

以下は、ページのプライマリStimulusコントローラーをバインドしている要素の関連部分です。ここではJSONBのprogressカラムにのみ注目します。

<%= content_tag :div,
  id: "workout_player",
  data: {
    controller: "workout-player",
    workout_player_progress_value: @workout_log.progress.to_json,
    workout_player_update_progress_url_value: update_workout_log_progress_path(@workout_log),
    action: "turbo:after-update-dataset->workout-player#streamUpdatedPlayerState",
  } do %>
  <!-- HTML -->
<% end %>

ここには以下が含まれています。

  • DOM ID (Turbo Streamでターゲットとして指定しやすくするため)
  • 現在のprogress値に対応するdata-*属性(JSONに変換)
  • URLパス(RailsのURLヘルパーで生成)
  • 私が作ったカスタムturbo:after-update-datasetイベントがコントローラアクションにバインドされている(後で起動されます)

Stimulusコントローラー自体の関連部分は次のとおりです。

// app/javascript/controllers/workout_player_controller.js
import { Controller } from '@hotwired/stimulus'
import { patch } from '@rails/request.js'

export default class extends Controller {
  static values = {
    progress: {
      type: Object,
      default: {}
    },
    updateProgressUrl: String,
  }

  // ユーザーがprogressオブジェクトを変更する操作を行うたびに呼び出される
  uploadProgress () {
    patch(this.updateProgressUrlValue, {
      body: { progress: this.progressValue }
    })
  }

  progressValueChanged () {
    // data-*属性がストリームによって改変されると発火する
  }

  streamUpdatedPlayerState () {
    // ストリーム更新に関することをここで行う
    // (turbo:after-update-datasetにバインドされる)
  }
}

各メソッドの機能については上記のコメントを参照してください。
ただし現時点では、「完了」ボタンをクリックするとアクションがトリガーされ、そのアクションによって uploadProgressが呼び出されて、HTTP PATCHリクエストがサーバーに送信されるとさしあたって考えてください。

🔗 3: 変更をサブスクライブ済みクライアントにブロードキャストする

ここでは、PATCHリクエストを処理するためのシンプルな使い捨てアクションを書いて、変更を永続化しています。

class WorkoutLogsController < ApplicationController
  def update_progress
    workout_log = current_user.workout_logs.find(params[:id])
    if workout_log.update(progress: params[:progress])
      head :no_content
    else
      head :unprocessable_entity
    end
  end
end

ここでマジックが発動します!
以下は、WorkoutLogモデルで3つのJSONBカラムを個別に更新可能にするafter_update_commitフックの完全なソースです。

class WorkoutLog < ApplicationRecord
  after_update_commit -> {
    if [:progress, :finished_at].any? { |attr| saved_changes.key?(attr) }
      broadcast_render_to(user_id, id, :workout_player,
        partial: "workout_logs/progress_update")
    end
    if saved_changes.key?(:movement_substitutions)
      broadcast_render_to(user_id, id, :workout_player,
        partial: "workout_logs/movement_substitutions_update")
    end
    if saved_changes.key?(:experiences)
      broadcast_render_to(user_id, id, :workout_player,
        partial: "workout_logs/experiences_update")
    end
  }
end

ドキュメントによると、ジョブキューへの送信にはlater付きのブロードキャストメソッド(例: broadcast_render_to ではなく broadcast_render_later_to)の利用が推奨されています。そうしないと、送信によってWebワーカーの時間が専有されてしまいます。

ただしこの場合、これらのパーシャルはいずれも単一のHTML要素を送信するだけで、データベースクエリを必要としないため、同じリクエスト/レスポンスのライフサイクルでは、パーシャルを送信する方が全体的に作業が少なくて済む可能性があります。

また、broadcast_render_toの最初のいくつかの引数、つまり「ユーザーのID」「ワークアウトID」「ワークアウトログのID」「:workout_player名」にご注意ください。broadcastメソッドは、Action CableがブロードキャストするサブスクリプションIDを作成するのに使う可変長引数を受け取ります。

そしてここが重要です!仮に、サブスクリプションのIDを以下のように単純な:workout_playerにしたとすると、「あらゆる」ユーザーの「あらゆる」ワークアウトの「あらゆる」更新が、毎回全員にブロードキャストされてしまいます。

broadcast_render_to(:workout_player,
  partial: "workout_logs/movement_substitutions_update")

そこで、代わりにbroadcast_render_to(user, id, :workout_player)を呼び出すことで、指定ユーザーの指定ワークアウトだけを一意に絞り込めるようにします(つまり、このユーザーはどのブラウザでも同じワークアウトログを閲覧するようになります)。

なお、私の場合はactioncable-enhanced-postgresql-adaptergemを導入することで、Action Cable サブスクリプションをPostgreSQLで管理し、production環境にRedis依存関係を持ち込まないようにしています。

reclaim-the-stack/actioncable-enhanced-postgresql-adapter - GitHub

🔗 4: Turbo Streamのカスタムアクションをレンダリングする

上で特定したprogress_updateパーシャルを以下に示します。

<!-- app/views/workout_logs/_progress_update.turbo_stream.erb -->
<%= tag.turbo_stream(action: :update_dataset, target: "workout_player",
  data: {
    workout_player_progress_value: workout_log.progress.to_json,
    workout_player_workout_finished_elsewhere_value: workout_log.finished?,
  }) %>

このパーシャルがレンダリングする<turbo-stream>タグは、まだ定義されていないTurbo Streamアクションをupdate_datasetという名前で指定し、workout_playerというIDのDOMノードをターゲットとします。各サブスクライバのAction Cableコネクションを介して送信される内容は、以下のように意味不明です。

{
  "identifier": "{\"channel\":\"Turbo::StreamsChannel\",\"signed_stream_name\":\"IloyxGtPaTh2WjNKdlp5OVZjMlZ5THpJOjMzMjp3b3Jrb3V0X3BsYXllciI=--d49ded58c4f9fd9d53209443e5bdba92f7ea7d8afc289e313bf098f7a2eed320\"}",
  "message": "<turbo-stream action=\"update_dataset\" target=\"workout_player\" data-workout-player-progress-value=\"{"blocks":[{"name":"Warm-up","sets":[{"status":"complete"}]},{"name":"Squat","sets":[{"status":"complete"},{"status":"skipped"},{"status":"complete"},{"status":"skipped"},{"status":"complete"},{"status":"complete"}]},{"name":"Combo","sets":[{"status":"incomplete"}]},{"name":"Combo","sets":[{"status":"complete"},{"status":"complete"},{"status":"incomplete"},{"status":"incomplete"},{"status":"incomplete"},{"status":"incomplete"},{"status":"incomplete"},{"status":"incomplete"}]},{"name":"Combo","sets":[{"status":"incomplete"},{"status":"incomplete"},{"status":"incomplete"},{"status":"incomplete"},{"status":"incomplete"},{"status":"incomplete"}]},{"name":"Finisher","sets":[{"status":"incomplete"},{"status":"incomplete"}]}]}\" data-workout-player-workout-finished-elsewhere-value=\"false\"></turbo-stream>\n"
}

データの見た目はイマイチですが、それでも2KB未満に収まります(Action Cableにgzip圧縮機能があれば転送サイズは596バイトで済むのですが)。

最後に、HTMLテンプレートのレンダリングではなく、data-*属性の更新用に設計されたTurbo Streamのカスタムアクションを定義する必要があります。
以下は自由にコピペでご利用いただけます。

// app/javascript/ext/turbo_rails_ext.js
import { Turbo } from '@hotwired/turbo-rails'

Turbo.StreamActions.update_dataset = function () {
  const target = document.getElementById(this.getAttribute('target'))
  const targets = target ? [target] : document.querySelectorAll(this.getAttribute('targets'))
  if (targets.length === 0) return

  targets.forEach(target => {
    target.dispatchEvent(new CustomEvent('turbo:before-update-dataset', { detail: { element: target } }))
    for (const [key, val] of Object.entries(Object.assign({}, this.dataset))) {
      target.dataset[key] = val
    }
    target.dispatchEvent(new CustomEvent('turbo:after-update-dataset', { detail: { element: target } }))
  })
}

🔗 5: ビューからAction Cableにサブスクライブする

とどめの一撃を加えましょう!
残る作業は、ブロードキャスト先のサブスクリプションと一致するAction Cableへのサブスクリプションを自動設定することだけです。これは、turbo-rails gemが提供するturbo_stream_fromビューヘルパーでできます。

<%= turbo_stream_from(current_user, @workout_log.id, :workout_player) %>

ここでは、正しいメッセージでサーバーからクライアントに接続するために、broadcast_render_toで渡したのと同じタプル(ユーザーID、ワークアウトログID、:workout_playerシンボル)を渡している点にご注意ください。

🔗 以上でおしまいです!

この機能があまりに素晴らしいので、実際に操作可能なデモアプリと完全なサンプルコードを丸一日かけて本記事に追加するのを踏みとどまるために、かなり自制心を必要としました。

これを実際に見てみたい方は、来月行われるRails World 2024のセッションをぜひご覧ください。皆さんはそれまで、この機能の本当の素晴らしさを指をくわえて想像するしかありません。

訳注

その後動画が公開されました(日本語字幕付き)。

関連記事

Rails: HotwireCombobox gemが素晴らしすぎるという話(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。