はじめに
ポートフォリオ制作中です!
今回は通知機能を実装しました!
enumとバルクインサートを活用しポリモーフィック関連付けを
使った実装をしたのでアウトプットとしてまとめます
※旅行やデートのプランを共有するアプリなので、投稿機能はPostではなくPlanを使用しています!
完成物
レイアウトがまだ整ってなくてすみません💦
通知の条件
1.新規投稿されたときフォロワーへ通知
2.投稿にコメントされたとき投稿者へ通知
3.投稿にいいねされたときに投稿者へ通知
4.フォローされたときにユーザーへ通知
5.メッセージを送信されたときに受信者へ通知
各機能は実装されていることを前提にまとめていきます
ER図
複雑ですが、通知テーブルから各アクションのリレーションは省略しています
モデル、マイグレーションファイル作成
モデル作成
通知モデルを作成します
$ rails g model Notification user:references subject:references{polymorphic} read:boolean action_type:integer
カラムを同時作成しています
referencesとは
(直訳:参照、参考)
名前の通り、作成済みのテーブルを参照する場合に使用します
コマンドを実行すると下記カラムがマイグレーションファイルに自動追加されます
t.references :user, null: false, foreign_key: true
ここで指定しなくとも、後からマイグレーションファイルに追加可能です!
のちにモデルに記載する「belongs_to」を自動で記載もしてくれます
※「has_many」は追加してくれないので注意⚠️
参考URL:https://prograshi.com/framework/rails/references_and_foreign-key/
・polymorphicは関連付けの一種で複数のモデルと関連付けを行うことができます
・readは既読判定に使うため、booleanを使用(true,false )
・action_typeはenumで管理するため、integerを使用
マイグレーションファイルに後から追記できるため
$ rails g model Notification
でも問題はありません
マイグレーションファイル
マイグレーションファイルを確認し、追記します(defaultなど)
モデル作成時にカラムを作成していない場合は、ここで追加しましょう
class CreateNotifications < ActiveRecord::Migration[6.1]
def change
create_table :notifications do |t|
t.references :user, null: false, foreign_key: true
t.references :subject, polymorphic: true, null: false
t.integer :action_type, null: false
t.boolean :read, default: false, null: false
t.timestamps
end
end
end
解説 🌱
・referencesを指定することでindexが自動付与され、高速検索が可能になる
・referencesを指定することで外部キーxx_idが不要になる
・foreign_key:true
を指定することで外部キーとして設定される
・readは既読の判定のため、defaultにfalseを設定します!
polymorphicとは
ポリモーフィック関連付けを使うと、Notificationモデルが他の複数のモデルに属していることを表現できます。
今回で言うと、notification.subject
の記述でLike、Comment、Relationshipなどのインスタンスを呼び出すことが可能になります
action_type
で各モデルを判別します!
マイグレーションファイルの記述が完了したら忘れないうちにmigrateしましょう!
$ rails db:migrate
マイグレートするとschema.rbに反映されます↓
ActiveRecord::Schema.define(version: 2024_06_14_012234) do
:
create_table "notifications", force: :cascade do |t|
t.integer "user_id", null: false
t.string "subject_type", null: false
t.integer "subject_id", null: false
t.integer "action_type", null: false
t.boolean "read", default: false, null: false
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["subject_type", "subject_id"], name: "index_notifications_on_subject"
t.index ["user_id"], name: "index_notifications_on_user_id"
end
:
end
ルーティング
publicとadminでルーティングを分けているため以下のように記述しています
分けていない場合はresources :notifications, only: [:index, :destroy]
のみで問題ありません
Rails.application.routes.draw do
scope module: :public do
:
resources :notifications, only: [:index, :destroy]
end
:
end
コントローラー
class Public::NotificationsController < ApplicationController
before_action :authenticate_user!
def index
@notifications = current_user.notifications.order(created_at: :desc)
@notifications.where(read: false).each do |notification|
notification.update(read: true)
end
end
def destroy
@notifications = current_user.notifications.destroy_all
redirect_to notifications_path
end
end
current_user.notifications
現在ログインしているユーザー(current_user)に関連付けられた全ての通知(notifications)を取得(モデルにアソシエーションを記述することで取得可能になります)
.order(created_at: :desc)
上記で取得したすべての通知を降順に並び替え
解説 🌱
@notifications.where(read: false).each do |notification|
先ほど取得したすべての通知から未読(read: false)分を取得
notification.update(read: true)
一覧ページを開いた時点で未読をすべて既読(read: true)に変更します
ここら辺は実装したい通知一覧に合わせてカスタマイズしてください!
current_user.notifications.destroy_all
通知一覧を空にするための前削除ボタンを作成するのに使います
モデル記述(アソシエーション)
userは通常のアソシエーション同様has_manyを記述します
class User < ApplicationRecord
# アソシエーション
: # 他のアソシエーション
has_many :notifications, dependent: :destroy
:
end
notificationはenumを使用して各モデルをaction_type
で管理します
class Notification < ApplicationRecord
belongs_to :user
belongs_to :subject, polymorphic: true
enum action_type: { new_plan: 0, commented_to_own_plan: 1, liked_to_own_plan: 2, followed_me: 3, new_message: 4}
end
commentモデル
class Comment < ApplicationRecord
# アソシエーション
belongs_to :user
belongs_to :plan
has_many :notification, as: :subject, dependent: :destroy
# 通知コールバック
after_create_commit :create_notifications
# バリデーション
validates :comment, presence: true
private
# 通知機能
def create_notifications
Notification.create(subject: self, user: plan.user, action_type: :commented_to_own_plan)
end
end
・has_many :notification
「commentモデルが複数のNotificationモデルを持っている」という関連性を示します
・has_many
と has_one
の使い分け
has_many
commentモデル
1つの「コメント」に対して通常1つの通知(Notification)が生成されますが、
同じユーザーが複数回コメントする場合や、他のユーザーが同じ投稿に対して複数のコメントをする場合を考慮すると、has_many :notifications
が適切です
has_one
likeモデル
1つの「いいね」に対して通常1つの通知が生成されます
ユーザーが投稿に対して「いいね」をした場合、その行為に対して1つの通知が生成されるためhas_one :notification
が適切です
・as: :subject
ポリモーフィック関連付けを示しており、
Notificationモデルのsubjectがコメントモデルになります
・after_create_commit
RailsのActiveRecordにおけるコールバック機能を使用して、
特定のタイミングで自動的に処理を実行します
今回の場合は、「コメントモデルのインスタンスがデータベースに保存された(create)後(after)に」という意味です
`afrete_create_commit` と `after_create` の違い
・after_create
は、レコードがデータベースに保存された直後に実行されます
このタイミングでは、まだトランザクションがコミットされていない(データベースに対する一連の操作が完全に実行が完了していない)可能性があります
・after_create_commit
は、レコードが確実にデータベースに保存され、
データベースに対する一連の操作が完全に実行が完了した後に実行されます
通知を送信するタイミングが確実にデータベースに保存された後である方が、安全であることが多いため、今回はafter_create_commit
を使用しています
・ create_notifications
コールバックに指定したcreate_notifications
の中身をprivate
で記述しています
コメントモデルのインスタンスがデータベースに保存された(create)後(after)に「通知(notification)インスタンスを作成する」という意味です
ここでsubject
の中身を指定します
・大体の書き方は他のモデルもコメントモデルと同じです!
通知機能を実装したいモデルを確認してください
likeモデル
class Like < ApplicationRecord
# アソシエーション
belongs_to :user
belongs_to :plan
has_one :notification, as: :subject, dependent: :destroy
# 通知コールバック
after_create_commit :create_notifications
private
# 通知機能
def create_notifications
Notification.create(subject: self, user: plan.user, action_type: :liked_to_own_plan)
end
end
relationshipモデル
class Relationship < ApplicationRecord
# アソシエーション
belongs_to :follower, class_name: "User"
belongs_to :followed, class_name: "User"
has_one :notification, as: :subject, dependent: :destroy
# 通知コールバック
after_create_commit :create_notifications
private
# 通知機能
def create_notifications
Notification.create(subject: self, user: followed, action_type: :followed_me)
end
end
chatモデル・roomモデル
class Chat < ApplicationRecord
# アソシエーション
belongs_to :user
belongs_to :room
has_many :notification, as: :subject, dependent: :destroy
# 通知コールバック
after_create_commit :create_notifications
private
# 通知機能
def create_notifications
room = self.room
recipient_user = room.users.where.not(id: self.user.id).first
Notification.create(subject: self, user: recipient_user, action_type: :new_message)
end
end
DM機能の通知に関しては、送信元のユーザーIDを取得しrecipient_user
に代入してから、
subjectのuser情報に渡します
class Room < ApplicationRecord
# アソシエーション
has_many :user_rooms, dependent: :destroy
has_many :chats, dependent: :destroy
has_many :users, through: :user_rooms
end
user_roomモデルを介してuser情報をchatモデルに渡すため、
has_many :users, through: :user_rooms
の記述が必要です
planモデルはバルクインサートを使用した実装方法のため他とは記述が異なります
planモデル
class Plan < ApplicationRecord
# アソシエーション
belongs_to :user
has_many :comments, dependent: :destroy
has_many :likes, dependent: :destroy
has_many :notifications, as: :subject, dependent: :destroy
# 通知機能
after_create_commit :notify_followers
private
def notify_followers
follower_ids = user.followers.pluck(:id)
if follower_ids.any?
notifications = follower_ids.map do |follower_id|
{
subject_type: 'Plan',
subject_id: self.id,
user_id: follower_id,
action_type: 'new_plan',
created_at: Time.current,
updated_at: Time.current
}
end
Notification.insert_all(notifications)
end
end
end
新規投稿した際に、フォロワー全員に通知する必要があります
フォロワーが1人や2人なら問題ありませんが、何万人といれば一つの投稿をするたびにフォロワーの人数だけインサートをすることになってしまい、投稿処理に時間を要してしまう可能性があります
これをN+1問題といいます
この問題を解決するために、バルクインサートを使用します
バルクインサート
詳しくはこちらの記事がわかりやすかったです
https://qiita.com/xhnagata/items/79184ded56158ea1b97a
バルクインサートは、以前までgemの追加が必要でしたが、
rails6で公式に追加されたため、insert_allメソッドとして使用できるようになりました
follower_ids = user.followers.pluck(:id)
投稿したユーザーのフォロワーidをpluckで取得しfollower_ids
に代入します
また、pluckを使用すると取得した情報を配列として返してくれます
例えば今回のuser.idだと[1, 2, 3]といった具合です
if follower_ids.any?
通知を作成する際にフォロワーがいるか確認します
この記述がないとフォロワーがいないユーザーは投稿するとエラーが起きます!!!
pluckメソッドとは
ActiveRecordのクエリメソッドの一つ
特定のカラムの値を直接データベースから取得し、配列として返します
単一カラム、複数カラム、条件指定での取得全て可能
# 単一カラム
names = User.pluck(:name)
# 複数カラム
user_info = User.pluck(:name, :email)
# 条件指定
names = User.where("age >= 30").pluck(:name)
notifications = follower_ids.map do |follower_id|
{
subject_type: 'Plan',
subject_id: self.id,
user_id: follower_id,
action_type: 'new_plan',
created_at: Time.current,
updated_at: Time.current
}
end
mapメソッドを使って各フォロワーのIDに対して通知のハッシュを作成しています
具体的には、各フォロワーID(follower_id)に対して、次のようなハッシュを生成しています
[
{ subject_type: 'Plan', subject_id: 123, user_id: 1, action_type: 'new_plan', created_at: '2024-06-15 12:00:00', updated_at: '2024-06-15 12:00:00' },
{ subject_type: 'Plan', subject_id: 123, user_id: 2, action_type: 'new_plan', created_at: '2024-06-15 12:00:00', updated_at: '2024-06-15 12:00:00' },
{ subject_type: 'Plan', subject_id: 123, user_id: 3, action_type: 'new_plan', created_at: '2024-06-15 12:00:00', updated_at: '2024-06-15 12:00:00' }
]
最後に、生成した通知の配列を一括でデータベースに挿入します
Notification.create
の代わりにNotification.insert_all(notifications)
を使用します
これにより、複数の通知を一度に作成でき、パフォーマンスの向上が期待できます
View作成
<li class="mx-3">
<%= link_to notifications_path do %>
<i class="fa-solid fa-bell" style="color: #FFD43B;">通知</i>
<% end %>
</li>
<div class="container">
<div class="row">
<div class="col-sm-12 col-md-10 col-lg-8 bg-white my-3 p-4 shadow-lg mx-auto rounded">
<% if @notifications.present? %>
<%= link_to "全削除",notification_path(@notifications), method: :delete,class:"btn btn-danger" %>
<% @notifications.each do |notification| %>
<%= render "#{notification.action_type}", notification: notification %>
<% end %>
<% else %>
通知はありません
<% end %>
</div>
</div>
</div>
comment
<%= link_to transition_path(notification) do %>
<div>
<%= link_to notification.subject.user.name, user_path(notification.subject.user) %>
があなたの
<%= link_to '投稿', plan_path(notification.subject.plan) %>
にコメントしました
<%= " (#{time_ago_in_words(notification.created_at)}前)" %>
</div>
<% end %>
like
<%= link_to transition_path(notification) do %>
<div>
<%= link_to notification.subject.user.name, user_path(notification.subject.user) %>
があなたの
<%= link_to '投稿', plan_path(notification.subject.plan) %>
にいいねしました
<%= " (#{time_ago_in_words(notification.created_at)}前)" %>
</div>
<% end %>
follower
<%= link_to transition_path(notification) do %>
<div>
<% if notification.subject.follower.guest_user? %>
ゲストユーザー
<% else %>
<%= link_to notification.subject.follower.name, user_path(notification.subject.follower) %>
<% end %>
があなたをフォローしました
<%= " (#{time_ago_in_words(notification.created_at)}前)" %>
</div>
<% end %>
chat
<%= link_to transition_path(notification) do %>
<div>
<%= link_to notification.subject.user.name, user_path(notification.subject.user) %>
さんから
<%= link_to 'メッセージ', chat_path(notification.subject.user) %>
が届いています!
<%= " (#{time_ago_in_words(notification.created_at)}前)" %>
</div>
<% end %>
new_plan
<%= link_to transition_path(notification) do %>
<div>
<%= link_to notification.subject.user.name, user_path(notification.subject.user) %>
が新しい
<%= link_to '投稿', plan_path(notification.subject) %>
をしました
<%= " (#{time_ago_in_words(notification.created_at)}前)" %>
</div>
<% end %>
helper
ヘルパーについてはこちらの記事に詳しく書かれています
module Public::NotificationsHelper
def transition_path(notification)
case notification.action_type.to_sym
when :new_plan
plan_path(notification.subject)
when :commented_to_own_plan
plan_path(notification.subject, anchor: "comment-#{notification.subject.id}")
when :liked_to_own_plan
plan_path(notification.subject.plan)
when :followed_me
user_path(notification.subject.follower)
when :new_message
user_path(notification.subject.user)
end
end
end
transition_path
通知機能のヘルパーメソッドとして、通知の内容に応じて適切なURLパスを生成するためのメソッドです
このメソッドは、通知をクリックしたときにユーザーをどのページに遷移させるかを決定します
今回の場合は、通知のaction_typeに応じて異なるURLパスを生成しています
notification.action_type.to_sym
文字列として格納されている action_type をシンボルに変換するために使用されています
シンボルは、Rubyの内部で効率的に処理されるため、case 文や when 節での比較に使用されます
さいごに
enumを使用したaction_typeの分岐やバルクインサート、helperなど初めてのことばかりで理解が遅かったですが、実装できてよかったです!
参照記事
参考にさせていただきました!
ありがとうございました!