この記事はプログラミング学習者がアプリ開発中に躓いた内容を備忘録として記事におこしたものです。内容に不備などあればご指摘頂けると助かります。
記事投稿の背景
Xのクローンサイトを制作している時にメッセージ機能の実装で色々と躓いたので、知識整理も兼ねて記事として残すことにしました。
実装時のER図
UserモデルとRoomモデルの間に二つの中間テーブルを作りました。
Roomモデルはチャットルーム(メッセージをやり取りする場所)となります。
※チャットルームについて以降はルームと表現します。
Entrieモデルの役割:どのユーザー(user_id)がどのルーム(room_id)に居るのかを保存する
Messageモデルの役割:どのユーザー(user_id)がどのルーム(room_id)でどんな発言(content)をしたかを保存する
実装したコードと解説
resources :rooms, only: %i[index show create] do
resources :messages, only: %i[new create]
end
RoomモデルとMessageモデルは関連があるので、routes.rbにもその関連性を落とし込みました。
これでroom/room_id/message
のようなURLができます。
この場合、rooms_controllerのcreateアクションではヘルパーメソッドが表示されません。
これは何故かというとindexアクションでrooms_pathというヘルパーメソッドを使用しており、これはcreateメソッドにも当てがわれるヘルパーメソッドと重複しているからです。重複しているかどうかはヘルパーメソッド横に表示されているPathを見れば分かります。createアクションとindexアクションのPathが全く同じになっています。
createアクションではヘルパーメソッドが表示されていませんが、indexアクションと同じrooms_pathというヘルパーメソッドを使用することはできます。HTTPメソッドがGET(index)とPOST(create)で異なるので、同じヘルパーメソッド名を使ってもそれぞれ正しく動作させることができます。
class Room < ApplicationRecord
has_many :entries, dependent: :destroy
has_many :users, through: :entries
has_many :messages, dependent: :destroy
validate :room_user_limit
def room_user_limit
return unless entries.size >= 2
errors.add(:room, 'このチャットルームには既に2人のユーザーが参加しています。')
end
end
has_many :users, through: :entries
という記述を入れることでroom.users
といった簡潔な書き方ができるようになります。
無い場合はroom.entries...
といった冗長な書き方になってしまいます。
ルームはログインユーザーとその他のユーザー1人の合計2人で使用する想定なので、ルームの人数が2人を超える場合はバリデーションで阻止する実装にしています。
has_many :entries, dependent: :destroy
has_many :rooms, through: :entries
has_many :messages, dependent: :destroy
こちらもhas_many :rooms, through: :entries
の記述を入れることでuser.rooms
といった簡潔な書き方を実現できるようにしています。
class Entrie < ApplicationRecord
belongs_to :user
belongs_to :room
validates :user_id, uniqueness: { scope: :room_id }
end
validates :user_id, uniqueness: { scope: :room_id }
の記述を入れることでuser_id
とroom_id
の組み合わせに一意性を持たせることができます。同じルームに同じユーザーの組み合わせが重複して入ることを防ぎます。
class Message < ApplicationRecord
belongs_to :user
belongs_to :room
validates :content, presence: true
end
content
はメッセージの内容です。空のメッセージは送付できないようにしています。
class RoomsController < ApplicationController
# 誰がどのルームに居るかを管理する
def create
@room = Room.create
@entrie = @room.entries.build(user_id: params[:user_id])
@entrie_login_user = @room.entries.build(user_id: current_user.id)
if @entrie.save && @entrie_login_user.save
redirect_to room_path(@room), notice: 'チャットルームに入りました。'
else
redirect_to home_index_path, alert: 'チャットルームの作成に失敗しました。'
end
end
def index
@rooms = Room.all.includes(:users, :messages)
end
def show
@room = Room.find(params[:id])
@messages = @room.messages.includes(:user)
@message = Message.new
end
end
create
アクションではどのユーザーがどのルームに入るのかを実装しています。
新規でチャットルームを作る場合、@room
インスタンス変数を先に作ってルームを決めて、
そこに紐づく形でアソシエーションを使いながら、ルームに入るユーザーを結びつけます。
show
アクションでは後述のshow.html.slim
に記述していますが、メッセージ投稿フォームをform_with
ヘルパーを使って実装しています。form_with
ヘルパーに空のインスタンス(@room
)を渡すことでメッセージ投稿用のcreate
アクションが存在することを認識させます。
class MessagesController < ApplicationController
# 誰がどのルームでどんな発言をしたかを管理する
def create
@room = Room.find(params[:room_id])
@message = @room.messages.build(user_id: current_user.id, content: params[:message][:content])
if @message.save
redirect_to room_path(@room), notice: 'メッセージが送信されました。'
else
redirect_to room_path(@room), alert: 'メッセージの送信に失敗しました。'
end
end
end
rooms_controllerのshowアクションにあった空の@message
を認識させることで、form_with
の機能としてmessages_controllerのcreateアクションへ繋がるようにしています。
body
= render partial: "layouts/nav"
.message-box
.entrance-rooms
h2
| メッセージ
br
- @rooms.each do |room|
- if current_user.present?
- if room.entries.exists?(user_id: current_user.id)
- other_user = room.users.reject { |user| user == current_user }.first
p = link_to other_user.name, room_path(room)
- if room.messages.present? && room.messages.first.content.present?
p = truncate(room.messages.first.content, length: 20)
hr
body
= render partial: "layouts/nav"
.message-box
h2
= link_to image_tag("arrow_back.png", alt: "Arrow back", size: "35x35"), rooms_path
- opponent = @room.users.reject { |user| user == current_user }.first
= "#{opponent.name}さんとのメッセージ"
br
- @messages.each do|message|
- if message.user == current_user
.box1
p.login_user = message.content
- else
.box2
p.the_other_user = message.content
br
.message-room
= form_with model: @message, url: room_messages_path(@room), local: true, data: { turbo: false } do |f|
ul
li
= f.text_field :content, placeholder: '新しいメッセージを作成', class: "message-form form-control", style: "border: none"
li
= f.submit '送信', class: 'btn btn-primary'
CSSでメッセージの表示について相手を左側の黒地、ログインユーザーを右側の青地にしています。
.tab-content
div class="#{'show active' if params[:tab] == 'recommend'} tab-pane fade " id="for-you"
- @tweets.each do |tweet|
= link_to tweet_path(tweet.id) do
- if tweet.retweets.exists?(user: current_user)
ul
li
= image_tag('repost.png', size: '20x20', class: 'titled_repost')
object = link_to 'あなたがリポストしました', user_path(current_user.id)
ul
li
object = link_to image_tag(tweet.user.icon, alt: "Icon image", class: "icon_image", size: '50x50'), profile_path(tweet.user.id)
li
object = link_to tweet.user.name, profile_path(tweet.user.id), class: 'link'
li 投稿日: #{tweet.created_at.strftime('%Y/%m/%d %H:%M:%S')}
- if current_user.present? # ツイートのユーザーとログインユーザーが共に所属するチャットルームが存在するかをデータベースから検索する
- room_with_tweet_user = check_rooms(tweet.user)
- if current_user.present? && tweet.user.name.include?(current_user.name)
li.dropdown
| ...
- elsif current_user.present? && tweet.user.followers.pluck(:user_id).include?(current_user.id)
li.dropdown
| ...
ul.sub-list
li.sub
object
= link_to "#{tweet.user.name}さんのフォローを解除する", unfollow_user_path(tweet.user), data: {turbo_method: :delete}
br
- if room_with_tweet_user.present? # 既に2人のチャットルームがある場合
= link_to "#{tweet.user.name}さんにメッセージを送る", room_path(room_with_tweet_user)
- else # 2人のチャットルームが無くて作成が必要な場合
= link_to "#{tweet.user.name}さんにメッセージを送る", rooms_path(user_id: tweet.user.id), data: {turbo_method: :post}
- elsif current_user.present? && !tweet.user.followers.pluck(:user_id).include?(current_user.id)
li.dropdown
| ...
ul.sub-list
li.sub
object
= link_to "#{tweet.user.name}さんをフォロー", follow_user_path(tweet.user), data: {turbo_method: :post}
br
- if room_with_tweet_user.present? # 既に2人のチャットルームがある場合
= link_to "#{tweet.user.name}さんにメッセージを送る", room_path(room_with_tweet_user)
- else # 2人のチャットルームが無くて作成が必要な場合
= link_to "#{tweet.user.name}さんにメッセージを送る", rooms_path(user_id: tweet.user.id), data: {turbo_method: :post}
p = tweet.content
- if tweet.images.attached?
- tweet.images.each do |tweet_image|
= image_tag tweet_image, size: '250x200', class: "tweet_image"
end
.icons
.comment
= link_to image_tag('comment.png', size: '26x26', class: "balloon"), tweet_path(tweet.id)
= tweet.comments.length
.repost
- if current_user.present? && tweet.retweets.exists?(user: current_user)
= link_to image_tag('repost.png', size: '26x26', class: "circled_arrow"), tweet_retweet_path(tweet_id: tweet.id, id: tweet.retweets.find_by(user: current_user).id), data: {turbo_method: :delete}
= tweet.retweets.length
- else
= link_to image_tag('repost.png', size: '26x26', class: "circled_arrow"), tweet_retweets_path(tweet_id: tweet.id), data: {turbo_method: :post}
= tweet.retweets.length
.favorite
- if current_user.present? && tweet.favorites.exists?(user: current_user)
= link_to image_tag('heart.png', size: '32x32'), tweet_favorite_path(tweet_id: tweet.id, id: tweet.favorites.find_by(user: current_user).id), data: {turbo_method: :delete}
= tweet.favorites.length
- else
= link_to image_tag('heart_with_hole.png', size: '32x32'), tweet_favorites_path(tweet_id: tweet.id), data: {turbo_method: :post}
= tweet.favorites.length
.bookmark
- if current_user.present? && tweet.bookmarks.exists?(user: current_user)
= link_to image_tag('bookmarked.png', size: '20x20'), tweet_bookmark_path(tweet_id: tweet.id, id: tweet.bookmarks.find_by(user: current_user).id), data: {turbo_method: :delete}
- else
= link_to image_tag('not_bookmark.png', size: '20x20'), tweet_bookmarks_path(tweet_id: tweet.id), data: {turbo_method: :post}
hr
= paginate @tweets, :param_name => 'recommend', params: {tab: 'recommend'}, theme: 'twitter-bootstrap-4'
room_with_tweet_user = check_rooms(tweet.user)
の所でログインユーザーと投稿者が入っているルームがあるかどうか判別しています。ロジック詳細はrooms_helperに切り出しました。当初はそれぞれのviewファイル(合計5ファイル,12箇所くらい)に書いていましたが、ちょっと変更を加えたい時も該当箇所が多くて大変でした。rooms_helperに切り出してからは変更は一箇所で済むようになりました。
module RoomsHelper
def check_rooms(tweet_user)
Room.joins(:entries).where(entries: { user_id: [current_user.id,
tweet_user.id] }).group('rooms.id').having('COUNT(entries.id) = 2').first
end
end
ここではviewから切り出したロジックを実装しています。ログインユーザーと投稿者が一緒に入っているルームがあるかどうかを判別しています。
-
Room.joins(:entries)
ではRoomモデルに対してEntrieモデルを内部結合(INNER JOIN)しています。これによりEntrieモデルを介してRoomに所属するユーザー情報を取得できるようになります。INNER JOINなので、room_id同士で結合して合致したデータのみ(ルームに入っているユーザーのみ)を取得します。 -
.where(entries: { user_id: [current_user.id, tweet_user.id] })
ではEntrieテーブルのユーザーIDがログインユーザーと投稿者の両方を持つデータを取得します。これによりログインユーザーと投稿者が同じルームに居る可能性があるルームを絞り込めます。 -
.group('rooms.id')
ではルームのidごとにグループ化します。これにより、同じルームに対してユーザーが複数居る場合にそれぞれのエントリーをまとめて扱うことができます。 -
.having('COUNT(entries.id) = 2')
ではentriesの数が2つであるルームを絞り込みます。entriesが2つということは2人という意味。これにより、同じルームに2人のユーザーが居る(ログインユーザーと投稿者)がいるという条件を満たすルームだけに絞り込むことができます。 -
.first
該当する最初の1つのルームを取得します。ルームは1対1であることが期待されるため、該当するルームがあれば1つのみ返されます。1対1はルームと特定の2人のユーザーのことを指します。1つのルームには特定のユーザー2人が対となることを表します。この時点でルームを取得出来なければ、2人の間ではまだルームが作成されていないことになりますので、新規ルームを作成する流れになります。
今回参考にした記事
[Rails] DM機能を解説する
Rails DM機能の実装
やさしい図解で学ぶ 中間テーブル 多対多 概念編
【Rails/一意性制約】モデルのバリデーションをコンソールで確認する方法/複数カラムへの一意性制約
【Rails入門】helperの使い方まとめ
Rails Viewに〇〇ロジックは書かない (※後日追記)
Viewにビジネスロジックは書かない
最後まで読んで頂きありがとうございました。