こんにちは!
ねこじょーかー(@nekojoker1234)と申します。
先日、ゼロから独学で勉強して、Webサービス「Lookmine」を立ち上げました。
通知機能があればいいなと思い実装したところ、思いのほか大変だったので、他の人が同じことで大変な思いをしないように記事にしてみました。
完成形のイメージ
通知一覧を確認する画面は、以下のようになります。
また、通知があった場合は画面上にマークを付けておくようにします。
前提
- 投稿機能を実装済み(この記事では、Postモデル)
- コメント機能を実装済み(この記事では、Commentモデル)
- いいね機能を実装済み(この記事では、Likeモデル)
- フォロー機能を実装済み(この記事では、Relationshipモデル)
「コメント」「いいね」「フォロー」はすべて実装されている必要はなく、最低限どれか一つだけでも実装されていれば問題ありません。
参考にした記事
以下の記事を参考にしました。
【学習アウトプット4】通知機能の作り方
一応実装はできたのですが、ところどころ省略されている箇所でつまづいたので、もう少し細かいところも解説していこうと思います。
通知モデルの概要
テーブルの情報としては、以下のようになります。
visitor_id | visited_id | post_id | comment_id | action | checked |
---|---|---|---|---|---|
1 | 2 | nil | nil | follow | false |
1 | 2 | 3 | nil | like | false |
1 | 2 | nil | 4 | comment | false |
- visitor_id : 通知を送ったユーザーのid
- visited_id : 通知を送られたユーザーのid
- post_id : いいねされた投稿のid
- comment_id : 投稿へのコメントのid
- action : 通知の種類(フォロー、いいね、コメント)
- checked : 通知を送られたユーザーが通知を確認したかどうか
必要な情報以外は、nil
を格納するようにしています。
たとえば、フォローの場合は「投稿」「コメント」は関係ないので、nil
を格納します。
コメントの場合、post_id
にnil
を格納していますが、必要であれば正しい投稿IDを格納してもOKです。
今回は特に使用しないのでnil
にしています。
通知モデルの作成
まずは、コマンドプロンプトから通知モデルを作成します。
$ rails g model Notification
出来上がったマイグレーションファイルを開き、以下のように変更します。
class CreateNotifications < ActiveRecord::Migration[5.2]
def change
create_table :notifications do |t|
t.integer :visitor_id, null: false
t.integer :visited_id, null: false
t.integer :post_id
t.integer :comment_id
t.string :action, default: '', null: false
t.boolean :checked, default: false, null: false
t.timestamps
end
add_index :notifications, :visitor_id
add_index :notifications, :visited_id
add_index :notifications, :post_id
add_index :notifications, :comment_id
end
end
idのところには、検索パフォーマンスを考えてインデックスを張っています。
また、必ず値が設定される列には、nil
を設定できないよう制約を追加します。
通知を確認したかどうかの初期値は、false
(通知未確認)にしておきましょう。
マイグレーションをして、DBにテーブルを作成します。
$ rails db:migrate
これで、通知テーブルの作成が完了しました。
モデル関連付け
次に作成した通知モデルを、User
、Post
、Comment
と紐付けする作業をしていきます。
User->Notifications
ユーザーと通知モデルの紐付けをしていきます。
has_many :active_notifications, class_name: 'Notification', foreign_key: 'visitor_id', dependent: :destroy
has_many :passive_notifications, class_name: 'Notification', foreign_key: 'visited_id', dependent: :destroy
- active_notifications:自分からの通知
- passive_notifications:相手からの通知
紐付ける名前とクラス名が異なるため、明示的にクラス名とIDを指定して紐付けます。
また、ユーザーを削除したとき、同時に通知も削除したいので、 dependent: :destroy
を追加します。
Post->Notifications
投稿と通知モデルの紐付けをしていきます。
has_many :notifications, dependent: :destroy
今度は、紐付ける名前とクラス名が一致しているため、明示的に指定する必要はありません。
Comment->Notifications
コメントと通知モデルの紐付けをしていきます。
こちらは、Postと同じで問題ありません。
has_many :notifications, dependent: :destroy
Notifications->User,Post,Comment
ここまでは、ユーザー、投稿、コメントから通知モデルへの関連付けを行いました。
今度は、逆方向の紐付けを行います。
default_scope -> { order(created_at: :desc) }
belongs_to :post, optional: true
belongs_to :comment, optional: true
belongs_to :visitor, class_name: 'User', foreign_key: 'visitor_id', optional: true
belongs_to :visited, class_name: 'User', foreign_key: 'visited_id', optional: true
default_scope
では、デフォルトの並び順を「作成日時の降順」で指定しています。
つまり、常に新しい通知からデータを取得することができるということです。
たとえば、Notification.first
を実行すると、一番古い通知ではなく、一番新しい通知が取得できます。
post
とcomment
についているoptional: true
は、nil
を許可するものです。
belongs_to
で紐付ける場合はnil
が許可されないのですが、今回はnil
を設定したいので、付けています。
なぜnil
を設定するのか忘れてしまった人は、記事上部の「通知モデル概要」を確認してください。
通知作成メソッドを作る
いいね通知の作成メソッド
「いいね」が押されたタイミングで、以下のようなデータを作成します。
visitor_id | visited_id | post_id | comment_id | action | checked |
---|---|---|---|---|---|
いいねした人のid | いいねされた人のid | いいねされた投稿のid | nil | like | false |
visitor_id
については、先ほどユーザーモデルで関連付けをしたため、ここでの紐付けは不要です。
したがって、post_id
、visited_id
、action
の3つを設定してあげましょう。
メソッド名に「!」を付けているのは、「メソッド内でデータ登録もやっているので、呼び出す際は気をつけてね」というのをわかりやすくするためです。(つけなくても動作します)
def create_notification_like!(current_user)
# すでに「いいね」されているか検索
temp = Notification.where(["visitor_id = ? and visited_id = ? and post_id = ? and action = ? ", current_user.id, user_id, id, 'like'])
# いいねされていない場合のみ、通知レコードを作成
if temp.blank?
notification = current_user.active_notifications.new(
post_id: id,
visited_id: user_id,
action: 'like'
)
# 自分の投稿に対するいいねの場合は、通知済みとする
if notification.visitor_id == notification.visited_id
notification.checked = true
end
notification.save if notification.valid?
end
end
ここでポイントとなるのが、「いいねされていない場合のみレコードを作成する」という分岐処理が入っているところです。
例えば、いいねボタンを連打した場合、押した数だけ相手に通知がいってしまいます。
これでは、悪意を持ったユーザーがひたすらいいねを連打して迷惑行為をするかもしれません。
そこで、以下の検索処理を事前に行い、レコード存在チェックをします。
# すでに「いいね」されているか検索
temp = Notification.where(["visitor_id = ? and visited_id = ? and post_id = ? and action = ? ", current_user.id, user_id, id, 'like'])
これで、いいねを連続でした場合でも、1度しか相手に通知がいかないようになります。
ちなみに、「?」は「プレースホルダ」と言って、「?」を指定した値で置き換えることができるものです。
SQLインジェクションを防ぐためにも、必須のセキュリティ対策になります。
# 自分の投稿に対するいいねの場合は、通知済みとする
if notification.visitor_id == notification.visited_id
notification.checked = true
end
自分の投稿にいいねをして、「自分がいいねといっています」と通知が来ても虚しいですよね。
虚しい気持ちにならないよう、自分の投稿に対するいいねは、事前に通知済みとしておきます。
コメント通知の作成メソッド
同様に、コメント投稿時にも処理を追加していきます。
def create_notification_comment!(current_user, comment_id)
# 自分以外にコメントしている人をすべて取得し、全員に通知を送る
temp_ids = Comment.select(:user_id).where(post_id: id).where.not(user_id: current_user.id).distinct
temp_ids.each do |temp_id|
save_notification_comment!(current_user, comment_id, temp_id['user_id'])
end
# まだ誰もコメントしていない場合は、投稿者に通知を送る
save_notification_comment!(current_user, comment_id, user_id) if temp_ids.blank?
end
def save_notification_comment!(current_user, comment_id, visited_id)
# コメントは複数回することが考えられるため、1つの投稿に複数回通知する
notification = current_user.active_notifications.new(
post_id: id,
comment_id: comment_id,
visited_id: visited_id,
action: 'comment'
)
# 自分の投稿に対するコメントの場合は、通知済みとする
if notification.visitor_id == notification.visited_id
notification.checked = true
end
notification.save if notification.valid?
end
いいねの時とは少し違うことに気付きましたか?
いいねのときは、すでにいいねされているかどうかを事前にチェックして、
いいねされていれば、通知レコードを作成しないような制御を入れていました。
コメントはというと、1つの投稿に対して複数回コメントを残す場合があるため、通知レコードの存在チェックはしません。
なので、コメントの度に相手に通知がいくようになります。
また、投稿者に対してだけではなく、他にコメントをしている人がいれば、その人にも通知を届けないといけないため、最初に以下の処理を追加しています。
temp_ids = Comment.select(:user_id).where(post_id: id).where.not(user_id: current_user.id).distinct
この処理を箇条書きで説明すると、以下のようになります。
- 投稿にコメントしているユーザーIDのリストを取得する
- 自分のコメントは除外する
- 重複した場合は削除する
なぜ重複した場合に削除するのかというと、取得したユーザーIDの分だけ通知を作成するとき、同じ通知が複数回登録されてしまうことを防ぐためです。
IDが複数取得されるケースというのは、同じ人が同じ投稿に複数回コメントした場合ですね。
また、なぜ自分のコメントを除外するのかというと、「コメントを登録→通知を登録」という順番のため、たった今登録したコメントの通知レコードが、自分に対して作成されてしまうからです。
フォロー通知の作成メソッド
次に、フォロー時の処理を書いていきます。
まずは、通知レコード作成メソッドを作ります。
def create_notification_follow!(current_user)
temp = Notification.where(["visitor_id = ? and visited_id = ? and action = ? ",current_user.id, id, 'follow'])
if temp.blank?
notification = current_user.active_notifications.new(
visited_id: id,
action: 'follow'
)
notification.save if notification.valid?
end
end
フォローの場合もいいねのときと同じで、「連続でフォローボタンを押す」ことに備えて、
同じ通知レコードが存在しないときだけ、レコードを作成するようにします。
また、フォローの場合は以下の処理を入れていません。
# 自分の投稿に対するコメントの場合は、通知済みとする
if notification.visitor_id == notification.visited_id
notification.checked = true
end
これは、「自分が自分をフォローすること」はありえないためです。
通知作成メソッドの呼び出し
次に、「いいね」「コメント」「フォロー」のタイミングで、通知作成メソッドの呼び出しをします。
いいねを押したとき
いいねを押したタイミングで通知レコードを作成するように、先ほど追加したメソッドをlikes_controller
のcreate
に追加します。
(create
メソッドの中身は、みなさんと違うかもしれません)
def create
@like = current_user.likes.build(like_params)
@post = @like.post
@like.save
post = Post.find(params[:post_id])
# ここから
post.create_notification_like!(current_user)
# ここまで
respond_to :js
end
コメントを投稿したとき
次に、コメントをしたタイミングで通知レコードを作成するように、先ほど追加したメソッドをcomments_controller
のcreate
に追加します。
(create
メソッドの中身は、みなさんと違うかもしれません)
def create
@comment = Comment.new(comment_params)
@post = @comment.post
if @comment.save
# ここから
@post.create_notification_comment!(current_user, @comment.id)
# ここまで
respond_to :js
else
render 'posts/show'
end
end
フォローしたとき
最後に、フォローをしたタイミングで通知レコードを作成するように、先ほど追加したメソッドをrelationships_controller
のcreate
に追加します。
(create
メソッドの中身は、みなさんと違うかもしれません)
def create
@user = User.find(params[:relationship][:following_id])
current_user.follow!(@user)
# ここから
@user.create_notification_follow!(current_user)
# ここまで
respond_to do |format|
format.html { redirect_to @user }
format.js
end
end
通知一覧画面の作成
あとは、通知一覧の画面を作っていきましょう。
まずは、通知コントローラーの作成からです。
$ rails g controller notifications
通知一覧の画面はindex
で作るので、ルーティングを追加しておきましょう。
他のメソッドは不要なので、only
を追加しています。
resources :notifications, only: :index
index
アクションを実装します。
class NotificationsController < ApplicationController
def index
@notifications = current_user.passive_notifications.page(params[:page]).per(20)
@notifications.where(checked: false).each do |notification|
notification.update_attributes(checked: true)
end
end
end
未確認の通知レコードだけ取り出したあと、「未確認→確認済」になるように更新をしています。
ちなみに以下の処理はkaminariというgemを利用しています。
.page(params[:page]).per(20)
Googleの検索結果では、以下のようにページで分かれて表示されますよね。
これは「ページネーション」と呼ばれていて、Rubyでも同じことができるようにしたgemがkaminariです。
使わなくても問題ありませんが、データ量が多いときは使うと便利ですよ。
通知一覧の画面を作っていきましょう。
erb
じゃなくてslim
です。erb
で書いている人はごめんなさい。
雰囲気は伝わると思うので、脳内置換してください。
/ 自分の投稿に対するいいね、コメントは通知に表示しない
- notifications = @notifications.where.not(visitor_id: current_user.id)
- if notifications.exists?
= render notifications
= paginate notifications
- else
p
| 通知はありません
= paginate notifications
は、kaminari
を使っていない人は削除してください。
まず、以下の記述についてです。
/ 自分の投稿に対するいいね、コメントは通知に表示しない
- notifications = @notifications.where.not(visitor_id: current_user.id)
自分の投稿に対する「いいね」と「コメント」は、通知済みとしてレコードを登録しました。
しかし、そのままでは通知一覧に「自分の投稿に対する通知」も表示されてしまいます。
そこで、「自分以外」という条件をここで追加しています。
= render notifications
レンダリングするときに、単数だけでなく複数のインスタンスを渡すことができます。
この場合は、以下の動きになります。
- インスタンスの数だけ、部分テンプレート
notifications/_notification.html.slim
が呼び出される - 個別のインスタンスを、
notification
として部分テンプレートで使用できる
- visitor = notification.visitor
- visited = notification.visited
.col-md-6.mx-auto
.form-inline
span
= link_to user_path(visitor) do
= image_tag avatar_url(visitor).to_s, class: "icon_mini"
strong
= visitor.name
= 'さんが'
- case notification.action
- when 'follow' then
= "あなたをフォローしました"
- when 'like' then
= link_to 'あなたの投稿', notification.post, style: "font-weight: bold;"
= "にいいねしました"
- when 'comment' then
- if notification.post.user_id == visited.id
= link_to "あなたの投稿", notification.post, style: "font-weight: bold;"
- else
span
= link_to post_path(notification.post) do
= image_tag avatar_url(notification.post.user).to_s, class: "icon_mini"
strong
= notification.post.user.name + 'さんの投稿'
= "にコメントしました"
p.text-muted.mb-0
= Comment.find_by(id: notification.comment_id)&.comment
.small.text-muted.text-right
= time_ago_in_words(notification.created_at).upcase
hr
これは一つひとつ説明するよりも画像を見たほうが早いと思うので、実際に動かしたイメージを以下に載せておきます。
いい感じですね。
最後に、通知があった場合は画面上にマークを付けておくと、わかりやすくなります。
今回使用したのは、みんな大好き「fontawesome」です。
もし通知があれば、ベルのアイコンにオレンジの円を重ねて表示しています。
(参考)アイコンを重ねて表示する方法
https://fontawesome.com/how-to-use/on-the-web/styling/stacking-icons
= link_to(notifications_path) do
- if unchecked_notifications.any?
span.fa-stack
i.far.fa-bell.fa-lg.fa-stack-2x style="font-size: 1.5em;"
i.fas.fa-circle.n-circle.fa-stack-1x
- else
i.far.fa-bell.fa-lg style="font-size: 1.5em;"
次に、notifications_helper
に「未確認の通知を検索するメソッド」を追加しておきます。
module NotificationsHelper
def unchecked_notifications
@notifications = current_user.passive_notifications.where(checked: false)
end
end
円マークにはn-circle
というクラスをつけて、CSSで微調整しています。
CSSは、自分が作成しているアプリに合わせて変更してください。
.n-circle {
position: absolute;
padding-left: 1rem;
padding-top: 0rem;
color: #efa04c;
}
これで、ひと通り完成です!
長丁場お疲れさまでした。
自分が送った通知を「アクティビティ」として確認する機能の実装も以下の記事で解説しているので、ぜひチャレンジしてみてください。
https://qiita.com/nekojoker/items/e45b3c8e9b600cb16d92
通知機能の実装の記録は、Lookmineに登録しておきましょう!
##あわせて読みたい
- HTMLもわからない初心者が、独学で「投稿型SNSサービス」を作ったって本当?【193日間の死闘】
- Webプログラミング「経験0」の私が、「193日間の独学」でWebサービスを立ち上げた話(外部リンク)
運営している PlayFab 専用ブログ
https://playfab-master.com