【Rails6】ActionCableを用いたリアルタイムグループチャット機能① ~リアルタイムチャット完成まで~

#はじめに
はじめまして、とあるプログラミングスクールでメンターをしている者です。
今回、その受講生からグループチャットを作りたいというお願いを聞き自分で勉強をしてみました。せっかくなので、リアルタイムでチャットができるよう調べて見たところ、RailsのActioncableという機能にたどり着きました。今回、その備忘録となっております。まだまだRailsの細かい機能については理解できていないので、間違い等あれば指摘していただければと思います。
本記事を1つにまとめるとかなりのボリュームとなってしまうため、2部構成となっております。本記事は第1部目です。第2部目は以下のリンクからご参照ください。
[【Rails6】ActionCableを用いたリアルタイムグループチャット機能② ~グループ機能導入→完成まで~](https://qiita.com/kengoyam72/private/ea4271a84ab0591a2450)

#参考記事
[【Rails6.0】ActionCableとDeviseの欲張りセットでリアルタイムチャット作成(改正版)](https://qiita.com/rhiroe/items/4c4e983e34a44c5ace27)
[【Rails6.0】ActionCableを使用したライブチャットアプリを実装する手順を解説](https://techtechmedia.com/action-cable-rails6/)
[ DOM Keyboardイベントで押されたキーを判別するにはkeyプロパティを使う](https://qiita.com/ledsun/items/fb2570cc1542039f4516)

#本題
今回作成していくために使っている機能は以下の通りです。

* ユーザー認証・・・Devise
* リアルタイムチャットの実現・・・Actioncable

また、基本的なCRUD機能のついたアプリの作成済みが前提となっております。

##作成順序
以下の手法で作成していきます。

1. チャット機能の作成
2. グループに参加するメンバーのみでチャットができるよう改良

第一部ではここまで行います。
第二部では、グループ作成→完成まで行います。

##開発環境
macOS Big Sur 11.4
Ruby2.7.4
Rails6.1.4.1
DB:sqlite3

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

```ruby:Gemfile
gem 'devise'
```
```:ターミナル
$ bundle install
```

##ユーザー認証
インストールしたDeviseを用いて、ユーザー認証を実現します。

```:ターミナル
$ rails generate devise:install
$ rails generate devise user
$ rails db:migrate
```
詳しいdeviseの仕様等は、検索して頂くと参考になる記事がたくさん出てくると思います。
サーバーを起動して、[http://localhost:3000/users/sign_in](http://localhost:3000/users/sign_in)にアクセスし、ログイン機能を確認してください。

##チャット機能の作成

###Messageモデルの作成
```:ターミナル
# string型のcontentカラムを持つMessageモデルを作成
$ rails g model message content:string
$ rails db:migrate
```

###roomsコントローラーの作成
```:ターミナル
# showアクションを持つroomsコントローラーを作成
$ rails g controller rooms show
```

###ルーティングの設定
```ruby:routes.rb
Rails.application.routes.draw do
  #deviseインストール時に自動的に追加される
  devise_for :users
  #roomsコントローラーのshowアクションに飛ぶリンクの作成
  get 'rooms/show' => 'rooms#show'
end
```

###showアクションの中身を追加
```ruby:app/controllers/rooms_controller.rb
class RoomsController < ApplicationController
  before_action :authenticate_user! # Deviseのログイン確認
  def show
    # メッセージ一覧を取得
    @messages = Message.all
  end
end
```

###roomsコントローラーのshowアクションに対するビューの作成
```erb:app/views/rooms/show.html.erb
<h1>Chat room</h1>
<div id='messages'>
  <%= render @messages %>
</div>
```
`render @messages`とは`render partial: 'messages/message', collection: @messages`の省略形です。曖昧な方は以下の記事を参考にしてください。

[【Rails基礎】ややこしい部分テンプレートの省略形について簡単にまとめてみた](https://techtechmedia.com/partial-template-rails/)

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

```erb:app/views/messages/_message.html.erb
<div class='message'>
  <p><%= message.content %></p>
</div>
```
この中の`message`変数の中に`@messages`のそれぞれのレコードが代入されている。

### 投稿データの登録

```:ターミナル
$ rails c
```
```rb:ruby
 Message.create! content: 'Hello world!!'
```
[http://localhost:3000/rooms/show](http://localhost:3000/rooms/show)にアクセスし、投稿したデータが表示されるか確認。

###Roomチャネルの作成

Actioncableの機能を使うためにチャネルを作成します。
そのためにjQueryを使用するための準備を行います。

```:ターミナル
$ yarn add jquery
```
```js: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;
```
```js:app/javascript/packs/application.js
// 追加
require('jquery');
```
デベロッパーツールのコンソールでバージョンが表示されたら準備OK。

```js:console
console.log($.fn.jquery);
// 3.6.0
```

Actioncableの設定、`speak`メソッドを持つ`room`チャネルを作成します。

```:ターミナル
$ rails g channel room speak
```

Actioncableの有効化、`routes.rb`に以下の事項を追記する。
※バージョンによっては記述しなくても大丈夫。

```ruby:config/routes.rb
# 追加
mount ActionCable.server => '/cable'
```

`room_channel.rb`を編集する。

* subscribedメソッドのコメントアウトを外し、"room_channel"をstreamする。
* speakメソッドに引数を用意し、中身を追加する。

```ruby:app/channels/room_channel.rb
class RoomChannel < ApplicationCable::Channel
  def subscribed
    # room_channel.rbとroom_channel.jsでデータの送受信ができるようになる。
    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を実行させるために`room_channel.js`でspeak関数を定義する。

```js:app/javascript/channels/room_channel.js
import consumer from "./consumer"

// 「const chatChannel =」を追記
const chatChannel = consumer.subscriptions.create("RoomChannel", {
  // 省略

  // room_channel.rbでブロードキャストされたデータがreceivedに届き、アラート表示を実行。
  // アラート表示する内容は「data([‘message’])」
  // 「event.target.value」で取得したデータと同じ
  received(data) {
    return alert(data['message']);
  },

  // 仮引数 function(message)のmessage
  // 実引数 event.target.value
  // room_channel.rbのspeakアクションを動かすために、speak関数を定義
  speak: function(message) {
    return this.perform('speak', {message: message});
  }
});

// フォーム内でEnterキーが押された時の動作を記述
// event.KeyCode === 13は非推奨となっているため、event.key === 'Enter'と変更
$(document).on('keypress', '[data-behavior~=room_speaker]', function(event) {
  if (event.key === 'Enter') {
    chatChannel.speak(event.target.value);
    event.target.value = '';
    event.preventDefault();
  }
})
```
サーバーを起動し、テキストボックスに文字を入力、Enterキーを押してアラートが出ればOK.

###入力したテキストがデータベースに保存されるように改善

`room_channel.rb`の`speak`アクションの記述を以下のように変更.

```ruby:app/channels/room_channel.rb
class RoomChannel < ApplicationCable::Channel

  #省略
  def speak(data)
    #ActionCable.server.broadcast 'room_channel', message: data['message']
    #上記の文を変更
    Message.create! content: data['message']
  end

end
```

Broadcastのjobを作成

```:ターミナル
$ rails g job MessageBroadcast
```

作成したjobを編集し、ブロードキャスト処理を追加

```ruby:app/jobs/message_broadcast_job.rb
class MessageBroadcastJob < ApplicationJob
  #省略

  # ブロードキャスト(一つのネットワークの中にあるすべてのホストに対してデータを送る。)
  def perform(message)
    ActionCable.server.broadcast 'room_channel', message: render_message(message)
  end

  # app/views/message/_message.html.erbを呼び出す。
  private
  def render_message(message)
    ApplicationController.renderer.render(partial: 'messages/message', locals: { message: message })
  end
end
```

Messageモデルを編集。データ保存後の処理を指定。

```ruby:app/models/message.rb
class Message < ApplicationRecord
  validates :content, presence: true
  # データ保存後にMessageBroadcastJobのperformメソッドを実行,引数はself
  after_create_commit { MessageBroadcastJob.perform_later self }
end
```

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

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

`room_channel.js`の変更。

```js:app/javascript/channels/room_channel.js
  received: function(data) {
    return $('#messages').append(data['message']);
  }
```

実際にメッセージを送信して確認。これでチャット機能の実装は終了です。

##複数のルームとメッセージの紐付け
ここから改良して、複数ルームのそれぞれのルームに所属している人のみにチャットが見れるようにします。

```:ターミナル
$ rails g model room
$ rails db:migrate
```

messagesテーブルに紐付けのためのカラムを追加します。

```:ターミナル
$ rails g migration AddReferencesToMessages user:references room:references
```

[NotNull制約](https://qiita.com/wonder_meet/items/fa804f0d436a29c97460)でうまくいかなかったので以下のようにMigrationファイルを手直し
この解決策をどなたかにご教示して頂きたいです。

```ruby:db/migrate/マイグレーションファイル
class AddReferencesToMessages < ActiveRecord::Migration[6.1]
  def change
    add_reference :messages, :user, foreign_key: true
    add_reference :messages, :room, foreign_key: true
  end
end
```
```:ターミナル
$ rails db:migrate
```

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

```rb:app/models/user.rb
class User < ApplicationRecord

  # ...

  has_many :messages
end
```
```rb:app/models/room.rb
class Room < ApplicationRecord
  has_many :messages
end
```
```rb:app/models/message.rb
class Message < ApplicationRecord

  # ...

  belongs_to :user
  belongs_to :room
end
```

参考：アソシエーションはuserとmessageが1:N、roomとmessageが1:N
![スクリーンショット 2021-09-10 2.01.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1898687/c4a0d4d2-b1de-1f33-1f7c-360ace625089.png)

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

###Routing

```rb:routes.rb
Rails.application.routes.draw do
  mount ActionCable.server => '/cable'
  devise_for :users

  # ...

  resources :rooms
end
```
###Controller

```rb:app/controllers/rooms_controller.rb
class RoomsController < ApplicationController

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

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

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

```erb:app/views/rooms/show.html.erb
<%#ここの data-room_id を使ってjs側で部屋を見分ける %>
<div id='messages' data-room_id="<%= @room.id %>">
  <%= render @messages %>
</div>

<form>
  <%= label_tag :content, 'Say something:' %>
  <input type="text" data-behavior="room_speaker">
</form>
```

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

```erb:app/views/messages/_message.html.erb
  <div class='message'>
    <p><%= "#{message.user.username}: #{message.content}" %></p>
  </div>
```

Room一覧画面を追加

```erb:app/views/rooms/index.html.erb
<h1>Real part</h1>
<div>
  <ul>
    <% @room_lists.each do |room| %>
      <li><%= link_to "ROOM#{room.id}", room_path(room) %></li>
    <% end %>
  </ul>
</div>
```

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

```js:app/javascript/channels/room_channel.js
import consumer from "./consumer"

// $(function() { ... }); で囲むことでレンダリング後に実行される
// レンダリング前に実行されると $('#messages').data('room_id') が取得できない
// turbolinks を使っている場合は $(document).on('turbolinks:load', function() { ... }); で囲うorturbolinksの設定を無効にする。
$(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.key === 'Enter') {
        chatChannel.speak(event.target.value);
        event.target.value = '';
        return event.preventDefault();
      }
    });
});
```
```rb:app/channels/room_channel.rb
class RoomChannel < ApplicationCable::Channel
  
  def subscribed
    stream_from "room_channel_#{params['room']}" 
  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']
  end

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

```rb: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を使っている場合、ログインユーザーのインスタンスには以下でアクセスできる。

```rb
env['warden'].user
```
これを使ってcurrent_userをWebsocket側で使うためのコードを書く

```rb:app/channels/application_cable/connection.rb
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
```

#完成
以上で第一部の実装が完了となります。第二部ではグループチャットの部分を仕上げて最終完成まで持っていきます。↓
[【Rails6】ActionCableを用いたリアルタイムグループチャット機能② ~グループ機能導入→完成まで~](https://qiita.com/kengoyam72/private/ea4271a84ab0591a2450)