79
79

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2018-02-04

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

Ruby on Rails チュートリアル
https://railstutorial.jp/

#Sample Appの拡張
チュートリアルの最後には、作成したSampleAppの拡張機能についていくつかのヒントが記載されています。
その中の以下の機能を順に実装していきます。

  1. ユーザー検索
  2. マイクロポスト検索
  3. フォロワーの通知
  4. 返信機能
  5. メッセージ機能

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

  • メッセージは、受信者(送信先ユーザー)のページで送信、確認する

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

前回(返信機能)はこちら

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

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

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

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の関連付け部分です。

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

そして、Userです。

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を設定します。

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に、メッセージを送信するためのメソッドを追記します。

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のところで少し追記しますが。。)

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

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

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

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

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アクションのビューへの追加を行います。

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

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

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に渡しています。

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

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に少し追加します。

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

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

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

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

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

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

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_iduser_idroom_idは、ビューの中にセットした値を取得しているだけです。
room_chは、ルームIDが指定されているかをチェックし、指定されていればハッシュを返し、されていなければnullを返します。
message_heightは、ビューでやっていた処理と同じで、メッセージの高さの合計を取得しています。
これは、新しいメッセージが表示された時、自動的にスクロールさせるためです。

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

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

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

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

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

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モデルに戻り、コールバックを追加します。

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

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

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

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

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が良さそうでしたが、試行錯誤する気力がありませんでした。。。

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

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

79
79
7

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
79
79

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?