LoginSignup
1
1

enumとバルクインサートを使用した通知機能実装

Last updated at Posted at 2024-06-15

はじめに

ポートフォリオ制作中です!
今回は通知機能を実装しました!
enumとバルクインサートを活用しポリモーフィック関連付けを
使った実装をしたのでアウトプットとしてまとめます

※旅行やデートのプランを共有するアプリなので、投稿機能はPostではなくPlanを使用しています!

完成物

スクリーンショット 2024-06-15 13.33.47.png

レイアウトがまだ整ってなくてすみません💦

通知の条件

1.新規投稿されたときフォロワーへ通知
2.投稿にコメントされたとき投稿者へ通知
3.投稿にいいねされたときに投稿者へ通知
4.フォローされたときにユーザーへ通知
5.メッセージを送信されたときに受信者へ通知

各機能は実装されていることを前提にまとめていきます

ER図

複雑ですが、通知テーブルから各アクションのリレーションは省略しています

スクリーンショット 2024-06-15 13.43.27.png

モデル、マイグレーションファイル作成

モデル作成

通知モデルを作成します

$ 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など)
モデル作成時にカラムを作成していない場合は、ここで追加しましょう

xxxxx_create_notifications.rb
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に反映されます↓

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]のみで問題ありません

scope moduleなどについてはこちら

routes.rb
Rails.application.routes.draw do
  scope module: :public do
:
    resources :notifications, only: [:index, :destroy]
  end
:
end

コントローラー

notifications_controller.rb
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を記述します

user.rb
class User < ApplicationRecord

  # アソシエーション
: # 他のアソシエーション
  has_many :notifications, dependent: :destroy
:
end

notificationはenumを使用して各モデルをaction_typeで管理します

notification.rb
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モデル
comment.rb
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_manyhas_one の使い分け
has_manycommentモデル
1つの「コメント」に対して通常1つの通知(Notification)が生成されますが、
同じユーザーが複数回コメントする場合や、他のユーザーが同じ投稿に対して複数のコメントをする場合を考慮すると、has_many :notifications が適切です

has_onelikeモデル
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モデル
like.rb
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モデル
relationship.rb
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モデル
chat.rb
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情報に渡します

room.rb
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モデル
plan.rb
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問題といいます
この問題を解決するために、バルクインサートを使用します

バルクインサート
🌱バルクインサートとは

テーブルに複数のレコードを登録する時に、
1行1行インサートすると効率が悪いので、まとめてインサートすること

詳しくはこちらの記事がわかりやすかったです

https://qiita.com/xhnagata/items/79184ded56158ea1b97a

バルクインサートは、以前までgemの追加が必要でしたが、
rails6で公式に追加されたため、insert_allメソッドとして使用できるようになりました

バルクインサート使用時の注意点⚠️

insert_allを使う場合、created_at,updated_atは明示的に指定する必要があります!Time.currentを記述するようにしましょう!

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)

plan.rb
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作成

_footer.html.erb
<li class="mx-3">
  <%= link_to notifications_path do %>
  <i class="fa-solid fa-bell" style="color: #FFD43B;">通知</i>
  <% end %>
</li>
index.html.erb
<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
notifications/_commented_to_own_plan.html.erb
<%= 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
_liked_to_own_plan.html.erb
<%= 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
_followed_me.html.erb
<%= 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
_new_message.html.erb
<%= 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
_new_plan.html.erb
<%= 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

ヘルパーについてはこちらの記事に詳しく書かれています

notifications_helper.rb
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など初めてのことばかりで理解が遅かったですが、実装できてよかったです!

参照記事

参考にさせていただきました!
ありがとうございました!

1
1
0

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
1
1