8
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【Rails】SNSアプリにおけるリツイート機能の実装

Last updated at Posted at 2021-01-17

現在就活用のポートフォリオとして、自分の好きな物をレビュー付きで共有できるRevoriteというSNSアプリを作成しています。
基本的な投稿機能やいいね機能などはググるとたくさんヒットしますが、リツイート機能に関する記事が少ないように感じたので、備忘録も兼ねてどなたかの参考になればと思い記事にしました。

前提

ユーザを管理するuserモデル、投稿(ツイート)を管理するpostモデル、フォロー・フォロワーの関係を管理するrelationshipモデルはそれぞれ作成済み

モデルの作成

リポスト(当アプリではツイートではなく投稿(post)という言葉で統一しているため、以降リツイートではなくリポストと呼んでいます)を管理するrepostモデルを作成します。

$ rails g model repost
YYYYMMDDHHMMSS_create_reposts.rb
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テーブルはユーザと投稿の多対多の中間テーブルという位置付けになります。

user.rb
has_many :reposts, dependent: :destroy
post.rb
has_many :reposts, dependent: :destroy
repost.rb
belongs_to :user
belongs_to :post

コントローラの作成

次にユーザが投稿をリポストできるように、コントローラを作成します。リポストした投稿のリポストボタンを再度押すことで取り消すことも可能です。

$ rails g controller reposts
reposts_controller.rb
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

ここで併せてルートも編集しておきます。

routes.rb
resources :posts, only: [:index, :new, :create, :destroy] do
  resources :reposts, only: [:create, :destroy]
end

リポストボタンの組み込み

ビューにリポストボタンを組み込みます。
各投稿にボタンを表示する必要があるため、部分テンプレートで実装しています。

_repost.html.haml
- 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)メソッドは以下で定義しています。ユーザがそのポストをリポスト済みかどうかの判定用です。

user.rb
def reposted?(post_id)
    self.reposts.where(post_id: post_id).exists?
end

これで、ユーザが任意の投稿のリポストボタンを押すと、その情報がrepostsテーブルに登録されるようになりました。

リポストを含んだユーザの投稿一覧を表示

次に、ユーザの投稿一覧を表示するページで、そのユーザの投稿だけでなくリポストもいっしょに表示できるようにします。
通常、ユーザの投稿だけであればuser.postsで取得できますが、リポストも併せてとなると途端に難易度が上がります。(ここでかなり苦労しました、、)

ユーザ自身の投稿及びリポストした投稿を取得

リポストを同時に取得できるように、posts_with_repostsというメソッドを定義していきます。

user.rb
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問題を回避しつつ投稿に付随するアソシエーションを取得します。ここではpostsreposts以外はデータ絞り込み等では使わない(ビューの表示でのみ使う)ため、性能を考慮して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(...)で囲んで警告を回避しています。

コントローラーに記述、及びビューへの表示

users_controller.rb
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.html.haml
- 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が以下です。

user.rb
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はあらかじめ以下のように定義しています。

user.rb
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される場合、その中でもrepostscreated_atが一番新しいレコードのみを取得する、という条件を入れています。これはNOT EXISTSとサブクエリを利用し、「自身よりも新しいレコードが無い」という条件にすることで実現できます。

コントローラーに記述、及びビューへの表示

posts_controller.rb
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の違い

8
7
1

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?