Action CableとTurbo Streamで簡易的なリアルタイムチャットを作ってみる
現在インターン先でRailsを使ったWebアプリ開発の課題に取り組んでおり、その中でユーザー同士がやり取りできるチャット機能を実装する必要が出てきました。
Railsでどのようにしてチャット機能を実装できるのか調べてみた所、標準でWebSocketによるリアルタイム通信を可能にするAction Cableという機能が用意されていることが分かりました。
これを使うことでクライアントとサーバー間で双方向にデータをやり取りでき、リアルタイムで更新することが出来るらしいです。
またリアルタイムで画面の内容を反映させる場合、通常はJavaScript等を使って非同期処理を実装する必要がありますが、RailsにはTurbo Streamsという仕組みも用意されており、これを使うことでJavaScriptを記述することなく非同期処理が出来るらしいです。
今回はTurbo StreamsとAction Cableを使って簡易的なリアルタイムチャットを実装しながらそれぞれの基本的な使い方や挙動を確認してみようと思います。
Turbo StreamsとAction Cableを使ったリアルタイム処理の仕組み
Action Cableとは
Action CableはRailsに標準で搭載されているリアルタイム通信を扱うための機能です。
Action CableではWebSocketというクライアントとサーバーで双方向の通信が可能となる通信プロトコルを使用しています。
通常のHTTP通信はサーバーはクライアントからのリクエストに対してレスポンスを返す一方向のやり取りしかできませんが、WebSocketはサーバーとクライアントが常に接続された状態を保つことで、サーバーからクライアントへのリクエストを送ることができるようになります。
これにより投稿したデータを即座に相手側に配信することができます。
Action Cableの用語
コネクション
WebSocketでクライアントがサーバーと接続した状態
ブラウザのタブごとにコネクションは作られるので、同じユーザーが複数タブを開けば、それぞれ別の接続が確立される
チャンネル
機能ごとに通信を分ける単位
チャット機能ならメッセージ用のチャンネルに購読することで、そのメッセージのやり取りができるようになる
ストリーム
チャンネルの中でさらに絞り込んだ特定のデータの流れを表す
たとえば同じメッセージチャンネルの中でもルーム1みたいな特定のルームのみを購読することが出来る
サブスクライバ
特定のチャンネルを購読しているクライアントを指す
ブロードキャスト
サーバーから購読中のサブスクライバに向けてデータを送信すること
Turbo Streamとは
Turbo StreamsはHotwireという、JavaScriptをあまり使わずにモダンなwebアプリケーションを構築することができるフレームワークの機能の一つです。
Turbo Streamsを使うことで通常はAction Cableを用いたリアルタイム機能の実装に必要となるチャンネルの定義やJavaScriptの記述を行わずにリアルタイムで画面の切り替えができるアプリを実装することができます。
処理の流れ
購読の開始
クライアントがページを読み込むとTurbo::StreamsChannel(Turboが用意するAction Cableの既定チャンネル)を通じてそのチャンネルを購読する(WebSocketの接続が開始される)
↓
投稿を保存する
ユーザーがメッセージを送信すると、通常のHTTPリクエストとしてサーバーに送られ、メッセージが保存される
↓
ブロードキャストの実行
モデルにbroadcasts_toなどを設定しておくと、対象のストリームに対してTurbo Streams形式で自動的にブロードキャストされる。(実際のコード例は後ほど説明します)
↓
クライアントが受信して画面を更新
購読しているクライアントがTurbo Streamsをリアルタイムで受信し、該当するHTML要素が即時に更新される。
実際に作ってみる
では実際にチャットアプリを作ってみようと思います。
今回はシンプルに1対1のメッセージにします
モデルの作成
Userモデル
ユーザーの認証にはDeviseを使用しました
rails g devise User
メッセージで名前も乗せたいのでusernameを追加します
class DeviseCreateUsers < ActiveRecord::Migration[7.2]
def change
create_table :users do |t|
t.string :username, null: false
## Database authenticatable
t.string :email, null: false, default: ""
t.string :encrypted_password, null: false, default: ""
# ~省略~
end
add_index :users, :username, unique: true
add_index :users, :email, unique: true
add_index :users, :reset_password_token, unique: true
end
end
ユーザーが削除されたらルームも削除されるようにdependent: :destroyを付けました
メッセージはルームが削除された時に消えるようにするので二重で削除処理がされないようにここでは付けないでおきます。
has_many :rooms, foreign_key: :user_id, dependent: :destroy
has_many :messages
Roomモデル
1対1のチャットルームを表すモデルです。user(作成者)とrecipient(相手ユーザー)の2人で構成され、同じユーザーペアで複数のルームが作られないようにしています。
マイグレーション
class CreateRooms < ActiveRecord::Migration[7.2]
def change
create_table :rooms do |t|
t.references :user, null: false, foreign_key: true
t.bigint :recipient_id, null: false
t.timestamps
end
add_index :rooms, [:user_id, :recipient_id], unique: true
add_foreign_key :rooms, :users, column: :recipient_id
end
end
モデル
normalize_pairでuser_id と recipient_id を常に昇順に並べ替え、同じ二人の組み合わせでルームが重複しないようにしました
メッセージはルームが削除されたら消すようにしています
class Room < ApplicationRecord
belongs_to :user
belongs_to :recipient, class_name: "User"
has_many :messages, dependent: :destroy
before_validation :normalize_pair
validate :users_must_be_different
def self.find_or_create_by_users(a, b)
u, v = [a.id, b.id].minmax
find_or_create_by!(user_id: u, recipient_id: v)
end
def counterpart_for(user)
user.id == user_id ? recipient : self.user
end
private
def normalize_pair
return if user_id.blank? || recipient_id.blank?
self.user_id, self.recipient_id = [user_id, recipient_id].minmax
end
def users_must_be_different
if user_id == recipient_id
errors.add(:recipient_id, "は自分以外を指定してください")
end
end
end
Messageモデル
どのユーザーがどのルームに対して何を投稿したかを記録します。
class CreateMessages < ActiveRecord::Migration[7.2]
def change
create_table :messages do |t|
t.text :content, null: false
t.references :user, null: false, foreign_key: true
t.references :room, null: false, foreign_key: true
t.timestamps
end
end
end
モデル
broadcasts_to :room, inserts_by: :append, target: "messages"
先程も説明したとおりbroadcasts_toを設定することでメッセージが保存された直後にrommチャンネルのmessagesというストリームに対してブロードキャストされます。
これにより、Turbo Streamsなどを通じてリアルタイムで画面に反映することが出来ます。
class Message < ApplicationRecord
belongs_to :room
belongs_to :user
validates :content, presence: true
broadcasts_to :room, inserts_by: :append, target: "messages"
end
コントローラーの作成
RoomsController
index: 自分以外のユーザーを一覧で表示させます
create: Room.find_or_create_by_usersで他のユーザーとの間にすでにルームがあるかチェックし、なければ作成してそのルームにアクセスします。
show: 特定のルームにアクセスし、過去のメッセージの取得と新しいメッセージ投稿用のオブジェクトを用意します。
class RoomsController < ApplicationController
before_action :set_room, only: :show
before_action :ensure_member!, only: :show
def index
@users = User.where.not(id: current_user.id)
end
def create
recipient = User.find(params.require(:recipient_id))
room = Room.find_or_create_by_users(current_user, recipient)
redirect_to room_path(room)
end
def show
@counterpart = @room.counterpart_for(current_user)
@messages = @room.messages.includes(:user).order(:created_at)
@message = @room.messages.build
end
private
def set_room
@room = Room.find(params[:id])
end
def ensure_member!
unless [@room.user_id, @room.recipient_id].include?(current_user.id)
redirect_to root_path, alert: "このチャットルームにアクセスする権限がありません。"
end
end
end
MessagesController
before_actionでまずパラメーターから対象のルームを取得し、現在のユーザーがこのルームの参加者かどうか確認します。
create: 入力されたメッセージを現在のルームに保存し、保存に成功したらTurbo StreamまたはHTMLでレスポンスを返します。失敗した場合は再度ルーム画面を表示して修正できるようにします。
実際にアプリに組み込むときは編集や削除機能も付けたいなと思います。
class MessagesController < ApplicationController
before_action :set_room
before_action :ensure_member!
def create
@message = @room.messages.build(message_params)
@message.user_id = current_user.id
if @message.save
respond_to do |format|
format.turbo_stream
format.html { redirect_to room_path(@room) }
end
else
@counterpart = @room.counterpart_for(current_user)
@messages = @room.messages.includes(:user).order(:created_at)
render "rooms/show", status: :unprocessable_entity
end
end
private
def set_room
@room = Room.find(params[:room_id])
end
def ensure_member!
if ![@room.user_id, @room.recipient_id].include?(current_user.id)
redirect_to root_path, alert: "このチャットルームにアクセスする権限がありません。"
end
end
def message_params
params.require(:message).permit(:content)
end
end
ルーティングの設定
rooms#index: ユーザー一覧を表示するトップページです
rooms#create: 相手ユーザーを指定して新しいルームを作成します(または既存のルームに遷移します)
rooms#show: 特定のルームにアクセスしてメッセージ画面を表示させます
messages#create: ルームに紐づく新しいメッセージを投稿します
# config/routes.rb
Rails.application.routes.draw do
devise_for :users
resources :rooms, only: [:index, :create, :show] do
resources :messages, only: [:create]
end
root "rooms#index"
end
ビューの作成
簡易的なものなので、ユーザーリストとチャット画面のみ実装してます。
スタイルもありません...
rooms/index
ログイン中のユーザーと、自分以外のユーザーの一覧を表示します

<h1>メッセージアプリ</h1>
<p>こんにちは、<%= current_user.username %> さん</p>
<h2>ユーザー一覧</h2>
<% if @users.any? %>
<ul>
<% @users.each do |user| %>
<li>
<%= link_to user.username,
rooms_path(recipient_id: user.id),
data: { turbo_method: :post, turbo_frame: "_top" } %>
</li>
<% end %>
</ul>
<% else %>
<p>他のユーザーはいません</p>
<% end %>
rooms/show
メッセージ画面です。
turbo_stream_from @roomでこのルームのストリームの購読が開始されます
過去のメッセージはmessages/_messageを部分テンプレートとして描画しています。新しいメッセージはTurboStreamsによってここへ自動的に追加されます。
メッセージ入力フォームも部分テンプレートとして描画します。
<h2><%= @counterpart.username %> さんとのメッセージ</h2>
<%= turbo_stream_from @room %>
<div id="messages">
<%= render partial: "messages/message", collection: @messages %>
</div>
<%= render "messages/form", room: @room, message: @message %>
messages/_message、_form
<div class="message">
<div class="meta">
<%= message.user.username %>
<%= message.created_at.strftime("%Y/%m/%d %H:%M:%S") %>
</div>
<div class="body"><%= simple_format(h(message.content)) %></div>
</div>
<div id="message_form">
<%= form_with model: [room, message] do |f| %>
<% if message.errors.any? %>
<% message.errors.full_messages.each do |m| %><p>error: <%= m %></p><% end %>
<% end %>
<%= f.text_area :content, rows: 2, placeholder: "メッセージを入力…" %>
<%= f.submit "送信" %>
<% end %>
</div>
messages/create.turbo_stream
送信成功後、turbo_stream.replace "message_form"でフォームを空の新規フォームに置き換え、入力欄をクリアさせます。
<%= turbo_stream.replace "message_form",
partial: "messages/form",
locals: { room: @room, message: Message.new } %>
動作確認
以上で実装は完成です。
実際にpcとスマホでブラウザを開いてチャットを送ってみた所無事にリアルタイムで更新されていることがわかりました

DevToolでWebSocketの通信の流れを確認する
最後にブラウザのデベロッパーツールを使ってメッセージを送った時にWebSocketがどのような通信をしているか確認していきます。
チャットのルームを開くと以下のようなcableという項目がネットワークタブに流れてきます。
これがWebSocket通信です。

この項目のMessagesタブを見ると、どんな通信をしているかがわかります。

-
welcome
サーバーが接続を受け入れたことを示します。WebSocketが正常に確立された合図です。
{"type":"welcome"}
-
subscribe
クライアント(ブラウザ)が Turbo::StreamsChannel を購読するようにサーバーに要求しています。signed_stream_name は購読対象(例:特定のルームやモデル)を識別するための署名付き文字列です。
{
"command": "subscribe",
"identifier": "{\"channel\":\"Turbo::StreamsChannel\",\"signed_stream_name\":\"...\"}"
}
-
confirm_subscription
購読が承認されたことをクライアントに通知しています。これでストリームの受信準備が整います。
{
"identifier": "{\"channel\":\"Turbo::StreamsChannel\",\"signed_stream_name\":\"...\"}",
"type": "confirm_subscription"
}
-
ping
接続が切れていないか確認する役割があります。
{
"type": "ping",
"message": 1759122123
}
-
Turbo Streamメッセージ
メッセージを送信すると、turbo-stream タグ付きのHTMLが送信され、DOMが更新されます。投稿したメッセージがmessages要素に追加されます。
{
"identifier": "...",
"message": "<turbo-stream action=\"append\" target=\"messages\">..."
}
まとめ
今回はRails標準のAction CableとTurbo Streamsを組み合わせて1対1のリアルタイムチャット機能を実装してみました。
実際に実装してみて感じたメリットはやっぱり必要なコードの少なさだと思います。今回のアプリもリアルタイム通信に必要なコードはほんの数行でした。これがRailsの標準機能だけで作れるのはほんとすごいと思います。
一方で、複雑な要件には限界があると感じました。より本格的な機能を付けていくとAction Cableのチャンネル定義が必要になりますし、WebSocketの通信なので端末がスリープモードになると接続が遮断されてそのまま更新がされない可能性もあります。
より大規模なアプリに組み込むとなると、パフォーマンスや接続の安定性を含めてもう少し設計を考える必要があるのかなと思いました。
Discussion