【rails】ActionCableを使用して複数チャットルームでメッセージを投稿する

今回は、ActionCableを使用した実装を紹介します。

ActionCableは、websocketという技術を使用した実装になっていて、メッセージのやりとりをhttpよりも軽量かつスピーディーにできるようです。

正直、僕も完全に理解しているわけではなく、実装自体も間違っている可能性があります。ただ、苦労して実装したので、備忘録として残しておきます。

versions
  • ruby 2.6.5
  • rails 6.0.0

websocketとは何か

クライアントとサーバーのコネクションを切断せずに、継続的な接続を可能にする技術です。

HTTPリクエストの穴

HTTPは1リクエストに対して1レスポンスしか返せないため、リアルタイム性に欠けます。

サーバーの任意のタイミングでしかレスポンスが返ってこず、ユーザビリティが低下してしまいます。

昔はブラウザで検索することがインターネットの主な目的となっていたため、これで良かったのですが、webの進化に伴いリアルタイム性の課題が浮き彫りになってきました。

Ajax通信の穴

そこで登場したのが、ajax通信です。

ajax通信は、リアルタイム性を確保するために、サーバーにリクエストを投げた後、処理を待たずにブラウザを操作できる仕組みとなっています。

ajaxの実装の代表は、javascriptになっており、javascriptのコールバック関数によって、サーバーと通信をさせます。

と同時に、javasctiptによってページをリロードすることなく、ページの一部を書き換えることができるので、ユーザビリティの高い技術となります。

しかし、あくまでもクライアント側からのリクエストにしか応じないため、サーバーからの応答はリクエストに依存します。

そのため、他ユーザーはサーバーにリクエストを送るまでは、画面が更新されないという問題があります。

Cometの穴

そこで登場したのが、Cometの技術です。

Cometは、リクエストの要求を受けたサーバができる限り接続を長引かせ、接続が切れる直前で、一気にレスポンスを返します。

これをロングポールと言ったりするらしいです。

これによって他ユーザーも一定間隔で画面が同時に反映されるようになったのですが、現代のスピードを求めたユーザビリティに追いつくことができていません。

websocketの誕生

ここでついにwebsocketの誕生です。

HTTPやajax、Cometの技術によって出てきたデメリットを吸い上げるよう誕生しました。

クライアントとサーバーのリアルタイム性を重視した技術となっており、HTTPの技術を改良して作られました。

リクエストヘッダーにupgradeヘッダーを付与することで、実現しているらしいのですが、詳しいところは全く理解できませんでした。もっと勉強せねば。。。

コネクションは、一度確立すると継続的に接続されており、クライアントからもサーバーからも通信が可能となります。

通常のHTTPとは違い、何度もコネクションを確立する必要がないため、低コストで通信のやりとりができるのが特徴です。

最後に僕が参考にしたサイトを全て載せておくので、興味のある方は一緒に勉強してください。

実装の準備をしよう

僕の備忘録も含めて、少し長くなりすぎましたね。

以下のコマンドを順番に叩いて準備をします。

※rails newはやdeviseのinstallは省略します。

actioncable$ rails g devise user
actioncable$ rails g model room name:string
actioncable$ rails g model comment content:text user:references room:references
actioncable$ rails db:migrate
actioncable$ rails g controller room index show
actioncable$ rails g controller comment create

viewを以下のように変更します。

<% @rooms.each do |room| %>
  <%= link_to room.name, room_path(room)%>
<% end %>

<%= link_to "logout",  destroy_user_session_path, method: :delete%>
<%= form_with model: @comment, url: room_comments_path(@room), html: {id: "message"} do |f| %>
  <%= f.text_field :content %>
  <input type="hidden" value=<%= @room.id %> id="room">
  <%= f.submit "送信する" %>
<% end %>


<% if @comments.present? %>
  <% @comments.each do |comment| %>
    <p><%= comment.content %></p><br>
  <% end %>
<% end %>

最後にroomをいくつか作成していきます。

面倒なので、fakerとseedファイルを使用します。

※fakerのinstallをお願いいたします

5.times do
  Room.create(name:  Faker::Name.name)
end
actioncable$ rails db:seed

これで準備は完了です。

ブラウザにアクセスして、ユーザー登録をお願いいたします。

ActionCableを導入しよう

まずは、ターミナルで以下のコマンドを叩いてください。

actioncable$ rails g channel room comment

すると、以下の二つのファイルができます。

import consumer from "./consumer"

const roomApp = consumer.subscriptions.create({channel: "RoomChannel", room: document.getElementById('room').value}, {
  connected() {
      
  },

  disconnected() {

  },

  received(data) {
  },

  comment: function(comment) {
    return this.perform('comment', {
      comment: comment
    });
  }
});
class RoomChannel < ApplicationCable::Channel
  def subscribed
   
  end

  def unsubscribed
    # Any cleanup needed when channel is unsubscribed
  end

  def comment(data)
   
end

ActionCableを実装しよう

ここからが本題です。

出来上がったファイルを以下のように編集してください。

import consumer from "./consumer"


window.addEventListener('load', function() {
  if (!document.getElementById('room')) return false;
  const roomApp = consumer.subscriptions.create({channel: "RoomChannel", room: document.getElementById('room').value}, {
    connected() {
      
    },

    disconnected() {

    },

    received(data) {
      console.log(data);
      const html = `<p>${data.comment.content}</p>`;
      document.getElementById('message').insertAdjacentHTML('beforeend', html);
      
    },

    comment: function(comment) {
      return this.perform('comment', {
        comment: comment
      });
    }
  });

  document.addEventListener('keypress', function(e) {
    if (event.keyCode === 13) {
      roomApp.comment(e.target.value);
      e.target.value = '';
      return e.preventDefault();
    }
  })
});
class RoomChannel < ApplicationCable::Channel
  def subscribed
    stream_from "room_channel_#{params['room']}"
  end

  def unsubscribed
    # Any cleanup needed when channel is unsubscribed
  end

  def comment(data)
    Comment.create!(content: data['comment'], user_id: current_user.id, room_id: params['room'])
  end
end

なにこれ?って感じですよね。簡単に解説していきます。

まずは、room_channel.jsからです。

room_channel.jsはクライアント側からサーバーへリクエストを送るために使用されます。

重要なのが、

 const roomApp = consumer.subscriptions.create({channel: "RoomChannel", room: document.getElementById('room').value}

ここです。

subuscriptionのインスタンスをクライアント再度で作成して、どのルームとコネクションを持つかを決定しています。

そして次が、room_channel.rbです。

 stream_from "room_channel_#{params['room']}"

ここで、サーバーとの接続を確立しています。

これで、クライアントサイドとサーバーサイドの接続が完了です。

これでほぼ完成なのですが、

after_create_commit { RoomBroadcastJob.perform_later self }

Commentモデルに上記を記述しましょう。

これがないとリクエストがHTTPになり、ブロードキャストされません。

そしてブロードキャストをする記述をしていきます。

ターミナルにで以下コマンドを実行します。

actioncable$ rails g job RoomBroadcast

できたファイルに以下を記述します。

class RoomBroadcastJob < ApplicationJob
  def perform(comment)
    ActionCable.server.broadcast "room_channel_#{comment.room_id}", comment: comment
  end
end

これで完成です、と言いたいところですが、実は最後にもう一つ。

ActionCableを使用すると、実はcurrent_userが使用できないんです。

なので、最後にそれを記述していきます。

実はこれが一番勉強になったり。。。

module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :current_user

    def connect
      reject_unauthorized_connection unless find_verified_user
    end

    private

    def find_verified_user
      self.current_user = env['warden'].user
    end
  end
end

「env[‘warden’]ってなに?」って感じなんですが、どうやらこれで取得できるみたいです。

これで完成です。

viewはめちゃくちゃ適当なので、気になる人は修正してから実行してください。

別解

こんなにつらつら書いてきましたが、実装後にもっと簡単なやり方があったので、そちらを紹介します。

import consumer from "./consumer"


window.addEventListener('load', function() {
  if (!document.getElementById('room')) return false;
  consumer.subscriptions.create({channel: "RoomChannel", room: document.getElementById('room').value}, {
    connected() {
      
    },

    disconnected() {

    },

    received(data) {
      console.log(data);
      const html = `<p>${data.comment.content}</p>`;
      document.getElementById('message').insertAdjacentHTML('beforeend', html);
      
    },
  });
});
class RoomChannel < ApplicationCable::Channel
  def subscribed
      @room = Room.find(params[:room])
      stream_for @room
  end

  def unsubscribed
    # Any cleanup needed when channel is unsubscribed
  end
end
class CommentsController < ApplicationController
  def create
    @room = Room.find(params['room_id'])
    @comment = Comment.new(content: params[:comment][:content], user_id: current_user.id,room_id: params[:room_id])
    @comment.save
    RoomChannel.broadcast_to @room, comment: @comment
  end
end

いや、こっちの方が記述がすっきりしていますね。
普通にこっちでやれば良かったです。

まとめ

  • websocketは双方向通信をリアルタイムでできる技術
  • ActionCableはrailsのwebsocket通信を実現するために用意された技術

この実装はいろいろ疲れました。。。

ちょっとゆっくり休もうと思います。

websocketって結構使われているんですか?
知っていたら教えてください。。。

参考

WebSocket のはなし

プロキシとリバースプロキシの違いまとめ

リバースプロキシとは?仕組みをわかりやすく解説

WebSocketについて調べてみた。

Windows 8 と WebSocket プロトコル

Jettyで始めるWebSocket超入門

【同期通信、非同期通信】

複数の識別方法でActionCableを使用する

【Rails6.0】ActionCableとDeviseの欲張りセットでリアルタイムチャット作成(改正版)

ActionCableでリアルタイムDMを実装する