✉️

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の記述を行わずにリアルタイムで画面の切り替えができるアプリを実装することができます。
https://zenn.dev/takeyuwebinc/articles/9f63f07fe5f4e0

処理の流れ

購読の開始

クライアントがページを読み込むと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