#Ruby on Rails チュートリアルについて
Ruby on Railsを勉強したいというと、まず紹介される有名なRailsのチュートリアル。
内容はハードですが、無料でRailsによるWebアプリケーション開発を楽しく学べます。
Ruby on Rails チュートリアル
https://railstutorial.jp/
#Sample Appの拡張
チュートリアルの最後には、作成したSampleAppの拡張機能についていくつかのヒントが記載されています。
その中の以下の機能を順に実装していきます。
- ユーザー検索
- マイクロポスト検索
- フォロワーの通知
- 返信機能
- メッセージ機能
#メッセージ機能
今回は、5つ目のメッセージ機能の実装を行います。(ラストです)
メッセージ機能は下記の仕様で作っていきます。
- メッセージは、受信者(送信先ユーザー)のページで送信、確認する
例: ID6のユーザーであれば、urlは〜/users/6 - メッセージは、送信者と受信者にリアルタイムで表示される
- メッセージは最大500件、最新のものが表示される
- メッセージは最大50文字まで(メッセージ送信時に警告を出す)
- 画像の送信はできない(できるようにしたかった。。。)
前回(返信機能)はこちら
##環境と準備
今回は、特に新たにインストールする必要のあるモジュールはありません。
モジュール | バージョン |
---|---|
Rails | 5.1.2 |
Ruby | 2.3.1 |
##実装
今回は少し長いので、区切ります。
###Messageモデル
メッセージ機能の実装はActionCableを利用して行いますが、まずはモデルの準備から行います。
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の関連付け部分です。
belongs_to :from, class_name: "User"
belongs_to :to, class_name: "User"
そして、Userです。
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を設定します。
# 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に、メッセージを送信するためのメソッドを追記します。
# 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で下記を実現する必要があります。
- 送信者と受信者に紐づけられたメッセージを取得し表示する
- メッセージを送信する
まずはメッセージの取得を実装します。
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アクションのビューへの追加を行います。
<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を埋め込んでるからですね。。。
やっていることは、メッセージ全部の高さを合計して、スクロール開始位置を一番下に指定しています。
なぜこんなことをするかというと、一番新しいメッセージが一番下に表示されるため、
ページがロードされた時に一番下にスクロールしておいて欲しいからです。
やり方はスマートではありませんが、これしか思いつきませんでした。。。(いい方法知っている方、教えていただけると嬉しいです)
次に、メッセージ一つ一つを表示するためのパーシャルを作成します。
<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に渡しています。
次にメッセージ送信用のフォームを作成します。
<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
に少し追加します。
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;
}
コントローラーとビューもこれで完成です。
###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
にクライアントサイドの処理を記載していきます。
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つはイベントの登録をしています。(ここは難しいので、こちらを参考にさせていただきました)
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
の実装を行います
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モデルに戻り、コールバックを追加します。
# Callbacks
after_create_commit { MessageBroadcastJob.perform_later self }
なぜそんなことをしているのかというと、新規のメッセージが作成された際、送信者と受信者にリアルタイムにメッセージがブロードキャストされて欲しいからです。
ここでは、ジョブを利用してこの機能を実装しています。
以下のコマンドを使用してブロードキャストを実行するジョブを作成します。
rails g job MessageBroadcast
コマンドを実行すると、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モデルのテストのみを実施しています。(すいません)
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