Ruby
Rails
devise
ActionCable

【Rails5.2】ActionCableとDeviseの欲張りセットでリアルタイムチャット作成


編集履歴

'18.10.12 修正

find_verified_userメソッド内のif文が必ずfalseになってしまっていたので修正

'18.6.27 追記

cookies.encrypted[Rails.application.config.session_options[:key]][‘warden.user.user.key’][0][0]にログインしているユーザーのID情報を発見。記事を修正しました。


前置き

今回この記事を書こうと思った理由は3つ。

1つ目は、ActionCableを使ったリアルタイムチャット作成の記事が少ないこと、

2つ目は、既存する記事のRailsや諸々のバージョンが古いこと、

3つ目は、その多くがユーザー認証にDeviseを使うことを前提としていなかったこと。

などと偉そうなことを言っている私は、実はまだ未経験で入社して2ヶ月目のひよっこですので、もしおかしなところや説明不足なところがございましたら教えてください🙏


本題

今回作成するアプリはユーザー認証にDevise、リアルタイムチャットを実現するためにActionCableを使用します。順序としては全てのユーザーが接続できるオープンなチャットアプリを作成した後、グループを作成してそこに参加するメンバーのみでチャットができるよう改良します。


開発環境

Ruby 2.5

Rails 5.2


参考にした記事

Action Cableでリアルタイムチャットアプリの作成方法 (Rails 5.1.4にて)(その1) herokuで動かす!

Rails 5 Action Cable メッセージとルームを紐付ける。


完成形イメージ

下のような同じルーム内でのみリアルタイムで投稿内容が表示されるシンプルなアプリです。

名称未設定.mov.gif


ソースコード

必要ないかもしれませんがGitHubのURLを貼っておきます。

https://github.com/eRy-sk/chat_app


アプリの作成

今回はchat_appという名前でアプリケーションを作成します。


bash

$ rails new chat_app


必要なgemをインストールします。


Gemfile

gem 'devise'

gem 'jquery-rails'


bash

$ bundle install


jQueryを読み込む


app/assets/javascripts/application.js

//= require jquery

//= require rails-ujs


ユーザー認証

続いてDeviseでサクッとユーザー認証を実現します。


bash

$ rails g devise:install

$ rails g devise user
$ rails db:migrate

サーバーを起動してhttp://localhost:3000/users/sign_inにアクセスし、実装されたか確認します。


チャット機能の作成


ControllerとModelを作成


bash

$ rails g controller rooms show

$ rails g model message content:text


showアクションの追加


app/controllers/rooms_controller.rb

  def show

@messages = Message.all
end


message一覧を表示させるviewを作成


app/views/rooms/show.html.erb

<h1>Chat room</h1>

<div id="messages">
<%= render @messages %>
</div>


message1つ1つを表示するパーシャルを追加


app/views/messages/_message.html.erb

<div class="messages">

<p><%= message.content %></p>
</div>


投稿データの登録


bash

$ rails c



console

 Message.create! content: "Hello"


http://localhost:3000/rooms/showにアクセスし、投稿したデータが表示されるか確認


Roomチャンネルの作成


bash

$ rails g channel room speak


ブラウザ側のコンソールを開いて確認(デベロッパー ツール)


console

App.room.speak()

=>true

speakアクションを定義する


app/assets/javascripts/channels/room.coffee

  speak: (message) ->

@perform 'speak', message: message

subscribedアクションのコメントアウトを外し、room_channelをstreamする


app/channel/room_channel.rb

class RoomChannel < ApplicationCable::Channel

def subscribed
stream_from "room_channel"
end

def unsubscribed
# Any cleanup needed when channel is unsubscribed
end

def speak(data) #サーバーサイドのspeakアクションの定義
ActionCable.server.broadcast 'room_channel', message: data['message']
end
end


データを受け取った際のアクション(アラート)を定義して動作確認


app/assets/javascripts/channels/room.coffee

received: (data) ->

alert data['message']

サーバー起動、ブラウザ側コンソールを開いて確認


console

App.room.speak("test")

#アラートが表示されればOK


フォームの作成


app/views/rooms/show.html.erb

<form>

<label>Say something:</label><br>
<input type="text" data-behavior="room_speaker">
</form>

エンターキーを押した時の動作を定義


app/assets/javascripts/channels/room.coffee





$(document).on 'keypress', '[data-behavior~=room_speaker]', (event) ->
if event.keyCode is 13 # return = send
App.room.speak event.target.value
event.target.value = ''
event.preventDefault()

サーバー起動、テキストボックスに文字を入力してエンターを押してアラートが出ればOK


入力したものが保存されるように改善

speakアクションの書き換え


app/channels/room_channel.rb

  def speak(data)

Message.create! content: data['message']
end


Broadcastのjobを作成


bash

$ rails g job MessageBroadcast


作成されたjobファイルを編集し、ブロードキャスト処理を追加


app/jobs/message_broadcast_job.rb

  def perform(message)

ActionCable.server.broadcast 'room_channel', message:
render_message(message)
end

private

def render_message(message)
ApplicationController.renderer.render(partial: 'messages/message', locals: {message: message})
end



messageモデルを編集する


app/models/message.rb

class Message < ApplicationRecord

validates :content, presence: true #これにより無記入投稿とエンター長押しの連続投稿の2つが同時に防げる
after_create_commit {MessageBroadcastJob.perform_later self}
end

サーバー起動、文字入力後エンターを押し、入力した文字入りアラートが表示されればOK


非同期で画面に入力された文字を表示させる


app/assets/javascripts/channels/room.coffee

  received: (data) ->

$('#messages').append data['message']

実際にメッセージを送信して確認

これでチャット機能は実装されました。


複数のルームとメッセージの紐付け

ここからは先ほど作ったアプリを改良して、複数ルームのそれぞれのルームの接続者のみにチャットが見えるようにします。


Roomモデルの作成


bash

$ rails g model room



モデルの紐付け

Messageモデルに紐付けのためのカラムを追加します。


bash

$ rails g migration AddColumnsToMessages user:references room:referencese

$ rails db:migrate

UserモデルとRoomモデルに紐付けのためのhas_manyを追加


app/models/user.rb

class Room < ActiveRecord::Base

has_many :messages



end


app/models/room.rb

class Room < ActiveRecord::Base

has_many :messages
end


接続ルームのメッセージのみが表示されるようControllerとViewを変更


Controller


app/controllers/rooms_controller.rb

class RoomsController < ApplicationController

def show
@room = Room.find(params[:id])
@messages = @room.messages
end
end


View

接続ルームに紐づいたメッセージを全て表示させるためのview


app/views/rooms/show.html.erb

<div id="messages", data-room_id="<%= @room.id %>">

<%= render @messages %>
</div>
<div class="input">
<form>
<label>post</label>
<input type="text" data-behavior="room_speaker">
</form>
</div>

接続ルームに紐づいたメッセージ1つ1つを表示するパーシャル


app/views/messages/_message.html.erb

<div class="message">

<p><%= "#{message.user.email}: #{message.content}" %></p>
</div>


Room.idを受け取り、監視する場所を分ける


app/assets/javascripts/channels/room.coffee

document.addEventListener 'turbolinks:load', ->

App.room = App.cable.subscriptions.create { channel: "RoomChannel", room_id: $('#messages').data('room_id') },
connected: ->

disconnected: ->

received: (data) ->
$('#messages').append data['message']

speak: (message)->
@perform 'speak', message: message

$(document).on 'keypress', '[data-behavior~=room_speaker]', (event) ->
if event.keyCode is 13 # return = send
App.room.speak event.target.value
event.target.value = ''
event.preventDefault()



app/channels/room_channel.rb

class RoomChannel < ApplicationCable::Channel

def subscribed
stream_from "room_channel_#{params['room_id']}"
end

def unsubscribed
# Any cleanup needed when channel is unsubscribed
end

def speak(data)
Message.create!(content: data['message'], user_id: current_user.id, room_id: params['room_id'])
end
end



current_user使えない問題

Websocket側はモデルのインスタンスを参照できないからcurrent_userが使えない(だったかな)。

そこでcookieのcookies.signed["user.id"]からログイン中のユーザーのIDを抜き出してcurrent_userを作ろうって話。


connection.rbに色々書いて設定


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
verified_user = User.find_by(id: cookies.signed["user.id"])
return verified_user if verified_user && cookies.signed['user.expires_at'] > Time.now
reject_unauthorized_connection
end
end
end



それでもcurrent_userが使えない問題

Deviseを使ってるとcookies.signed["user.id"]にログイン中のユーザーのIDが格納されないらしい。詳しくはWardenを学ぼう。


[追記 '18.6.27]

どこにあるかというと

cookies.encrypted[Rails.application.config.session_options[:key]][‘warden.user.user.key’][0][0]

にある。よって、先ほどのファイルは


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
session_key = cookies.encrypted[Rails.application.config.session_options[:key]]
verified_id = session_key['warden.user.user.key'][0][0]
verified_user = User.find_by(id: verified_id)
return reject_unauthorized_connection unless verified_user
verified_user
end
end
end


に変更することでログインユーザーのID情報をもとにcurrent_userを定義できます。

もしくは↓でも可能。


仕方ないのでcookies.signed["user.id"]にログイン中のユーザーのIDが格納されるようinitializers/warden_hooks.rbを追加して設定。


config/initializers/warden_hooks.rb

Warden::Manager.after_set_user do |user,auth,opts|

scope = opts[:scope]
auth.cookies.signed["#{scope}.id"] = user.id
auth.cookies.signed["#{scope}.expires_at"] = 30.minutes.from_now
end

Warden::Manager.before_logout do |user, auth, opts|
scope = opts[:scope]
auth.cookies.signed["#{scope}.id"] = nil
auth.cookies.signed["#{scope}.expires_at"] = nil
end



完成

やったね。

あとは必要に応じてルームを作成するアクションやviewを作ってルーティング設定して、実際に動かして確認してみてね。

この記事はアプリ作ってしばらくして、思い出しながら書いたので間違いがあったらごめんなさい。

うまくいかない場合報告していただければ…。

おわり。