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

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

# お知らせ
本内容とほとんど同等に動作するものをRails7で再実装しました。作りも荒く、まだ記事としての体裁を整えられていないので限定公開にしていますが、Rails7で開発している方はこちらを参考にしてください。

https://qiita.com/rhiroe/private/2121f96684581a44901e
https://github.com/rhiroe/actioncable_rails7

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

## 開発環境
Ruby 2.6.3
Rails 6.0.0

## 参考にした記事
・[Action Cableでリアルタイムチャットアプリの作成方法 （Rails 5.1.4にて）（その１）　herokuで動かす！](https://qiita.com/Hijiri-K/items/c3774c72a2cb68e1a720)
・[Rails 5 Action Cable メッセージとルームを紐付ける。](https://qiita.com/kohei1228/items/7aed5aad9c63e834c0e1)

## 完成形イメージ
下のような同じルーム内でのみリアルタイムで投稿内容が表示されるシンプルなアプリです。
![名称未設定.mov.gif](https://qiita-image-store.s3.amazonaws.com/0/262029/f8963451-bba3-493c-8e1e-2a1d0942b7e5.gif)

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

## アプリの作成
今回はchat_appという名前でアプリケーションを作成します。
まずは全てのユーザーが接続できるオープンなチャットアプリを作成していきます。
テストは作成しません。
DBはデフォルトのSQLiteを使います。

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

```bash:bash
$ mkdir chat_app
$ cd chat_app
$ bundle init
```
```rb: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: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を追加し、インストールします。

```rb:Gemfile
gem 'devise'
```
```bash:bash
$ bundle install
```
## ユーザー認証

続いてDeviseでサクッとユーザー認証を実現します。
Deviseについての細かな説明は割愛します。
少し検索すればとても参考になる記事がいくつも出てくると思うのでそちらを参考にしてください。

```bash: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](http://localhost:3000/users/sign_in)にアクセスし、ログイン機能を確認。

## チャット機能の作成

### Messageモデルを作成

```bash:bash
# text型のcontentカラムを持つMessageモデルを作成
$ rails g model message content:text
$ rails db:migrate
```

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

### ルーティングの設定
```rb:routes.rb
Rails.application.routes.draw do
  devise_for :users
  get 'rooms/show'
end
```

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

```rb:app/controllers/rooms_controller.rb
class RoomsController < ApplicationController
  before_action :authenticate_user! # Deviseのログイン確認

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

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

```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`の略です。
メッセージ毎に`messages/_message.html.erb`というパーシャルが適用されるようになります。

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

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

### 投稿データの登録

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

### Roomチャネルの作成
ActionCableの機能を使うためにチャネルを作成します。
その前にjQueryを使えるようにしておきましょう。

```bash:bash
$ 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.4.1
```

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

```bash:bash
$ rails g channel room speak
```

`app/channel/room_channel.rb`のsubscribedメソッドのコメントアウトを外し、"room_channel"をstreamする。
そしてspeakアクションに引数を用意し、中身を追加する

```rb: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関数を定義する。

```js:app/javascript/channels/room_channel.js
consumer.subscriptions.create("RoomChannel", {
  // ...
  // これが実行されるとコンシューマになったRoomChannel#speak({ message: message })が呼ばれる
  speak: function(message) {
    return this.perform('speak', {
      message: message
    });
  }
});
```

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

```js:app/javascript/channels/room_channel.js
consumer.subscriptions.create("RoomChannel", {
  // ...
  // room_channel.rbでブロードキャストされたものがここに届く
  received: function(data) {
    return alert(data['message']);
  },
  // ...
});
```

### フォームの作成

```erb: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' } %>
```

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

```js: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アクションの書き換え

```rb: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:bash
$ rails g job MessageBroadcast
```

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

```rb: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モデルを編集する

バリデーションとデータ作成後にジョブを実行させるよう追記する

```rb: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

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

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

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

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

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

### Roomモデルの作成

```bash:bash
$ rails g model room
$ rails db:migrate
```

###モデルの紐付け

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

```bash:bash
$ rails g migration AddUserRefAndRoomRefToMessages user:references room:references
```
うまいこと自動生成されてなかった場合は手直しする。

```rb:add_user_ref_and_room_ref_to_messages.rb
class AddUserRefAndRoomRefToMessages < 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なカラムをいきなり追加しようとするとエラーになるので小細工する。

```rb:add_user_ref_and_room_ref_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:bash
$ 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
```

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

### Controller

```rb: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
```

```rb: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

```erb: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つを表示するパーシャル

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

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

```erb: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を受け取り、監視する場所を分ける

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

// $(function() { ... }); で囲むことでレンダリング後に実行される
// レンダリング前に実行されると $('#messages').data('room_id') が取得できない
// turbolinks を使っている場合は $(document).on('turbolinks:load', function() { ... }); で囲う
$(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();
    }
  });
});
```

```rb: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
```

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

```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を使っている場合、ログインユーザーのIDはクッキーの以下に格納されている

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

また、ログインユーザーのインスタンスには以下でアクセスできる。

```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
```

##完成

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

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

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