現在就活用のポートフォリオとして、自分の好きな物をレビュー付きで共有できるRevoriteというSNSアプリを作成しています。
基本的な投稿機能やいいね機能などはググるとたくさんヒットしますが、リツイート機能に関する記事が少ないように感じたので、備忘録も兼ねてどなたかの参考になればと思い記事にしました。
前提
ユーザを管理するuser
モデル、投稿(ツイート)を管理するpost
モデル、フォロー・フォロワーの関係を管理するrelationship
モデルはそれぞれ作成済み
モデルの作成
リポスト(当アプリではツイートではなく投稿(post)という言葉で統一しているため、以降リツイートではなくリポストと呼んでいます)を管理するrepost
モデルを作成します。
$ rails g model repost
class CreateReposts < ActiveRecord::Migration[5.2]
def change
create_table :reposts do |t|
t.references :user, foreign_key: true
t.references :post, foreign_key: true
t.timestamps
end
end
end
$ rails db:migrate
でreposts
テーブルを作成。誰がどの投稿をリポストしたか、を管理します。
次にアソシエーションです。ユーザは複数投稿をリポストできるし、一つの投稿は複数ユーザにリポストされうるため、reposts
テーブルはユーザと投稿の多対多の中間テーブルという位置付けになります。
has_many :reposts, dependent: :destroy
has_many :reposts, dependent: :destroy
belongs_to :user
belongs_to :post
コントローラの作成
次にユーザが投稿をリポストできるように、コントローラを作成します。リポストした投稿のリポストボタンを再度押すことで取り消すことも可能です。
$ rails g controller reposts
class RepostsController < ApplicationController
before_action :set_post
def create # リポストボタンを押下すると、押したユーザと押した投稿のIDよりrepostsテーブルに登録する
if Repost.find_by(user_id: current_user.id, post_id: @post.id)
redirect_to root_path, alert: '既にリポスト済みです'
else
@repost = Repost.create(user_id: current_user.id, post_id: @post.id)
end
end
def destroy # 既にリポストした投稿のリポストボタンを再度押下すると、リポストを取り消す(=テーブルからデータを削除する)
@repost = current_user.reposts.find_by(post_id: @post.id)
if @repost.present?
@repost.destroy
else
redirect_to root_path, alert: '既にリポストを取り消し済みです'
end
end
private
def set_post # リポストボタンを押した投稿を特定する
@post = Post.find(params[:post_id])
if @post.nil?
redirect_to root_path, alert: '該当の投稿が見つかりません'
end
end
end
ここで併せてルートも編集しておきます。
resources :posts, only: [:index, :new, :create, :destroy] do
resources :reposts, only: [:create, :destroy]
end
リポストボタンの組み込み
ビューにリポストボタンを組み込みます。
各投稿にボタンを表示する必要があるため、部分テンプレートで実装しています。
- if user_signed_in?
- unless post.user.id == current_user.id # 自身の投稿はリポストできない
- if current_user.reposted?(post.id)
= link_to "/posts/#{post.id}/reposts/#{post.reposts.ids}", method: :delete, title: "リポストを取り消す", remote: true do
%i.fas.fa-retweet.post-action__repost--reposted
= post.reposts.length
- else
= link_to "/posts/#{post.id}/reposts", method: :post, title: "リポストする", data: {confirm: "この投稿をリポストしますか?"}, remote: true do
%i.fas.fa-retweet
= post.reposts.length
- else
%i.fas.fa-retweet.nonactive
= post.reposts.length
- else
%i.fas.fa-retweet.nonactive
= post.reposts.length
※reposted?(post.id)
メソッドは以下で定義しています。ユーザがそのポストをリポスト済みかどうかの判定用です。
def reposted?(post_id)
self.reposts.where(post_id: post_id).exists?
end
これで、ユーザが任意の投稿のリポストボタンを押すと、その情報がreposts
テーブルに登録されるようになりました。
リポストを含んだユーザの投稿一覧を表示
次に、ユーザの投稿一覧を表示するページで、そのユーザの投稿だけでなくリポストもいっしょに表示できるようにします。
通常、ユーザの投稿だけであればuser.posts
で取得できますが、リポストも併せてとなると途端に難易度が上がります。(ここでかなり苦労しました、、)
ユーザ自身の投稿及びリポストした投稿を取得
リポストを同時に取得できるように、posts_with_reposts
というメソッドを定義していきます。
def posts_with_reposts
relation = Post.joins("LEFT OUTER JOIN reposts ON posts.id = reposts.post_id AND reposts.user_id = #{self.id}")
.select("posts.*, reposts.user_id AS repost_user_id, (SELECT name FROM users WHERE id = repost_user_id) AS repost_user_name")
relation.where(user_id: self.id)
.or(relation.where("reposts.user_id = ?", self.id))
.with_attached_images
.preload(:user, :review, :comments, :likes, :reposts)
.order(Arel.sql("CASE WHEN reposts.created_at IS NULL THEN posts.created_at ELSE reposts.created_at END"))
end
順番に上から見ていきましょう。
いきなりrelation = Post.・・・
と出てきているのは、後に登場するor
メソッドで繰り返し記述が必要になるため、変数化しています。
その内容ですが、posts
に対して左外部結合でreposts
を取得しています。一つの投稿には複数ユーザがリポストすることがあるため、結合条件のON句の中で自身のリポストのみで絞っています。left_joins
メソッドだとON句を自由に設定できないようなので、joins
メソッドでベタ書きしています。
単純にeager_load
でJOINしてwhere
で自身のリポストのみを絞り込むやり方も考えたのですが、投稿やリポストが増えるほど性能面への影響が跳ね上がるため、記述は少し煩雑ですが先に絞り込んだ形のrepost
をJOINすることで取得する、という形にしています。
その次のselect
メソッドですが、posts
だけでなくreposts
の情報も取得したい(のちにビューで利用する)ので指定しています。
ここまでが取得対象のテーブル、取得項目を表しています。次からは取得条件です。
relation.where(user_id: self.id).or(relation.・・・
ですが、これはユーザ自身の投稿、またはリポストしたユーザが自身である投稿を取得する、という意味になります。
.with_attached_images
と.preload(・・・
でN+1問題を回避しつつ投稿に付随するアソシエーションを取得します。ここではposts
とreposts
以外はデータ絞り込み等では使わない(ビューの表示でのみ使う)ため、性能を考慮してeager_load
ではなくpreload
を採用しています。
最後の.order(・・・
、取得順ですが、基本は投稿日時順です。ただし、リポストした投稿は投稿日時ではなくリポストした日時で順序判定をする、という指定をしています。自身の投稿であればJOINしたreposts
の各項目はNULLであることから判定してソート項目を使い分けています。
※order
メソッド内で条件文を使おうと思うとベタ書きにせざるを得ないのですが、そうすると Dangerous query method ... みたいなエラーが発生します。
これはRails5.2からの機能で、order句の中でSQLをベタ書きするとSQLインジェクションの危険性がありますよ!という警告をしてくれるものだそうです。Arel.sql(...)
で囲えば警告は出なくなるので、危険性が無いことを確認した上で使ってね、という意味合いですね。(参考記事:Arel.sqlを付けるだけじゃダメ!? Railsで"Dangerous query method …”の警告が出たときの対応方法)
今回はタイムスタンプ項目であるcreated_at
を順序判定の項目として使うだけで、問題無いことは明白なのでArel.sql(...)
で囲んで警告を回避しています。
コントローラーに記述、及びビューへの表示
class UsersController < ApplicationController
before_action :set_user
def show
@posts = @user.posts_with_reposts
end
private
def set_user
@user = User.find(params[:id])
end
end
- posts.each_with_index.reverse_each do |post, i|
%li.post
- if post.has_attribute?(:repost_user_id) # リポストを取得しない投稿一覧ページ(お気に入り一覧など)でも同じ部分テンプレートを利用しているため、この判定を入れている
- if post.repost_user_id.present?
.post-repost
%i.fas.fa-retweet
= link_to post.repost_user_name, "/users/#{post.repost_user_id}", "data-turbolinks": false
さんがリポスト
.post-container
.post-left
= link_to user_path(post.user.id), "data-turbolinks": false do
- if post.user.image.attached?
= image_tag(post.user.image, alt: 'デフォルトアイコン')
- else
= image_tag('sample/default_icon.png', alt: 'デフォルトアイコン')
.post-right
= render partial: "posts/post", locals: {post: post}
= render partial: "posts/comment", locals: {post: post}
- if i != 0
%hr
こんな感じで、ユーザの投稿及びリポストした投稿の一覧を表示できました。
リポストを含んだタイムラインの表示
タイムライン(ユーザがフォローしている人(=フォロイー)の投稿一覧)には、ユーザの投稿一覧ページと同様に、フォロイーの投稿だけでなく彼らのリポストも表示する必要があります。
ただ、ユーザの投稿一覧ページとは違う点として、複数の異なるフォロイーが同じ投稿をリポストしている場合、重複して表示をしないようにするといった考慮が必要になってきます。
この点を踏まえた上で実装したメソッドfollowings_posts_with_reposts
が以下です。
def followings_posts_with_reposts
relation = Post.joins("LEFT OUTER JOIN reposts ON posts.id = reposts.post_id AND (reposts.user_id = #{self.id} OR reposts.user_id IN (SELECT follow_id FROM relationships WHERE user_id = #{self.id}))")
.select("posts.*, reposts.user_id AS repost_user_id, (SELECT name FROM users WHERE id = repost_user_id) AS repost_user_name")
relation.where(user_id: self.followings_with_userself.pluck(:id))
.or(relation.where(id: Repost.where(user_id: self.followings_with_userself.pluck(:id)).distinct.pluck(:post_id)))
.where("NOT EXISTS(SELECT 1 FROM reposts sub WHERE reposts.post_id = sub.post_id AND reposts.created_at < sub.created_at)")
.with_attached_images
.preload(:user, :review, :comments, :likes, :reposts)
.order(Arel.sql("CASE WHEN reposts.created_at IS NULL THEN posts.created_at ELSE reposts.created_at END"))
end
基本的には前述したposts_with_reposts
メソッドと構成は近いです。異なる点を順に挙げていきます。
・relation = Post.joins(
posts_with_reposts
メソッドでは自身のリポストのみに絞ったreposts
をJOINしていましたが、ここでは自身またはフォロイーのリポストのみに絞り込んでいます。
本当はuser_id IN (#{self.followings_with_userself.pluck(:id)})
みたいに実装したかったのですが、この記述の場合コンパイルされるとIN句の中が([1,2,3])
みたいになって上手く取得できないため、少し冗長ですがrelationships
からフォロイーのIDを取得して条件に指定しています。
ベタ書きではない通常のwhere
メソッド等でpluckを使う分にはうまくいくのですが…ここは上手なやり方があれば多少リファクタリングできそうです。
・relation.where(user_id: self.followings_with_userself.pluck(:id))
ユーザまたはフォロイーの投稿一覧を取得しています。一応ですがfollowings_with_userself
はあらかじめ以下のように定義しています。
def followings_with_userself
User.where(id: self.followings.pluck(:id)).or(User.where(id: self.id))
end
・.or(relation.where(・・・
・.where("NOT EXISTS(・・・
中身を見る前に文の構成がややこしいので前置きすると、一つ前のwhere文をA、当説明のor文をB、where文をCとすると**A OR (B AND C)
**という構成になっています。
取得したいデータの条件は以下の通りです。
(ユーザとフォロイー自身の投稿) または ((ユーザとフォロイーがリポストした投稿) かつ (重複している中で一番リポスト日時が新しいもの))
reposts
はLEFT JOINで取得しているため、一つの投稿に対し複数のフォロイーがリポストした場合、同じ投稿が複数レコード取得されてしまいます。そのため一つの投稿に対し複数のreposts
レコードがJOINされる場合、その中でもreposts
のcreated_at
が一番新しいレコードのみを取得する、という条件を入れています。これはNOT EXISTS
とサブクエリを利用し、「自身よりも新しいレコードが無い」という条件にすることで実現できます。
コントローラーに記述、及びビューへの表示
def index
if user_signed_in?
@user = User.find(current_user.id)
@posts = @user.followings_posts_with_reposts
else
# 未ログインの場合、フォローやタイムラインといった概念が無いため、リポストは表示せず投稿のみを表示
@posts = Post.with_attached_images.preload(:user, :review, :comments, :likes)
end
end
ビューは先述と同じため(部分テンプレートで共通)割愛します。
最後に
アソシエーションを組んでいるテーブルを取得する際はとりあえずN+1問題回避のためにincludesだ!くらいの知識しか無かったのですが、このリツイート機能の実装に伴い eager_loading と lazy loading の違い、join / preload / eager_load / includes の違いをしっかり理解する良い機会となりました。
ポートフォリオ作成においてはポピュラーなSNSアプリですが、リツイート機能は他の機能よりは比較的難易度は高めだと思います。記事が少ないということは実装している方も少ないのかな?個人的にはとても勉強になったので、SNSアプリを開発している方は是非実装してみてはいかがでしょうか。
以下、データ取得の考えた方として参考にさせていただいた記事です。
ActiveRecordのincludesは使わずにpreloadとeager_loadを使い分ける理由
ActiveRecordのjoinsとpreloadとincludesとeager_loadの違い