Rails: データベース変更をリアルタイム&低予算でユーザーにブロードキャストする(翻訳)
私がBeckyの筋トレビジネス用のアプリ(まだリリースしていません)を構築しているときには、アプリをサーバーサイドとクライアントサイドに分けずに動的なフロントエンドらしい振る舞いを得るためにHotwireのTurboとStimulusの組み合せを大いに活用しました(なお、これらは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度に1つのワークアウトにしか参加できない仕様のため) -
ユーザーがあるデバイスでワークアウトを完了したら、ユーザーがログインしている他のすべてのデバイスにも、ページを更新せずに完了を反映する必要がああります。
(スマホで完了済みのセットをiPadで再び実行するよう指示されてユーザーが混乱するのを防ぐため) -
私は本質主義者です(要するにケチ)。
ユーザーがワークアウトを行うたびにデータベースに数百行を生成するようなアプリにはしたくありません。ユーザーが何かをクリックするたびにクエリを数十件も投げたり、不必要に多いHTMLをネット越しに投げつけたり、そうした無駄をキャッシュ層で軽減しようと試みたりすることもしたくありません。
これが、アプリのような外観を持つWebサイトというものです!ユーザーはWebサイトにもネイティブアプリのような操作性を期待するようになってきました。そして実際のネイティブアプリがやっているのは、バックグラウンドでデバイス間をこっそり同期することです。
以上を実現するために私が行ったのは以下のとおりです。
- 「ユーザーの進捗」「運動の差し替え」「ユーザーのメモ」は
WorkoutLog
の単一レコードにJSONBカラムとして保存することにし、各ユーザーが行う1つのワークアウトで1行だけを作成すれば済むようにした(ステップ1)。 -
最初のレンダリングでは、これらのJSONBカラムを
data-*
属性経由でそのページのプライマリStimulusコントローラに設定する。
ユーザーがボタン(セットの「完了」など)をクリックすると、Stimulusのprogress
値が更新されます。値が変更されるたびに、HTTPのPATCH
リクエストで値が送信されます。送信先の使い捨てコントローラアクションは、そのレコードのそのカラムだけを更新します(ステップ2)。 -
WorkoutLog
モデルのafter_update_commit
フックを活用して、saved_changes
ハッシュをチェックする形でJSONBカラムが変更されたかどうかを調べる。
変更ありの場合は、broadcast_render_to
またはbroadcast_render_later_to
を呼び出して、Turbo Streamの使い捨てパーシャルテンプレートをレンダリングします(ステップ3)。 -
そのテンプレートで、私が書いたTurbo Streamカスタムアクションを呼び出す。
このカスタムアクションはHTMLテンプレートをまったくレンダリングせずに、プライマリStimulusコントローラをバインドした要素のdata-*
属性に近いJSONBカラムを更新します(ステップ4)。 -
ワークアウトのページからビューテンプレート経由で同じ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依存関係を持ち込まないようにしています。
🔗 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のセッションをぜひご覧ください。皆さんはそれまで、この機能の本当の素晴らしさを指をくわえて想像するしかありません。
訳注
その後動画が公開されました(日本語字幕付き)。
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。