🔌

【入門】Rails エンジニアが知っておきたい Action Cable で実現するリアルタイム機能

に公開

はじめに

Rails に搭載されている機能の1つに Action Cable があります。

何度か使ってみたことはあるものの、あまり深く理解していなかったので、改めて Action Cable について学んでみました。

できるだけ分かりやすく Action Cable についてまとめてみましたので、少しでも皆様の参考になりますと幸いです。

注意点

環境

Action Cable とは

Action Cable とは、Rails に搭載されている機能の1つです。

Action Cable はフルスタックのフレームワークで、クライアント側の JavaScript フレームワークと、サーバー側の Ruby フレームワークを提供します。

Action Cable を使用することで、簡単に WebSocket を Rails アプリケーションに導入し、リアルタイム機能を実装することができます。

https://railsguides.jp/action_cable_overview.html

WebSocket とは

WebSocket とは、クライアントとサーバーの間で1度接続を確立すると、リアルタイムで双方向の通信を可能にするプロトコルのことです。

HTTP のように、リクエストごとに接続を確立する必要がなく、効率的なデータの送受信が可能となっており、リアルタイム性が求められるアプリケーションの開発に活用されています。

https://shukapin.com/infographicIT/websocket

用語

Action Cable、WebSocket を学ぶ上で、理解しておくべき用語が6つあります。

コネクション

  • コネクション(connection)は、クライアントとサーバーを繋ぐもの
  • 1つのコネクションは、1つのコネクションインスタンスを持っている
  • 1つの Action Cable サーバーは、複数のコネクションインスタンスを持つことができる
  • あるユーザーがブラウザタブを複数開いたり、複数のデバイスを用いている場合、それぞれにコネクションがオープンされる

コンシューマー

  • WebSocket コネクションのクライアントは、コンシューマー(consumer)と呼ばれる
  • Action Cable のコンシューマーは、クライアント側の JavaScript フレームワークによって作成される

チャネル

  • コンシューマーごとに、複数のチャネル(channel)をサブスクライブすることができる
    • 1つのコンシューマーが、「チャットチャネル」「ゲームチャネル」のように複数のチャネルをサブスクライブすることができる
  • 各チャネルは、機能単位で、カプセル化されており、チャネル内では「送られてきたデータを処理する」「必要なデータを返す」のように Rails の Controller と同じことが行われている

サブスクライバ

  • コンシューマーがチャネルでサブスクライブされると、サブスクライバ(subscriber)として振る舞う
  • サブスクライバとチャネルの間のコネクションは、サブスクリプションと呼ばれる

Pub/Sub

  • Pub/Sub は、Publish-Subscribe の略
  • Pub/Sub は、情報の送り手(パブリッシャー)と受け手(サブスクライバー)を直接つなげない、柔軟なやり取りの仕組み
  • メールのように送り手を指定するのではなく、Pub/Sub は、送り手がトピックと呼ばれる特定のテーマに対して情報を送信し、そのトピックをサブスクライブしているすべての受け手に配信される
  • Action Cable では、この Pub/Sub を用いてサーバーと多数のクライアントの間の通信を行う

ブロードキャスト

  • ブロードキャストは、Pub/Sub の考え方に基づいた、特定のチャネルに接続しているすべてのクライアントに一斉に情報を送信する仕組み
  • 情報の送り手をブロードキャスターと呼ぶ
  • チャネルは複数のブロードキャストをストリーミングできる
    • ブロードキャスト:情報を送信する仕組み
    • ストリーミング:データを転送する方式

実装

では、実際に Rails アプリケーションに Action Cable を導入し、リアルタイム機能を実装してみます。

今回は、しりとりゲームにリアルタイム機能を導入してみます。

認証の設定

app/channels/application_cable/connection.rb を編集し、認証の設定を行います。

connect メソッドは、クライアントが WebSocket 接続を試みたときに最初に呼び出されるメソッドです。

どのユーザーが接続しようとしているかを特定し、ユーザーが特定できた場合はそのユーザーを返し、ユーザーが特定できなかった場合は WebSocket 接続を拒否します。

app/channels/application_cable/connection.rb
module ApplicationCable
  class Connection < ActionCable::Connection::Base
+   identified_by :current_user

+   def connect
+     self.current_user = find_verified_user
+   end

+   private

+   def find_verified_user
+     if (verified_user = env['warden']&.user)
+       verified_user
+     else
+       reject_unauthorized_connection
+     end
+   end
  end
end

ファイルの作成

以下のコマンドを実行すると、2つのファイルが作成されます。

ターミナル
docker compose exec app rails g channel chain_word
  • 作成されたファイル
    • app/channels/chain_word_channel.rb
      • サーバー側のロジックを記述するファイル
    • app/javascript/channels/chain_word_channel.js
      • クライアント側のロジックを記述するファイル

サーバー側の実装

app/channels/chain_word_channel.rb を編集し、サーバー側のロジックを実装します。

subscribed メソッドは、クライアントがチャネルへのサブスクライブをリクエストしたときに呼び出されるメソッドです。

chain_word という名前のストリームからデータを受信することを宣言しています。

app/channels/chain_word_channel.rb
class ChainWordChannel < ApplicationCable::Channel
  def subscribed
+   stream_from 'chain_word'
  end

  def unsubscribed
    # Any cleanup needed when channel is unsubscribed
  end
end

次に、app/controllers/chain_words_controller.rb を編集し、ブロードキャスト機能を実装します。

ChainWord レコードが正常に保存された場合、Action Cable を使用して chain_word チャネルにブロードキャストを行うようにしています。

app/controllers/chain_words_controller.rb
class ChainWordsController < ApplicationController
  def index
    @chain_words = ChainWord.includes(:user).order(id: :desc)
    @chain_word = current_user.chain_words.new
  end

  def create
    @chain_word = current_user.chain_words.new(chain_word_params)

    if @chain_word.save
+     ActionCable.server.broadcast('chain_word', {
+                                    action: 'create',
+                                    chain_word: {
+                                      word: @chain_word.word,
+                                      user_name: @chain_word.user.name
+                                    }
+                                  })
      redirect_to chain_words_path, notice: 'ChainWord was successfully created.'
    else
      @chain_words = ChainWord.includes(:user).order(id: :desc)
      render :index
    end
  end

  private

  def chain_word_params
    params.require(:chain_word).permit(:word)
  end
end

クライアント側の実装

app/javascript/channels/chain_word_channel.js を編集し、クライアント側のロジックを実装します。

connected メソッドは、クライアントがチャネルに接続したときに呼び出されるメソッドです。

disconnected メソッドは、クライアントがチャネルから切断されたときに呼び出されるメソッドです。

received メソッドは、クライアントがサーバーからデータを受信したときに呼び出されるメソッドです。

received メソッドで、サーバーから受信したデータの action が create の場合、新しい単語を画面に追加する addChainWord メソッドを呼び出しています。

app/javascript/channels/chain_word_channel.js
import consumer from "./consumer"

consumer.subscriptions.create("ChainWordChannel", {
  connected() {
+   console.log("Connected to ChainWordChannel");
  },

  disconnected() {
+   console.log("Disconnected from ChainWordChannel");
  },

  received(data) {
+   console.log("Received data:", data);

+   if (data.action === 'create') {
+     this.addChainWord(data.chain_word);
+   }
},

+ addChainWord(chainWord) {
+   const newCard = document.createElement('div');
+   newCard.className = 'card border m-3';
+   newCard.style.width = '400px';

+   newCard.innerHTML = `
+     <div class="card-header h5 text-center">${chainWord.word}</div>
+     <div class="card-body h5 text-center">${chainWord.user_name}</div>
+   `;

+   const cardContainer = document.querySelector('#chain-word-container');

+   if (cardContainer) {
+     cardContainer.insertBefore(newCard, cardContainer.firstChild);

+     const wordInput = document.querySelector('#chain_word_word');
+     if (wordInput) {
+       wordInput.value = '';
+     }

+     this.showMessage('新しい単語が追加されました!', 'success');
+   }
+ },

+ showMessage(text, type = 'info') {
+   const existingMessage = document.querySelector('.realtime-message');
+   if (existingMessage) {
+     existingMessage.remove();
+   }

+   const message = document.createElement('div');
+   message.className = `alert alert-${type} realtime-message`;
+   message.style.position = 'fixed';
+   message.style.top = '20px';
+   message.style.right = '20px';
+   message.style.zIndex = '9999';
+   message.textContent = text;

+   document.body.appendChild(message);

+   setTimeout(() => {
+     if (message.parentNode) {
+       message.parentNode.removeChild(message);
+     }
+     }, 3000);
+   }
+ });

以上で実装は完了です。

動作確認

他のユーザーが新しい単語を追加したときに、ブラウザをリロードしなくても、リアルタイムで新しい単語が表示されることが確認できました。

まとめ

今回、改めて Action Cable について学び、実際にリアルタイム機能を実装してみました。

用語を理解するのが少し大変でしたが、実装自体は簡単にできました。

今後、Action Cable を活用できそうな場面がありましたら、導入してみたいと思います。

最後までご覧いただき、ありがとうございました。

参考文献

https://railsguides.jp/action_cable_overview.html

https://shukapin.com/infographicIT/websocket

宣伝

株式会社L&E Group では、定期的にイベントを開催しています。

どなたでも参加可能となっておりますので、興味のある方はぜひご参加ください。

https://legrp.connpass.com/event/366731

https://legrp.connpass.com

GitHubで編集を提案
株式会社L&E Group

Discussion