[Rails]ActionCableを使用してリアルタイムチャットの実装

## 実装すること
ActionCableを使用して特定のユーザーとリアルタイムチャットをできるようにしていきます。

## 完成イメージ

<img width="823" alt="チャットイメージ図.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/539971/88687ca6-a3f1-165d-fe1c-104dc385a918.png">


## ActionCableとは
RailsにおいてWebsocketによる双方向通信を実現してくれているものです。
(Webにおいて双方向通信を低コストで行うための仕組み)
参考：Railsガイドhttps://railsguides.jp/action_cable_overview.html

## ER図
「EntryテーブルとDirectMessageテーブル」が「UserテーブルとRoomテーブル」の中間テーブルになっています。

<img width="553" alt="チャットER図.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/539971/3484635f-b3ec-512c-bde6-d1c3f52f861c.png">

## ページ設計
①ユーザーの詳細ページ(users/show)から新規でダイレクトメッセージを送ることができる。
②チャットルーム(rooms/show)に入ると、
(1)メッセージフォームまでスクロールされる(新着メッセージは下に蓄積されていくため)
(2)チャットルームでは、ログインユーザーが右に相手が左に表示される
③チャットルーム一覧(rooms/index)はヘッダーのリンクから飛べる

## Gemの追記
jQueryを使えるようにします。

```Gemfile.
gem 'jquery-rails'
```

```app/assets/javascripts/application.js
//
//= require rails-ujs
//= require activestorage
//= require turbolinks
//= require jquery ←追記
//= require jquery_ujs ←追記
//= require_tree .
```

```
$ bundle install
```

## モデルの作成
Userモデルはデバイスを元に作成したものとします。
作成手順は下記リンク先で説明しております。
[Rails]Ajaxを用いて非同期で投稿機能といいね機能の実装https://qiita.com/yuto_1014/items/78d8b52d33a12ec33448

それではRoomモデル、Entryモデル、DirectMessageモデルを作成していきます。

```
$ rails g model Room name:string
$ rails g model Entry user_id:integer room_id:integer
$ rails g model DirectMessage content:string user_id:integer room_id:integer
```

## アソシエーションの確認
多対多の関連がある時は`throughオプション`をつけてあげると中間テーブルを経由して関連先のモデルを取得できるようになります。

### Userモデル
```app/models/user.rb

   has_many :entries
   has_many :direct_messages
   has_many :rooms, through: :entries
```

### Roomモデル
`dependent: :destroy`で、roomの特定のレコードが消えれば、そのレコードの紐づいたentryレコードとdirect_messageレコードも消えるようにしています。

```app/models/room.rb

   has_many :entries, dependent: :destroy
   has_many :direct_messages, dependent: :destroy
   has_many :users, through: :entries
```

### Entryモデル

```app/models/room.rb

  belongs_to :user
  belongs_to :room
```

### DirectMessageモデル
データが作成されたら非同期でブロードキャスト処理を実行するようにします。
`after_create`ではなく、`after_create_commit`を使っている点に注意してください。
トランザクションをコミットしたあとでブロードキャストしないと、他のクライアントからデータが見えない恐れがあります。


```app/models/direct_message.rb

  belongs_to :user
  belongs_to :room
  #ブロードキャスト
  after_create_commit { DirectMessageBroadcastJob.perform_later self }
```

## コントローラーの作成

```
$ rails g controller users
$ rails g controller rooms
```

## ルーティングの作成

```config/routes.rb
 devise_for :users
 resources :users
 resources :rooms
```

## ユーザー詳細ページ(コントローラー・ビューの編集)

### users_controller
```app/controllers/users_controller.rb

    def show
		@user = User.find(params[:id])
        #チャット
		if user_signed_in?
			#Entry内のuser_idがcurrent_userと同じEntry
			@currentUserEntry = Entry.where(user_id: current_user.id)
			#Entry内のuser_idがMYPAGEのparams.idと同じEntry
		    @userEntry = Entry.where(user_id: @user.id)
		    	#@user.idとcurrent_user.idが同じでなければ
			    unless @user.id == current_user.id
			      @currentUserEntry.each do |cu|
			        @userEntry.each do |u|
			          #もしcurrent_user側のルームidと＠user側のルームidが同じであれば存在するルームに飛ぶ
			          if cu.room_id == u.room_id then
			            @isRoom = true
			            @roomId = cu.room_id
			          end
			        end
			      end
			      #ルームが存在していなければルームとエントリーを作成する
			      unless @isRoom
			        @room = Room.new
			        @entry = Entry.new
			      end
			    end
	  	end
    end
```

### ユーザー詳細ページ(users/show.html.erb)
・`<% if @isRoom == true %>`で、roomが存在していれば既存のroomへ飛び、まだroomが存在していなければroomを作成するようにしています。
・`<%= fields_for @entry do |e| %>`で、roomと同時にentryも作成するようにしています。

```app/views/users/show.html
<% if user_signed_in? %>
    <% unless @user.id == current_user.id %>
        <!-- 既にroomが存在していれば既存のroomへ -->
        <% if @isRoom == true %>
            <!-- メールアイコンでroomに飛べるようにする -->
	        <%= link_to room_path(@roomId) do %>
	            <button id="dm_submit"><i class="fas fa-envelope"></i></button>
	        <% end %>
        <% else %>
            <!-- roomが存在していなければroomを作成する  -->
	        <%= form_for @room, url: rooms_path do |f| %>
	            <%= fields_for @entry do |e| %>
	               <%= e.hidden_field :user_id, :value=> @user.id %>
	            <% end %>
	            <button type="submit" id="dm_submit"><i class="fas fa-envelope"></i></button>
	        <% end %>
	    <% end %>
    <% end %>
<% end %>
```

## ルームの作成・閲覧ページ(コントローラー・ビューの編集)

### rooms_controller
・**『index』**では、ログインユーザーのroom一覧の相手の名前を表示しています。
ログインユーザーが所属しているroomの中でログインユーザー以外のユーザー名を取って来ています。
・**『show』**は、roomの詳細を表示しています。
roomが存在していれば、そのroomに紐づいたdirect_messagesとentriesを取ってきます。
・**『create』**で、roomを作成します。
roomの作成と同時に、そのroomに紐づいたentryユーザーを2名(ログインユーザーとユーザー詳細ページのユーザー)を作成します。
・**『destroy』**で、roomを削除します。

```app/controllers/rooms_controller.rb
  def index
    @user = current_user
    @currentEntries = current_user.entries
    #@currentEntriesのルームを配列にする
    myRoomIds = []
    @currentEntries.each do |entry|
      myRoomIds << entry.room.id
    end
    #@currentEntriesのルーム且つcurrent_userでないEntryを新着順で取ってくる
    @anotherEntries = Entry.where(room_id: myRoomIds).where.not(user_id: @user.id).order(created_at: :desc)
  end

  def show
    @room = Room.find(params[:id])
    #ルームが作成されているかどうか
    if Entry.where(:user_id => current_user.id, :room_id => @room.id).present?
      @direct_messages = @room.direct_messages
      @entries = @room.entries
    else
      redirect_back(fallback_location: root_path)
    end
  end

  def create
    @room = Room.create(:name => "DM")
    #entryにログインユーザーを作成
    @entry1 = Entry.create(:room_id => @room.id, :user_id => current_user.id)
    #entryにparamsユーザーを作成
    @entry2 = Entry.create(params.require(:entry).permit(:user_id, :room_id).merge(:room_id => @room.id))
    redirect_to room_path(@room.id)
  end

  def destroy
      room = Room.find(params[:id])
      room.destroy
      redirect_to users_rooms_path
  end
```

### ルーム一覧(rooms/index.html.erb)
<img width="612" alt="チャットメッセージ一覧.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/539971/cbc5d39a-26ef-b357-6120-b157eb8424c9.png">


```app/views/rooms/index.html

<h2>メッセージ一覧</h2>
<% @anotherEntries.each do |e| %>
    <table class="table table-striped">
        <tr>
 	        <td>
　　　　　　　　　<!-- 名前からroomの詳細に飛べるようにリンク化 -->
	            <%= link_to room_path(e.room.id) do %>
		            <%= attachment_image_tag e.user, :profile_image, format: 'jpeg', class: "rounded-circle", fallback: "no_image.jpg", size: "30x30" %>
			        <%= e.user.name %>
		        <% end %>
		    </td>
		    <td>
               <!-- 最新メッセージ内容の最初の7文字を表示する -->
			    <% dm = DirectMessage.find_by(id: e.room.direct_message_ids.last).content %>
			    <%= truncate(dm, length: 10) %>
		    </td>
		    <td>
		        <h6 style="color: #C0C0C0;"><%= e.updated_at.strftime("%Y/%m/%d %H:%M") %></h6>
		    </td>
		    <td>
               <!-- ゴミ箱アイコンクリックでroomを消せるようにする -->
			    <%= link_to room_path(e.room.id), method: :delete do %>
				    <i class="fas fa-trash" style="color: black;"></i>
			    <% end %>
		    </td>
	    </tr>
	</table>
<% end %>
```

## ルーム詳細(rooms/show.html.erb)

<img width="823" alt="チャットイメージ図.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/539971/36dd02b4-5150-5f48-43ef-246794b3803f.png">

```app/views/rooms/show.html
<% @entries.each do |e| %>
   <!-- eachで回したentryユーザーがログインユーザーであれば、@classに"current_user"の文字列を代入する。ログインユーザーであれば、空白を代入する -->
    <% current_user == e.user ? @class = "current_user" : @class = ""  %>
       <!-- eachで回したentryユーザーがログインユーザーでなければ、entryユーザー名を表示する -->
        <% if e.user != current_user %>
	       <h5 class="text-left <%= @class %>" id="<%= @class %>" style="font-size: 30px;" data-id="<%= e.user.id %>"><%= link_to "@#{e.user.name}", user_path(e.user_id) %></h5>
        <% else %>
           <h5 class="text-left <%= @class %>" id="<%= @class %>" data-id="<%= e.user.id %>"></h5>
        <% end %>
<% end %>
<!-- メッセージ内容は、パーシャルにします -->
<div id="direct_messages" data-room_id="<%= @room.id %>">
    <%= render @direct_messages %>
</div>
<!-- メッセージフォーム -->
<form>
    <label style="color: white;" id="target">新しいメッセージを作成</label><br>
    <input type="text" id="chat-input" data-behavior="room_speaker" class="form-control">
</form>

<script>
	//トークルーム遷移時に入力フォーム記載場所にスクロールする(最新トークは下にあるため)
	var element = document.getElementById('target'); // 移動させたい位置の要素を取得
    var rect = element.getBoundingClientRect();
    var position = rect.top;
    setTimeout( function() {
  		scrollTo(0, position);}
		, 1000);
</script>
```


### メッセージ内容(direct_messages/_direct_message.html.erb)

```app/views/direct_messages/_direct_message.html
<p class="<%= direct_message.id %>" style= "color: white; margin-top: 0;">
	<%= attachment_image_tag direct_message.user, :profile_image, fallback: "no_image.jpg", class:"profile-img-circle", size: "40x40" %>
	<%= direct_message.user.name %>
</p>
<p class="<%= direct_message.id %>">
	<span class="balloon1-top">
		<h7 class="dm_content"><%= direct_message.content %></h7><br>
		<h7 style="color: #C0C0C0;"><%= direct_message.created_at.strftime("%Y/%m/%d %H:%M") %></h7>
	</span>
</p><br>

<!-- メッセージの表示位置を指定する -->
<script>
    //メッセージのユーザーidを取ってくる
	var direct_message = <%= direct_message.user.id %>;
    //id="current_user"の内容を取得
	var dm_user = document.getElementById('current_user');
    //dm_userのdata-idを取得する
	var current_user = dm_user.getAttribute('data-id')
    //dm_userがログインユーザーであれば右に表示する
	if(direct_message == current_user){
	  $('p.' + <%= direct_message.id %>).css('text-align', 'right');
	}else{
	  $('p.' + <%= direct_message.id %>).css('text-align', 'left');
	}
</script>

```

## roomチャンネルの作成
各ユーザーは、複数のケーブルチャネルにサブスクライブできます。各チャネルには機能の論理的な単位がカプセル化され、そこで行われることは、コントローラが通常のMVPセットアップで行うことと似ています。
参考:Railsガイドhttps://railsguides.jp/action_cable_overview.html

今回はspeakメソッドを持っているroomチャネルを作成します。

```
$ rails g channel room speak
```
これを実行することで下記のファイルが作成されます。
・`room_channel.rb`に、サーバー側の記述を記載します。
・`room.coffee`に、クライアント側の記述を記載します。

```
app/channels/room_channel.rb

app/assets/javascripts/channels/room.coffee
```

### room_channel
・`speakメソッド`で、メッセージ内容(content)、user_id、room_idを作成します。

```app/channels/room_channel.rb
class RoomChannel < ApplicationCable::Channel
  #接続されたとき
  def subscribed
    # stream_from "some_channel"
    stream_from "room_channel_#{params['room']}"
  end
#切断されたとき
  def unsubscribed
    # Any cleanup needed when channel is unsubscribed
  end

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

### room.coffee

```app/assets/javascripts/channels/room.coffee
document.addEventListener 'turbolinks:load', ->
    if App.room
      App.cable.subscriptions.remove App.room
    App.room = App.cable.subscriptions.create { channel: "RoomChannel", room: $('#direct_messages').data('room_id') },
      #通信が確立された時
      connected: ->
      #通信が切断された時
      disconnected: ->
      #値を受け取った時
      received: (data) ->
        #投稿を追加
        $('#direct_messages').append data['direct_message']
      #サーバーサイドのspeakアクションにdirect_messageパラメータを渡す
      speak: (direct_message) ->
        @perform 'speak', direct_message: direct_message
    $('#chat-input').on 'keypress', (event) ->
      #return キーのキーコードが13
      if event.keyCode is 13
        #speakメソッド,event.target.valueを引数に.
        App.room.speak event.target.value
        event.target.value = ''
        event.preventDefault()
```

## jobの作成・編集
非同期でブロードキャストするためのDirectMessageBroadcastジョブを作成します。

```
$ rails g job DirectMessageBroadcast 
```

### direct_message_broadcast_job.rb
`performメソッド`でブロードキャストを実行します。
このとき、direct_messageに単純な文字列ではなく、direct_messages/direct_messagパーシャルのHTMLを返しています。
ApplicationController.renderer.renderメソッドを使うと、コントローラ以外の場所でビューをレンダリングできます。

```app/jobs/direct_message_broadcast_job.rb
class DirectMessageBroadcastJob < ApplicationJob
  queue_as :default

	def perform(direct_message)
	  ActionCable.server.broadcast "room_channel_#{direct_message.room_id}", direct_message: render_direct_message(direct_message)
	end

	private

    def render_direct_message(direct_message)
      ApplicationController.renderer.render partial: 'direct_messages/direct_message', locals: { direct_message: direct_message }
    end
end

```

### ログインユーザーの情報を取得する(connection.rb)
ApplicationCable::Connectionクラスを使って、認証情報を定義します。
identified_byはコネクションを識別するキーとなるものです。connectメソッドはコネクションの接続時に呼ばれるメソッドです。ここではコネクションの識別キーとして、ログイン時に設定したCookieからuser_idを取り出しています。

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

    protected
    def find_verified_user
    	if verified_user = User.find_by(id: cookies.signed['user.id'])
          verified_user
        else
          reject_unauthorized_connection
        end
    end
  end
end
```

## 最後に
最後までご覧いただきありがとうございます。
初学者ですので間違っていたり、分かりづらい部分もあるかと思います。
何かお気付きの点がございましたら、お気軽にコメントいただけると幸いです。


## 参考
ActionCableを用いてリアルタイムチャットの実装
https://freecamp.life/rails-realtimechat/
Rails 5 + ActionCableで作る！シンプルなチャットアプリ（DHH氏のデモ動画より）
https://qiita.com/bisque33/items/1360477c2260b361ec03
[Rails5]Action Cableのサンプルを読み解いてみる
https://qiita.com/bisque33/items/1360477c2260b361ec03
リアルタイムチャットは誰でもつくれる~Action CableでDM機能を作ろう~
https://qiita.com/OgawaNorihiro/items/6d9f85d8e89d1def4f15
【Ruby on Rails】DM機能でDM相手の一覧ページを作成！
https://novice-programmer.com/dm_index/
Railsガイド　Action Cable の概要
https://railsguides.jp/action_cable_overview.html