Edited at

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


前置き

そこそこRailsが扱えるようになりました。

ありがたいことに色々コメントをいただく機会もあり、改めて見直してみたところ、ちょっとうまく動かないところとかもあり、Rails自体のバージョンも上がっているので最新版の6.0で書き直すことにしました。


本題

今回作成するアプリはユーザー認証にDevise、リアルタイムチャットを実現するためにActionCableを使用します。

作成順序は大まかに以下の通りです。

1. 全てのユーザーが接続できるオープンなチャットアプリを作成

2. グループを作成してそこに参加するメンバーのみでチャットができるよう改良する


開発環境

Ruby 2.6.3

Rails 6.0.0


参考にした記事

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

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


完成形イメージ

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

名称未設定.mov.gif


ソースコード

https://github.com/erysk/actioncable

https://github.com/erysk/chat_app (古いバージョンでLINE風にして遊んだみたもの)


アプリの作成

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

まずは全てのユーザーが接続できるオープンなチャットアプリを作成していきます。

テストは作成しません。

DBはデフォルトのSQLiteを使います。

まずはrailsアプリケーションを作成します。


bash

$ mkdir chat_app

$ cd chat_app
$ bundle init


Gemfile

# frozen_string_literal: true

source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

gem 'rails', '6.0.0'



bash

$ bundle install

$ rails _6.0.0_ new . -T

この-Tというオプションはテストを作成しませんよというものです。

--skip-testの略です。Minitest関連のファイルが自動生成されなくなります。

Gemfileを上書きしますか?と聞かれるので上書きしてください。

Rails6ではデフォルトでwebpackerを使うようになっていますので、

初めての場合yarnをインストールする必要があったりするかと思います。

だいたいはbrew install yarnとかで解決すると思いますが、調べてみてください。

エラーなどでyarnを新しくインストールした場合は再度rails webpacker:installを実行してください。

Gemfileに必要なgemを追加し、インストールします。


Gemfile

gem 'devise'



bash

$ bundle install



ユーザー認証

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

Deviseについての細かな説明は割愛します。

少し検索すればとても参考になる記事がいくつも出てくると思うのでそちらを参考にしてください。


bash

$ rails g devise:install

$ rails g devise user
$ rails db:migrate

たまにrails g devise:installで固まることがあります。

その場合はspring stopでspringを止めてから再度試してください。

サーバーを起動してhttp://localhost:3000/users/sign_inにアクセスし、ログイン機能を確認。


チャット機能の作成


Messageモデルを作成


bash

# text型のcontentカラムを持つMessageモデルを作成

$ rails g model message content:text
$ rails db:migrate


Roomsコントローラの作成


bash

# showアクションを持つroomsコントローラを作成

$ rails g controller rooms show


ルーティングの設定


routes.rb

Rails.application.routes.draw do

devise_for :users
get 'rooms/show'
end


showアクションの中身を追加


app/controllers/rooms_controller.rb

class RoomsController < ApplicationController

before_action :authenticate_user! # Deviseのログイン確認

def show
# メッセージ一覧を取得
@messages = Message.all
end
end



rooms#showに対応するviewを作成


app/views/rooms/show.html.erb

<h1>Chat room</h1>

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

render @messagesとはrender partial: 'messages/message', collection: @messagesの略です。

メッセージ毎にmessages/_message.html.erbというパーシャルが適用されるようになります。


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


app/views/messages/_message.html.erb

<div class='message'>

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

この中のmessage変数の中に@messagesのそれぞれのレコードが代入されています。


投稿データの登録


bash

$ rails c



ruby

 Message.create! content: 'Hello'


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


Roomチャネルの作成

ActionCableの機能を使うためにチャネルを作成します。

その前にjQueryを使えるようにしておきましょう。


bash

$ yarn add jquery



config/webpack/environment.js

const { environment } = require('@rails/webpacker');

const webpack = require('webpack');

environment.plugins.append('Provide',
new webpack.ProvidePlugin({
$: 'jquery/src/jquery',
jQuery: 'jquery/src/jquery'
})
);

module.exports = environment;



app/javascript/packs/application.js

// 追加

require('jquery');

デベロッパーツールのコンソールでバージョンが表示されればOK。


console

console.log($.fn.jquery);

// 3.4.1

speakメソッドを持つroomチャネルを作成します。


bash

$ rails g channel room speak


app/channel/room_channel.rbのsubscribedメソッドのコメントアウトを外し、"room_channel"をstreamする。

そしてspeakアクションに引数を用意し、中身を追加する


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)
# jsで実行されたspeakのmessageを受け取り、room_channelのreceivedにブロードキャストする
ActionCable.server.broadcast 'room_channel', message: data['message']
end
end


channelのspeakを実行させるためにjsでspeak関数を定義する。


app/javascript/channels/room_channel.js

consumer.subscriptions.create("RoomChannel", {

// ...
// これが実行されるとコンシューマになったRoomChannel#speak({ message: message })が呼ばれる
speak: function(message) {
return this.perform('speak', {
message: message
});
}
});

データを受け取った際のアクションを定義する。


app/javascript/channels/room_channel.js

consumer.subscriptions.create("RoomChannel", {

// ...
// room_channel.rbでブロードキャストされたものがここに届く
received: function(data) {
return alert(data['message']);
},
// ...
});


フォームの作成


app/views/rooms/show.html.erb

<h1>Chat room</h1>

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

<%= label_tag :content, 'Say something:' %>
<%= text_field_tag :content, nil, data: { behavior: 'room_speaker' } %>


フォーム内でエンターキーを押した時の動作を定義


app/javascript/channels/room_channel.js

const chatChannel = consumer.subscriptions.create("RoomChannel", {

// ...
});

$(document).on('keypress', '[data-behavior~=room_speaker]', function(event) {
if (event.keyCode === 13) {
chatChannel.speak(event.target.value);
event.target.value = '';
return event.preventDefault();
}
});


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


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

speakアクションの書き換え


app/channels/room_channel.rb

  def speak(data)

# ActionCable.server.broadcast 'room_channel', message: data['message']
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
# createの後にコミットする { MessageBroadcastJobのperformを遅延実行 引数はself }
after_create_commit { MessageBroadcastJob.perform_later self }
end

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


非同期でアラートの代わりに画面に文字を表示させる


app/javascript/channels/room_channel.js

  received: function(data) {

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

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

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


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

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


Roomモデルの作成


bash

$ rails g model room

$ rails db:migrate


モデルの紐付け

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


bash

$ rails g migration AddUserRefAndRoomRefToMessages user:references room:references


うまいこと自動生成されてなかった場合は手直しする。


_add_columns_to_messages.rb

class AddColumnsToMessages < ActiveRecord::Migration[6.0]

def change
add_reference :messages, :user, null: false, foreign_key: true
add_reference :messages, :room, null: false, foreign_key: true
end
end

デフォルト値の無いNotNullなカラムをいきなり追加しようとするとエラーになるので小細工する。


_add_columns_to_messages.rb

class AddUserRefAndRoomRefToMessages < ActiveRecord::Migration[6.0]

def change
add_reference :messages, :user, foreign_key: true
add_reference :messages, :room, foreign_key: true
change_column_null :messages, :user_id, false
change_column_null :messages, :room_id, false
end
end


bash

$ rails db:migrate


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


app/models/user.rb

class User < ApplicationRecord

# ...

has_many :messages
end



app/models/room.rb

class Room < ApplicationRecord

has_many :messages
end


app/models/message.rb

class Message < ApplicationRecord

# ...

belongs_to :user
belongs_to :room
end



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


Controller


app/controllers/rooms_controller.rb

class RoomsController < ApplicationController

# ついでにRoom一覧を表示させるアクションも追加しておく
def index
@rooms = Room.all.order(:id)
end

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



routes.rb

Rails.application.routes.draw do

devise_for :users

# rootはRoom一覧画面にしておく
root 'rooms#index'

# resourcesを使うとRESTfulなURLを自動生成できる
resources :rooms, only: %i[show]
end



View

紐づいたデータを表示させるよう修正する。

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


app/views/rooms/show.html.erb

<%#                ここの data-room_id を使ってjs側で部屋を見分ける %>

<div id='messages' data-room_id="<%= @room.id %>">
<%= render @messages %>
</div>

<%= label_tag :content, 'Say something:' %>
<%= text_field_tag :content, nil, data: { behavior: 'room_speaker' } %>


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


app/views/messages/_message.html.erb

<div class='message'>

<%# 投稿者を特定できるようにメールアドレスを表示させておく %>
<p><%= "#{message.user.email}: #{message.content}" %></p>
</div>

ついでにRoom一覧画面も追加しておこう


app/views/rooms/index.html.erb

<div>

<ul>
<% @rooms.each do |room| %>
<li><%= link_to "ROOM#{room.id}", room_path(room) %></li>
<% end %>
</ul>
</div>


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


app/javascript/channels/room_channel.js

import consumer from './consumer'

// $(function() {}; で囲むことでレンダリング後に実行される
// レンダリング前に実行されると $('#messages').data('room_id') が取得できない
$(function() {
const chatChannel = consumer.subscriptions.create({ channel: 'RoomChannel', room: $('#messages').data('room_id') }, {
connected() {
// Called when the subscription is ready for use on the server
},

disconnected() {
// Called when the subscription has been terminated by the server
},

received: function(data) {
return $('#messages').append(data['message']);
},

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

$(document).on('keypress', '[data-behavior~=room_speaker]', function(event) {
if (event.keyCode === 13) {
chatChannel.speak(event.target.value);
event.target.value = '';
return event.preventDefault();
}
});
});



app/channels/room_channel.rb

class RoomChannel < ApplicationCable::Channel

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

# ...

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


ブロードキャストする場所もルームごとに分ける


app/jobs/message_broadcast_job.rb

  def perform(message)

ActionCable.server.broadcast "room_channel_#{message.room_id}", message: render_message(message)
end


current_user情報を取得する

Deviseを使っている場合、ログインユーザーのIDはクッキーの以下に格納されている

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

これを使ってcurrent_userを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
# 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)
# こんなややこしくしなくても↓で取れました。
verified_user = User.find_by(id: env['warden'].user.id)
return reject_unauthorized_connection unless verified_user
verified_user
end
end
end



完成

あとはログインしないとチャットできないようにしたり、Roomの新規作成機能を付け加えたり、メッセージ画面をおしゃれな感じにしたり、Roomの閲覧制限をかけたりこの辺りは簡単にできると思う。

わかんないこととかあったら気軽にコメントかTwitterの方に連絡してくれれば責任を持って説明します。

あと、記事の内容を書き換えたので(一応確認はしたけど)うまく動かないとかがもしあれば教えてください。