Railsでユーザーフォロー機能を作る

今回は、TwitterInstagramのようなユーザーのフォロー機能を実装し、多対多のモデルの関連付けを学びました。

ここでは「Ruby on Rails チュートリアル - 第14章 ユーザーをフォローする」を参考に実装しました。
これがまた難しかったのでまとめておきます。

以下は作成済みの新規アプリケーションがあることが前提です。

学んだこと✏️

前提

必要なテーブルを考え、設計する。(詳細についてはここでは割愛) なお、翻訳ファイルについての記載も割愛。

参考記事

1.モデルの作成

今回はFriendship(友人関係)がモデルの名前として適切と判断。

$ rails g model Friendship follower_id:integer followed_id:integer --no-fixture --no-test-framework
  • follower_id: フォローをしているユーザーのid(自分のフォロワーではない)
  • followed_id:フォローされてるユーザーのid

生成されたdb/migrate/[timestamp]_create_friendships.rbに以下を追記。

t.references :follower, null: false, foreign_key: { to_table: :users }
t.references :followed, null: false, foreign_key: { to_table: :users }
       :
add_index :friendships, [:follower_id, :followed_id], unique: true
  • add_referenceまたは t.referencesを使うと、デフォルトで参照先のテーブルに対して外部キー制約とインデックスを作成する。
  • ここでは指定した2つのカラム(follower_idfollowed_id)の組み合わせが一意になるよう設定。
  • unique: trueでデータベース側からユニーク制約をつけ、あるユーザーが同じユーザーを2回以上フォローすることを防ぐ。
$ rails db:migrate
2.UserとFriendshipの関連付け

app/models/friendship.rb

class Friendship < ApplicationRecord
  belongs_to :follower, class_name: "User"
  belongs_to :followed, class_name: "User"
  validates :follower_id, uniqueness: { scope: :followed_id }
end

belongs_toとは?▶︎

Ruby on Railsで用意されているActive Recordのメソッドの1つで、1つのモデルが別のモデルに属することを示す。 つまり上記は、followerfollowedがUserモデルに属することを定義している。

バリデーションとは?▶︎

Railsにおけるバリデーションは、データの検証を行う仕組み。データの保存前に、特定の条件を満たしているかどうかを確認することで、データの品質を確保することができる。例えば、必須項目が入力されているか、文字列の長さが適切か、数値が範囲内にあるかなどを検証することができる。バリデーションにより、不正なデータがデータベースに保存されることを防ぐことができる。また、ユーザーにエラーメッセージを表示することで、入力ミスを防止することもできる。Railsでは、バリデーションをモデルに定義することができる。定義したバリデーションは、データの保存時に自動的に実行される。

  • validates :follower_id, uniqueness: { scope: :followed_id }
    • モデル側のバリデーションによる一意性チェック。follower_idfollowed_idの組み合わせが一意であることを検証するために使用。
    • このvalidates文がUserモデルで使われる場合、follower_idfollowed_idはUserのカラムの一部であることを意味する。follower_idがUser Aで、followed_idがUser Bの場合、このvalidates文により、同じ組み合わせで複数のレコードが作成されなくなる。つまり、あるユーザーが同じユーザーを2回以上フォローすることを防ぐ。(follower_idfollowed_idの組み合わせがすでに存在している場合、新しいレコードを作成することはできない。)

app/models/user.rb

class User < ApplicationRecord
  has_many :active_friendships,  class_name: 'Friendship',
                                 foreign_key: :follower_id,
                                 dependent: :destroy,
                                 inverse_of: :follower
  has_many :passive_friendships, class_name: 'Friendship',
                                 foreign_key: :followed_id,
                                 dependent: :destroy,
                                 inverse_of: :followed
end

この処理が示していること▶︎

このコードは、Active Recordを使用して、ユーザーモデルのActive Friendships(アクティブなフレンドシップ)とPassive Friendships(受動的なフレンドシップ)の2つの関連性を定義している。

has_manyは、Active Recordのメソッドで、1つのモデルが複数の他のモデルと関連付けられることを示す。ここでは、Userモデルが複数のFriendshipモデルと関連付けられることを定義している。

  • class_name

    • has_manyで指定した関連付け名と実際のモデル名が違う場合に使用。今回のように一つのFriendshipモデルに対して二つの関連(active_friendshippassive_friendships)を持たせたい場合、has_many :friendships(でuser.friendships)だとどちらの関連か区別できない。その為has_many :active_friendships(でuser.active_friendships)とする。ただこのままだとRailsは存在しないactive_friendshipsテーブルを探しに行くことになるため、実際に存在するfriendshipsテーブルを使うにはhas_many :active_friendships, class_name: 'Friendship'とする。以上によりactive_friendshipsという関連を実際のモデル名のFriendshipと対応づけることができる。

    • 4.1.2.2 :class_name

  • foreign_key

    • foreign_keyパラメータは、UserモデルとFriendshipモデルの関連付けに使用される外部キーを指定。今回の場合、Railsは自動的にfriendshipsテーブルのuser_idを探しに行くが、friendshipsテーブルにuser_idカラムはないため、foreign_keyオプションで明示的にfollower_id(またはfollowed_id)を指定する必要がある。

    • 4.1.2.5 :foreign_key

  • dependent: :destroy

    • Userモデルのデータリソースが削除されるとそれに紐づくFriendshipモデルのデータリソースも同時に削除される。

    • 4.1.2.4 :dependent

  • inverse_of

    • 2つのオブジェクト間の相互参照を定義する際に使用。このオプションは、Rails4以降のバージョンで利用可能で、関連付けを正確に反映させるために使用。inverse_ofを使用すると、関連するモデルを取得する際に、Railsがキャッシュを使用して関連するモデルのインスタンスを再検索する必要がなくなる。これにより、クエリの回数が減り、アプリケーションのパフォーマンスが向上。 rails4.1以降では指定せずともrails側が自動でつけてくれるが、自分で定義した関連には明示的にオプションを付ける必要がある。今回の場合、Friendshipモデルにもfollowerfollowedの逆方向の関連性を定義することで、より効率的なクエリーを生成することができる。

    • 3.5 双方向関連付け

このように、アクティブなフレンドシップと受動的なフレンドシップの2つの関連性を定義することで、ユーザーモデルが自身がフォローしているユーザーとフォローされているユーザーの両方を表現できる。

参考

3.フォロー・フォロワー数を表示

app/models/user.rb

class User < ApplicationRecord
  # 省略
  has_many :followings, through: :active_friendships, source: :followed
  has_many :followers, through: :passive_friendships, source: :follower # こっちのsourceオプションは省略可能
end
  • has_many :through

    • 2つのモデル間の多対多の関係を表現するために使用。
    • フォローするユーザーとフォローされるユーザーの多対多。
    • following(単数形)ではないの?となるかもしれないが、英語には例外的なケースがたくさんあり、都度考慮するのが困難なため、文法的な正しさよりもRailsの慣習に従う。また、複数形になっていれば、この関連が1対多の関係であることが自明になる。

    • 2.4 has_many :through関連付け

  • source: :followed

    • has_many :throughアソシエーションにおいて、中間モデルで参照する先のモデルを指定するために使用される。
    • sourceオプションがないと、Railsfriendshipsテーブルからfollowing_idを探しに行ってしまうので、source: :followedによりfriendshipsテーブルのfollowed_idを対象とする。

    • 4.3.2.9 :source

  • ex)user.followings

    • userがフォローしたユーザーの一覧を取得
    • active_friendshipsテーブル(userがフォローした関連)のfollowed_id(userにフォローされたユーザー)からuserがフォローしたユーザーの一覧を取得する。

app/views/users/show.html.erb

<%= render 'stats', user: @user %>

app/views/users/_stats.html.erb

<ul>
  <li><%= t('.followings', count: user.followings.count) %></li>
  <li><%= t('.followers', count: user.followers.count) %></li>
</ul>
4.フォロー一覧・フォロワー一覧画面
  • config/routes.rb
resources :users, only: %i[index show] do
    resources :followings, only: [:index], module: :users
    resources :followers, only: [:index], module: :users
end
HTTP動詞パスコントローラ#アクション名前付きルーティングヘルパー
GET/users/:user_id/followingsusers/followings#indexuser_followings_path
GET/users/:user_id/followersusers/followers#indexuser_followers_path

app/views/users/show.html.erb

<%= render 'stats', user: @user %>

app/views/users/_stats.html.erb

<ul>
  <li><%= link_to t('.followings', count: user.followings.count), user_followings_path(user) %></li>
  <li><%= link_to t('.followers', count: user.followers.count), user_followers_path(user) %></li>
</ul>

app/controllers/users/followings_controller.rb

class Users::FollowingsController < ApplicationController
  def index
    @user = User.find(params[:user_id])
    @followings = @user.followings.order(id: :desc)
  end
end
  • order(id: :desc)で新しいユーザーから表示される。

app/controllers/users/followers_controller.rb

class Users::FollowersController < ApplicationController
  def index
    @user = User.find(params[:user_id])
    @followers = @user.followers.order(id: :desc)
  end
end

app/views/users/followings/index.html.erb

<h1><%= t('.title') %></h1>
<p><%= User.model_name.human %>: <%= link_to @user.name, @user %></p>
<%= render 'users/users', users: @followings %>
<%= link_to t('views.common.back'), @user %>

app/views/users/followers/index.html.erb

<h1><%= t('.title') %></h1>
<p><%= User.model_name.human %>: <%= link_to @user.name, @user %></p>
<%= render 'users/users', users: @followers %>
<%= link_to t('views.common.back'), @user %>

app/views/users/_users.html.erb

<% if users.present? %>
  <table>
    <thead>
      <tr>
        <th><%= User.human_attribute_name(:email) %></th>
        <th><%= User.human_attribute_name(:name) %></th>
        <th><%= User.human_attribute_name(:postal_code) %></th>
        <th><%= User.human_attribute_name(:address) %></th>
        <th></th>
      </tr>
    </thead>

    <tbody>
      <% users.each do |user| %>
        <tr>
          <td><%= user.email %></td>
          <td><%= user.name %></td>
          <td><%= user.postal_code %></td>
          <td><%= user.address %></td>
          <td><%= link_to t('views.common.show'), user %></td>
        </tr>
      <% end %>
    </tbody>
  </table>
<% else %>
  <p>データがまだありません。</p>
<% end %>
5.フォロー・フォロー解除機能

config/routes.rb

resources :users, only: %i[index show] do
  resource :friendships, only: %i[create destroy]
  # 省略
end
HTTP動詞パスコントローラ#アクション名前付きルーティングヘルパー
DELETE/users/:user_id/friendshipsfriendships#destroyuser_friendships_path
POST /users/:user_id/friendshipsfriendships#createuser_friendships_path

app/models/user.rb

class User < ApplicationRecord
  # 省略
  def following?(user)
    active_friendships.where(followed_id: user.id).exists?
  end

  def follow(user)
    active_friendships.find_or_create_by!(followed_id: user.id)
  end

  def unfollow(user)
    friendship = active_friendships.find_by(followed_id: user.id)
    friendship&.destroy!
  end
end

&.演算子は、レシーバーがnilの場合に、メソッド呼び出しを行わずにnilを返すためのもの。つまり、friendshipnilの場合にはdestroyメソッドを呼び出すことができず、エラーになることを回避するために使用されている。

app/controllers/friendships_controller.rb

class FriendshipsController < ApplicationController
  before_action :set_user

  def create
    friendship = current_user.active_friendships.new(followed: @user)
    if friendship.save
      redirect_to @user, notice: t('.notice')
    else
      redirect_to @user, alert: t('.alert')
    end
  end

  def destroy
    friendship = current_user.active_friendships.find_by(followed: @user)
    if friendship&.destroy
      redirect_to @user, notice: t('.notice')
    else
      redirect_to @user, alert: t('.alert')
    end
  end

  private

  def set_user
    @user = User.find(params[:user_id])
  end
end

app/views/users/show.html.erb

<%= render 'follow_form', user: @user %>

app/views/users/_follow_form.html.erb

<% unless user == current_user %>
  <% if current_user.followings?(user) %>
    <%= button_to t('.destroy'), user_friendship_path(user), method: :delete %>
  <% else %>
    <%= button_to t('.create'), user_friendship_path(user), method: :post %>
  <% end %>
<% end %>
  • ユーザー対ユーザーのFriendshipは 0 個または 1 個しか存在せず、片方のユーザーを特定できれば Friendship を特定することができるため、FriendshipidをURLに含める必要はない。
まとめ

応用的な関連付け、ルーティング、画面設計等を学ぶことができました。 まだ不慣れですが、こちらも便利な機能なので、使いこなせるように実務でもしっかりと学んでいきたいです!