FreeCamp      

【Rails】ActionCableを用いてリアルタイムチャットの実装

SNSには必ずと言っていいほどリアルタイムチャットが導入されています。
どんな機能かというと、自分が投稿したら相手の画面に自分の投稿が「ブラウザをリロードせずに」表示されているというもの。
これはWebSocketという技術を使うと実装できます。
RailsではActionCableを使用すると簡単に実装できます。

目次

  1. グループチャットの実装
    1. ユーザー認証機能作成
    2. チャットルーム作成
    3. 投稿機能作成
  2. ActionCable導入
  3. 完成ソースコード

1. グループチャットの実装

リアルタイムチャットを作成する前にまずはグループチャットの実装を行います。
その後ActionCableを導入し、リアルタイムで投稿し合えるように編集していきます。

1.1 ユーザー認証機能

まずはGem deviseを用いてユーザー認証機能を作成します。
以下記事を参考にして、名前でログインできるようにします。

$ rails new rails_realtimechat
$ cd rails_realtimechat
$ rails g controller home top
...編集
get 'home/top'
↓
root 'home#top'
...追加
gem 'devise'
$ bundle
$ rails g devise:install
$ rails g devise User name:string
$ rails db:migrate
$ rails g devise:views users
$ rails g devise:controllers users
...編集
devise_for :users
↓
devise_for :users, controllers: {
  sessions: 'users/sessions',
  registrations: 'users/registrations'
}
...編集&追加
<div class="field">
  <%= f.label :email %><br />
  <%= f.email_field :email, autofocus: true, autocomplete: "email" %>
</div>
↓
<div class="field">
  <%= f.label :name %><br />
  <%= f.text_field :name, autofocus: true, autocomplete: "name" %>
</div>

<div class="field">
  <%= f.label :email %><br />
  <%= f.email_field :email, autocomplete: "email" %>
</div>
...編集
<div class="field">
  <%= f.label :email %><br />
  <%= f.email_field :email, autofocus: true, autocomplete: "email" %>
</div>
↓
<div class="field">
  <%= f.label :name %><br />
  <%= f.text_field :name, autofocus: true, autocomplete: "name" %>
</div>
...追加
before_action :configure_permitted_parameters,if: :devise_controller? 

private

def configure_permitted_parameters
  devise_parameter_sanitizer.permit(:sign_up,keys:[:email])
end
...編集
# config.authentication_keys = [:email]
↓
config.authentication_keys = [:name]
...追加(bodyタグ直下)
<% if user_signed_in? %>
  <%= link_to "ログアウト", destroy_user_session_path, method: :delete %>
<% else %>
  <%= link_to "新規登録", new_user_registration_path %>
  <%= link_to "ログイン", new_user_session_path %>
<% end %>

ログインしていたら自分の名前が表示されるようにします。

...編集
<% if user_signed_in? %>
  <p>名前: <%= current_user.name %></p>
  <%= link_to "ログアウト", destroy_user_session_path, method: :delete %>
<% else %>
  <%= link_to "新規登録", new_user_registration_path %>
  <%= link_to "ログイン", new_user_session_path %>
<% end %>

ここまででユーザー認証機能を作成することができました。
アプリケーションを実行し、試してみてください。

1.2 チャットルーム作成

チャットを行うには部屋という概念が必要です。
グループを作成し、自分ともう一人・自分と複数人でチャットできるようにします。
投稿に部屋のidを付与することで、その部屋内でのやりとりと認識します。
まずはチャットルームを作成していきます。

$ rails g model Room name:string
$ rails g model UserRoom user:references room:references
$ rails db:migrate

referencesは外部キーを明示的に指定するものです。
外部キーを設定する場合、integer型ではなくなるべくreferencesとした方が良いです。

モデルの作成が終わったら、モデル間の関連付けをしていきます。

...追加
has_many :user_rooms
has_many :rooms, through: :user_rooms
has_many :user_rooms
has_many :users, through: :user_rooms

UserRoomモデルは中間モデルです。
ユーザーが属している部屋、ある部屋に属しているユーザーを取得するためにUserモデル, Roomモデル両方にthroughを使用します。

次にユーザーが部屋を作成・部屋に参加するための記述をしていきます。

...編集
def top
  if user_signed_in?
    @room = Room.new
    @rooms = current_user.rooms
    @nonrooms = Room.where(id: UserRoom.where.not(user_id: current_user.id).pluck(:id))
  end
end
<% if user_signed_in? %>
  <h2>トークルーム作成</h2>
  <%= form_with model: @room do |f| %>
    <%= f.label :name %>
    <%= f.text_field :name %>
    <%= f.submit %>
  <% end %>

  <h2>参加済トークルーム一覧</h2>
  <% @rooms.each do |room| %>
    <%= room.name %>
  <% end %>

  <h2>未参加トークルーム一覧</h2>
  <% @nonrooms.each do |room| %>
    <%= room.name %>
  <% end %>
<% end %>
$ rails g controller room show
...編集
get 'rooms/show'
↓
resources :rooms, only: [:show, :create]
...編集&追加
def show
  @room = Room.find(params[:id])
end

def create
  @room = Room.new(room_params)
  @room.save
  current_user.user_rooms.create(room_id: @room.id)
  redirect_to @room
end

private
def room_params
  params.require(:room).permit(:name)
end

ここまででトークルームは作成できました。
これから投稿できるようにしていきます。

1.3 投稿機能作成

まずは投稿を保存するモデルを作成していきます。

$ rails g model Post message:string user_id:integer
$ rails db:migrate

そして、PostモデルとUserモデル, Roomモデルを関連付けていきます。

...追加
has_many :posts
...追加
belongs_to :user
belongs_to :room
...追加
has_many :posts

rooms_controller.rbのshowアクションを編集し、
そのトークルームでのメッセージを取得できるようにします。

...編集
def show
  @room = Room.find(params[:id])
  @posts = @room.posts
end
<p><%= "ルーム名:#{@room.name}" %></p>

<input type="text" data-behavior="room_speaker"> // 投稿フォーム

<div id="posts">
  <%= render "post", collection: @posts %>
</div
<% @posts.each do |post| %>
  <p><%= post.message %></p>
<% end %>

ここまででグループチャットの見た目は作成できました。
続いてActionCableを導入していきます。

2. ActionCable導入

リアルタイム通信を行うためにChannelを作成します。

$ rails g channel room speak

これはコントローラのようなもので、リアルタイム通信を行うなら必須です。
app/channels/room_channel.rbとapp/assets/javascripts/channels/room.coffeeが作成されました。
room_channel.rbはサーバーサイド、room.coffeeはフロントサイドで動作します。

フォームが入力され、Enterキーが押された時にリアルタイム通信を発火させたいので、jQueryを導入しましょう。

App.room = App.cable.subscriptions.create "RoomChannel",
  connected: ->
    # Called when the subscription is ready for use on the server
    # 通信が確立された時

  disconnected: ->
    # Called when the subscription has been terminated by the server
    # 通信が切断された時

  received: (data) ->
    # Called when there's incoming data on the websocket for this channel
    # 値を受け取った時

  speak: (message) ->
    @perform 'speak', message: message #サーバーサイドのspeakアクションにmessageパラメータを渡す

jQuery(document).on 'keypress', '[data-behavior~=room_speaker]', (event) ->
  if event.keyCode is 13 # return キーのキーコードが13
    App.room.speak event.target.value # speak メソッド, event.target.valueを引数に.
    event.target.value = ''
    alert()
    event.preventDefault()

ここまで書けたら実際にフォームの中に値を入れて、Enterキーを押してみましょう。
アラートダイアログが表示されれば成功です。

続いて、投稿を保存してチャットルームに投稿を表示させていきます。

class RoomChannel < ApplicationCable::Channel
  def subscribed
    # stream_from "some_channel"
    # 接続された時
    stream_from "room_channel"
  end

  def unsubscribed
    # Any cleanup needed when channel is unsubscribed
    # 切断された時
  end

  def speak(message)
    Post.create(message: message['message'])
  end
end

Postモデルのレコードを保存するためにはuser_idとroom_idの保存が必須です。
どうにかして、room_channe.rbにcurrent_user.idとroom_idを渡しましょう。
※current_user.idは使用できません。

<p><%= "ルーム名:#{@room.name}" %></p>

<input type="text" data-behavior="room_speaker" data-user="<%= current_user.id %>" data-room="<%= @room.id %>">

<div id="posts">
  <%= render "post", collection: @posts %>
</div>
App.room = App.cable.subscriptions.create "RoomChannel",
  connected: ->
    # Called when the subscription is ready for use on the server

  disconnected: ->
    # Called when the subscription has been terminated by the server

  received: (data) ->
    # Called when there's incoming data on the websocket for this channel

  speak: (message) ->
    @perform 'speak', message: message #サーバーサイドのspeakアクションにmessageパラメータを渡す

jQuery(document).on 'keypress', '[data-behavior~=room_speaker]', (event) ->
  if event.keyCode is 13 # return キーのキーコードが13
    App.room.speak [event.target.value, $('[data-user]').attr('data-user'), $('[data-room]').attr('data-room')] # speak メソッド, event.target.valueを引数に.
    event.target.value = ''
    event.preventDefault()
class RoomChannel < ApplicationCable::Channel
  def subscribed
    # stream_from "some_channel"
    # 接続された時
    stream_from 'room_channel'
  end

  def unsubscribed
    # Any cleanup needed when channel is unsubscribed
    # 切断された時
  end

  def speak(message)
    post = Post.new(message: message['message'][0], user_id: message['message'][1].to_i, room_id: message['message'][2].to_i)
    post.save
    ActionCable.server.broadcast 'room_channel', message: message['message'][0] # フロントへ返します
  end
end
App.room = App.cable.subscriptions.create "RoomChannel",
  connected: ->
    # Called when the subscription is ready for use on the server

  disconnected: ->
    # Called when the subscription has been terminated by the server

  received: (data) ->
    # Called when there's incoming data on the websocket for this channel
    # サーバーサイドから値を受け取る
    $('#posts').append("<p>"+data["message"]+"</p>"); # 投稿を追加

  speak: (message) ->
    @perform 'speak', message: message #サーバーサイドのspeakアクションにmessageパラメータを渡す

jQuery(document).on 'keypress', '[data-behavior~=room_speaker]', (event) ->
  if event.keyCode is 13 # return キーのキーコードが13
    App.room.speak [event.target.value, $('[data-user]').attr('data-user'), $('[data-room]').attr('data-room')] # speak メソッド, event.target.valueを引数に.
    event.target.value = ''
    event.preventDefault(