Ruby on Rails チュートリアル 機能拡張5(メッセージ機能)

#Ruby on Rails チュートリアルについて
Ruby on Railsを勉強したいというと、まず紹介される有名なRailsのチュートリアル。
内容はハードですが、無料でRailsによるWebアプリケーション開発を楽しく学べます。<br>
**Ruby on Rails チュートリアル**
https://railstutorial.jp/

#Sample Appの拡張
チュートリアルの最後には、作成したSampleAppの拡張機能についていくつかのヒントが記載されています。
その中の以下の機能を順に実装していきます。
1. ユーザー検索
2. マイクロポスト検索
3. フォロワーの通知
4. 返信機能
5. メッセージ機能

#メッセージ機能
今回は、5つ目のメッセージ機能の実装を行います。(ラストです)
メッセージ機能は下記の仕様で作っていきます。

- メッセージは、受信者(送信先ユーザー)のページで送信、確認する<br>
  例： ID6のユーザーであれば、urlは〜/users/6
- メッセージは、送信者と受信者にリアルタイムで表示される
- メッセージは最大500件、最新のものが表示される
- メッセージは最大50文字まで(メッセージ送信時に警告を出す)
- 画像の送信はできない(できるようにしたかった。。。)

前回(返信機能)は[こちら](https://qiita.com/YN6127yn/items/f701474d2c0276b52bfb)

##環境と準備
今回は、特に新たにインストールする必要のあるモジュールはありません。

|モジュール   |  バージョン |  
|:----------|-----------:|
| Rails     |      5.1.2 |
| Ruby      |      2.3.1 |

##実装
今回は少し長いので、区切ります。
###Messageモデル
メッセージ機能の実装はActionCableを利用して行いますが、まずはモデルの準備から行います。

```ruby:db/migrate/~.rb
def change
  create_table :messages do |t|
    t.text :content
    t.integer :from_id
    t.integer :to_id
    t.string :room_id
    
    t.timestamps
  end
  add_index :messages, [:room_id, :created_at]
end
```

Messageモデルの中身は以下の通りです。

|属性名    |内容           | 
|:---------|:-------------|
| content  |メッセージの内容 |
| from_id  |送信者のID     |
| to_id    |受信者のID     |
| room_id  |ルームID     |

ルームIDは、表示するユーザーを限定するために使っています。
また、ルームチャットではなくDMのみのため、Roomモデルは作りません。

上記をマイグレートしたら、次にUserとMessageの関連付けを行います。
モデル同士の関連付けは混乱しそうになることもありますが、
今回はチュートリアル内で作成したRelationshipモデルと同じようにします。

まずは、Messageの関連付け部分です。

```ruby:app/models/message.rb
belongs_to :from, class_name: "User"
belongs_to :to, class_name: "User"
```

そして、Userです。

```ruby:app/models/user.rb
has_many :from_messages, class_name: "Message",
          foreign_key: "from_id", dependent: :destroy
has_many :to_messages, class_name: "Message",
          foreign_key: "to_id", dependent: :destroy
has_many :sent_messages, through: :from_messages, source: :from
has_many :received_messages, through: :to_messages, source: :to
```

関連付けができたので、scopeとvalidationを設定します。

```ruby:app/models/message.rb
# Scopes
default_scope -> {order(created_at: :asc)}

# Validations
validates :from_id, presence: true
validates :to_id, presence: true
validates :room_id, presence: true
validates :content, presence: true, length: {maximum: 50}

# Methods
def Message.recent_in_room(room_id)
  where(room_id: room_id).last(500)
end
```

`Message.recent_in_room`は、指定されたルームIDの新しいメッセージを最大500件取得します。

最後にUserに、メッセージを送信するためのメソッドを追記します。

```ruby:app/models/user.rb
# Send message to other user
def send_message(other_user, room_id, content)
  from_messages.create!(to_id: other_user.id, room_id: room_id, content: content)
end
```

モデルに関してはこれで完成です。([ActionCable](#actioncable)のところで少し追記しますが。。)

###コントローラーとビュー
モデルが完成したので、コントローラとビューの実装に移ります。

まずはコントローラーですが、受信者のページでメッセージを送信/表示するので、
UserController#showで下記を実現する必要があります。

 - 送信者と受信者に紐づけられたメッセージを取得し表示する
 - メッセージを送信する

まずはメッセージの取得を実装します。

```ruby:app/controllers/users_controller
def show
  # 省略
  @room_id = message_room_id(current_user, @user)
  @messages = Message.recent_in_room(@room_id)
end

# 省略

def message_room_id(first_user, second_user)
  first_id = first_user.id.to_i
  second_id = second_user.id.to_i
  if first_id < second_id
    "#{first_user.id}-#{second_user.id}"
  else
    "#{second_user.id}-#{first_user.id}"
  end
end
```

送信者と受信者のIDからルームIDを作成し、それを元にメッセージを取得します。
ルームIDは、ハイフンで両者のIDを連結しているだけです。
コントローラーの実装は実はこれだけです。

次にビューの実装を行います。
まず、showアクションのビューへの追加を行います。

```html+erb:app/views/users/show.html.erb
<aside class="col-md-4">
  <!-- 省略 -->
  <% if !current_user?(@user) && logged_in? %>
    <section class="message_box">
      <div id="messages">
        <%= render @messages %>
      </div>
    </section>
    <script type="text/javascript">
      var height = 0;
      $("div.message").each( function() {
        height += ($(this).height());
      });
      $('section.message_box').scrollTop(height);
    </script>
    <div class="message_form">
     <%= render 'messages/message_form',
       {from_user: current_user, to_user: @user, room_id: @room_id} %>
    </div>
  <% end %>
</aside>
```

`id="messages"`でメッセージ一覧を表示し、`class="message_form"`でメッセージ送信用のフォームを表示します。

複雑なのは、javascriptを埋め込んでるからですね。。。
やっていることは、メッセージ全部の高さを合計して、スクロール開始位置を一番下に指定しています。
なぜこんなことをするかというと、一番新しいメッセージが一番下に表示されるため、
ページがロードされた時に一番下にスクロールしておいて欲しいからです。
やり方はスマートではありませんが、これしか思いつきませんでした。。。(いい方法知っている方、教えていただけると嬉しいです)

次に、メッセージ一つ一つを表示するためのパーシャルを作成します。

```html+erb:app/views/messages/_message.html.erb
<div class="message" data-session="<%= session[:user_id] %>">
  <% user = User.find_by(id: message.from_id) %>
  <p class="<%= message.id %>" id="message_sender"><%= user.name %></p>
  <p class="<%= message.id %>" id="message_content"><%= message.content %></p>
  <script type="text/javascript">
    var from_id = <%= message.from_id %>;
    var current_user_id = $('.message').data('session');
    if(from_id == current_user_id){
      $('p.' + <%= message.id %>).css('text-align', 'right');
    }else{
      $('p.' + <%= message.id %>).css('text-align', 'left');
    }
  </script>
</div>
```

基本的には、メッセージの送信者の名前とメッセージを表示しているだけです。
複雑に見えるのは、やっぱりjavascriptを少し埋め込んでるからですね。(ちゃんと分ければ綺麗になるかな。。)
何をしているかというと、送信者か受信者かによってメッセージの表示位置を変えています。

自分が送信したメッセージは右側に、相手が送信したメッセージは左側に表示されるようにしています。
しかし、新規メッセージの追加は非同期に行うので、current_userなどが利用できません。(nilになります)
そのため、sessionのユーザーID(送信者のID)を変数として保持し、javascriptに渡しています。

次にメッセージ送信用のフォームを作成します。

```html+erb:app/views/messages/_message_form.html.erb
<form class="message_form">
  <input type="hidden" name="from_id" value="<%= from_user.id %>">
  <input type="hidden" name="to_id" value="<%= to_user.id %>">
  <input type="hidden" name="room_id" value="<%= room_id %>">
  <label>Send Message to <%= to_user.name %></label><br>
  <input type="text" name="content" data-behavior="chat_speaker">
</form>
```

上記コードの通り、form_forではなく、formを利用しています。
これは、ActionCableでメッセージの送受信を管理するためです。

ここで重要なのが、`data-behavior="chat_speaker"`の部分です。
なぜ重要なのかというと、ActionCableの実装を行う際、ここをチェックしてイベントを拾うからです。

ビューの最後として、`custom.scss`に少し追加します。

```scss:app/assets/stylesheets/custom.scss
section.message_box {
  padding: 10px;
  border: 1px solid $gray-light;
  overflow: scroll;
  #messages {
    height: 400px;
  }
}

div.message_form {
  padding-top: 10px;
}

#message_sender {
  font-size: 1.1em;
}

#message_content {
  font-size: 1.0em;
}
```

見た目はこんな感じです。
![メッセージ.png](https://qiita-image-store.s3.amazonaws.com/0/229468/b89cc957-4afc-d265-9503-77390853bbfe.png)

コントローラーとビューもこれで完成です。

###ActionCable
いよいよActionCableを使ったリアルタイムチャット機能を実装します。

まずは、Channelを下記のコマンドで作ります。
`rails g channel chat speak`
コマンドを実行すると、`app/channels/chat_channel.rb`と`app/assets/javascripts/channels/chat.coffee`というファイルが作られます。
`chat_channel.rb`にはサーバサイド、`chat.coffee`にはクライアントサイドの動きを記載します。

まず、ActionCableを利用するために以下を`route.rb`に追記します。
`mount ActionCable.server => '/cable'`

次に、`chat.coffee`にクライアントサイドの処理を記載していきます。

```coffeescript:app/assets/javascripts/channels/chat.coffee
App.chat = null

current_user_id = ->
  $('input:hidden[name="from_id"]').val()

user_id = ->
  $('input:hidden[name="to_id"]').val()

room_id = ->
  $('input:hidden[name="room_id"]').val()

room_ch = ->
  id = room_id()
  if id?
    return {channel: 'ChatChannel', room_id: id}
  else
    return null

messages_height = ->
  temp = 0;
  $("div.message").each ->
    temp += ($(this).height());
  return temp

document.addEventListener 'turbolinks:request-start', ->
  if room_ch()?
    App.chat.unsubscribe()

document.addEventListener 'turbolinks:load', ->
  if room_ch()?
    App.chat = App.cable.subscriptions.create room_ch(),
      received: (data) ->
        $('#messages').append data['message']
        $('section.message_box').scrollTop(messages_height());

      speak: (from_id, to_id, room_id, content) ->
        @perform 'speak', {
          "from_id": from_id
          "to_id": to_id
          "room_id": room_id
          "content": content
        }

$(document).on 'keypress', '[data-behavior~=chat_speaker]', (event) ->
  if event.which is 13
    value = event.target.value
    if value.replace(/\s/g, '').length > 0 && value.length <= 50
      App.chat.speak(current_user_id(), user_id(), room_id(), value)
      event.target.value = ''
      event.preventDefault()
    else if value.length > 50
      alert("Message should be less than 51 characters.")
      event.target.value = ''
      event.preventDefault()
    else
      event.target.value = ''
      event.preventDefault()
```

少し長いですが、処理自体はそんなに難しくありません。
まず、`current_user_id`、`user_id`、`room_id`は、ビューの中にセットした値を取得しているだけです。
`room_ch`は、ルームIDが指定されているかをチェックし、指定されていればハッシュを返し、されていなければnullを返します。
`message_height`は、ビューでやっていた処理と同じで、メッセージの高さの合計を取得しています。
これは、新しいメッセージが表示された時、自動的にスクロールさせるためです。

次の2つはイベントの登録をしています。(ここは難しいので、[こちら](https://qiita.com/61503891/items/bd199b0497528af1f49f)を参考にさせていただきました)
1つ目：ページが遷移した際、購読を解除(`App.chat.unsubscribe`)
2つ目：ページが読み込まれた際、購読を開始(`App.cable.subscriptions.create`)

購読開始後、`received`で受信時の処理、`speak`で送信時の処理を指定しています。(speakはChannel作成時に作りました)
`received`では、メッセージのビューへの追加とスクロールを行なっています。
メッセージの追加時には`messages`を指定し、`views/users/show`の`id="messages"`の最後に追加しています。
`speak`では、引数にハッシュを指定し、`@perform 'speak'`で`chat_channel.rb`のspeakメソッドを呼び出しています。

そして、最後にEnterキーが押下された際のイベントを登録しています。
`_message_form.html.erb`で指定した`data-behavior=chat_speaker`を条件とし、メッセージが空白でないことと50字以下であることをチェックしています。
チェックを通ったら、`App.chat.speak`で、上記の`speak`を呼び出します。

送信時の流れとしては、Enterキー押下→`chat.coffee`の`speak`呼び出し→`chat_channel.rb`の`speak`呼び出し→サーバでの諸々の処理っていうイメージですね。

次に`chat_channel.rb`の実装を行います

```ruby:app/channels/chat_channel.rb
def subscribed
  stream_from "chat_channel_#{params[:room_id]}"
end

def unsubscribed
end

def speak(data)
  from_user = User.find_by(id: data['from_id'].to_s)
  to_user = User.find_by(id: data['to_id'].to_s)
  from_user.send_message(to_user, data['room_id'], data['content'])
end
```

ここはかなり簡単な実装になっていると思います。
`subscribed`では、購読するルームをルームIDで指定しています。
`speak`では、送信者と受信者を探し、`User#send_message`を使って新規メッセージを作成しています。

次にMessageモデルに戻り、コールバックを追加します。

```ruby:app/models/message.rb
# Callbacks
after_create_commit { MessageBroadcastJob.perform_later self }
```

なぜそんなことをしているのかというと、新規のメッセージが作成された際、送信者と受信者にリアルタイムにメッセージがブロードキャストされて欲しいからです。
ここでは、ジョブを利用してこの機能を実装しています。

以下のコマンドを使用してブロードキャストを実行するジョブを作成します。
`rails g job MessageBroadcast`

コマンドを実行すると、`app/jobs/message_broadcast_job.rb`が作成されるのでブロードキャスト処理を追加していきます。

```ruby:app/jobs/message_broadcast_job.rb
def perform(message)
  ActionCable.server.broadcast "chat_channel_#{message.room_id}",
                               message: render_message(message)
end

private

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

`perform`メソッドが呼び出された際、引数には新規作成されたメッセージが指定されます。(Messageモデルで`self`と指定していました)
処理は、ルームIDからチャンネルを指定し、指定したチャンネルに対してメッセージを追加しています。
`ApplicationController.renderer.render`を利用して、コントローラー以外からビューをレンダリングしています。
正確には、`chat.coffee`に記載した`received`の引数として`_message.html.erb`が渡されます。

長い道程でしたが、これで実装完了です！

##テスト
最後にテストについて記載します。

ですが、ActionCableを使ったはいいものの、良いテスト方法がわかりませんでした。
[Action Cable Testing](https://github.com/palkan/action-cable-testing)が良さそうでしたが、試行錯誤する気力がありませんでした。。。

そのため、今回はMessageモデルのテストのみを実施しています。(すいません)

```ruby:test/models/message_test.rb
class MessageTest < ActiveSupport::TestCase
  def setup
    @message = Message.new(from_id: users(:hoge).id, to_id: users(:fuga).id,
                           room_id: "#{users(:hoge).id}-#{users(:fuga).id}",
                           content: "abcdefghijklmnopqrstuvwxyz")
  end

  test "should be valid" do
    assert @message.valid?
  end

  test "from id should be present" do
    @message.from_id = nil
    assert_not @message.valid?
  end

  test "to id should be present" do
    @message.to_id = nil
    assert_not @message.valid?
  end

  test "room id should be present" do
    @message.room_id = nil
    assert_not @message.valid?
  end

  test "content should be present" do
    @message.content = "  "
    assert_not @message.valid?
  end

  test "content should be at most 50 characters" do
    @message.content = "a" * 51
    assert_not @message.valid?
  end

  test "order should be most recent last" do
    assert_equal messages(:most_recent), Message.last
  end
end
```

テストが通ったことを確認して終了です。

#最後に
私はまだRails歴1ヶ月ほどであり、以上の実装/テストも私の環境で動いたということに過ぎません。
修正点や指摘等ございましたら、ぜひコメントお願いいたします。

今回のメッセージ機能では、無限スクロールが実装ができませんでした。
どうしても下から上にスクロールすることを実装できず諦めました。。。
方法をご存知の方はぜひ教えていただきたけたら幸いです。

#参考記事
メッセージ機能では、下記のページを参考にさせていただきました。

**Turbolinks利用時のActionCableのpub/sub管理**
https://qiita.com/61503891/items/bd199b0497528af1f49f

**Rails 5 + ActionCableで作る！シンプルなチャットアプリ（DHH氏のデモ動画より）**
https://qiita.com/jnchito/items/aec75fab42804287d71b#room%E3%83%81%E3%83%A3%E3%83%B3%E3%83%8D%E3%83%AB%E3%81%AE%E4%BD%9C%E6%88%90

**How to Access Ruby Session Variable in Javascript**
https://stackoverflow.com/questions/29790604/how-to-access-ruby-session-variable-in-javascript