2つのモデルを使ってユーザーフォロー機能の実装

この記事はプログラミング学習者がアプリ開発中に躓いた内容を備忘録として記事におこしたものです。内容に不備などあればご指摘頂けると助かります。

## 記事投稿の背景
Xのクローンサービスを制作している時に自分が躓いたユーザーフォロー機能の実装内容について知識整理も兼ねてまとめたものです。

## 実装時のER図(最終版)
![スクリーンショット 2024-10-14 20.41.05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3508918/03d3eea2-1ef1-543e-d5d2-8fca3a4a9cc0.png)


## 実相当初のコード
```ruby:user.rbの関連箇所を抜粋
has_many :relations, dependent: :destroy
has_many :followers, through: :relations
```
```ruby:relation.rb
class Relation < ApplicationRecord
  belongs_to :user
  belongs_to :follower
end
```
```ruby:follower.rb
class Follower < ApplicationRecord
  has_many :relations, dependent: :destroy
  has_many :users, through: :relations
end
```
```ruby:followers_controller.rb
class FollowersController < ApplicationController
  before_action :set_followers, only: %i[destroy]
  # ログインしていない場合、ログインページへ遷移させる
  before_action :authenticate_user!, only: %i[create destroy]
  def create
    @follower = Follower.create(user_id: current_user.id)
    @relation = Relation.new(user_id: params[:user_id], follower_id: @follower.id)
    if @follower.save && @relation.save
      redirect_to home_index_path, notice: 'フォローしました。'
    else
      redirect_to home_index_path, alert: 'フォローに失敗しました。'
    end
  end
  def destroy
    @follower.destroy!
    redirect_to home_index_path, notice: 'フォローを解除しました。'
  end
  private
  def set_followers
    @follower = Follower.find(params[:id])
  end
end
```
モデルの関連としては
User ↔︎ Relation ↔︎ Followerとなっています。
Relationのカラムには外部キーとして`user_id`と`follower_id`を設定しています。
Followerのカラムには`user_id`を設定していました。
Follower(フォローする人), Followee(フォローされる人)と別々にモデルを分けて実装する方法もネット上では見かけたのですが、誰かが誰かをフォローするというシンプルな考え方を基に実装したものです。
※フォローもフォローバックもフォローしている事には変わりないという意味です。

実装当初、上記のコードで機能としては問題無く動いていたのですが、Followerモデルは必要無いという判断に至りました。外部キーの`follower_id`を通してFollowerモデルに保管されている`user_id`を確認して誰がフォローしているかを確認するという方法ではなく、Relationモデルにフォローするユーザーとフォローされるユーザーの両方を保管すればUserとRelationモデルの２つで完結できるのではないかという考えです。

## 修正後のコード
```ruby:user.rb
  has_many :relations, foreign_key: :user_id, dependent: :destroy
  has_many :following, through: :relations, source: :follower

  has_many :passive_relations, class_name: 'Relation', foreign_key: :follower_id, dependent: :destroy,
                               inverse_of: :follower
  has_many :followers, through: :passive_relations, source: :user
```

- 上の２行で user → relations → followingという関連を作り出し、このユーザーがフォローしている他のユーザーを取得しています。
`foreign_key: :user_id`で「relationテーブルの`user_id`はこのユーザーを示している」ことを明示しています。
`source: :follower`でrelationテーブルの`follower_id`カラムを使ってフォローしている他のユーザー(Userオブジェクト)を取得します。
<br/>

- 下の２行で user ← passive_relatons ← followersという関連を作り出し、このユーザーをフォローしている他のユーザーを取得しています。
`foreign_key: :follower_id`で「passive_relationsテーブルの`follower_id`はこのユーザーを示している」ということを明示しています。
また、`class_name: 'Relation'`と記載することでpassive_relationsテーブルもRelationモデルのテーブルであることを明示しています。
`source: :user`ではrelationテーブルの`user_id`カラムを使って、自分をフォローしているユーザー(Userオブジェクト)を取得します。


```ruby:relation.rb
class Relation < ApplicationRecord
  belongs_to :user
  belongs_to :follower, class_name: 'User'
end
```
relationモデルがuserとfollowerに属することを設定しています。
その中でfollowerもUserモデルであることを明示しています。

```ruby:users_controller.rb
  before_action :set_followers, only: %i[unfollow]
  # ログインしていない場合、ログインページへ遷移させる
  before_action :authenticate_user!, only: %i[show edit update follow unfollow]

  def follow
    @relation = current_user.relations.build(follower_id: params[:id])
    if @relation.save
      redirect_to home_index_path, notice: 'フォローしました。'
    else
      redirect_to home_index_path, alert: 'フォローに失敗しました。'
    end
  end

  def unfollow
    @relation.destroy!
    redirect_to home_index_path, notice: 'フォローを解除しました。'
  end
  
  private

  def set_followers
    @relation = current_user.relations.find_by(follower_id: params[:id])
  end
```

## モデル以外で実装時のポイント
Followerモデルを削除してUser, Relationモデルだけで実装した時に最初は下にある変更前の内容で実装していました。
```ruby:users_controller.rb(変更前)の抜粋
@relation = Relation.new(user_id: current_user.id, follower_id: params[:id])

def set_followers #既存のフォロー関係を削除する為に特定する
    @relation = Relation.find_by(follower_id: params[:id])
end
```
後で調べていて`build`メソッドを使えばパフォーマンス改善やセキュリティ面の向上を見られることが分かりました。
```ruby:users_controller.rb(変更後)の抜粋
@relation = current_user.relations.build(follower_id: params[:id])

def set_followers #既存のフォロー関係を削除する為に特定する
    @relation = current_user.relations.find_by(follower_id: params[:id])
end
```
変更前の`Relation.new(user_id: current_user.id, follower_id: params[:id])`でも機能はするのですが、変更後のよう`build`メソッドを使えばアソシエーションを考慮したインスタンスの生成ができます。
　また、`@relation = Relation.find_by(follower_id: params[:id])`はお勧めできないです。
変更後のように`@relation = current_user.relations.find_by(follower_id: params[:id])`とすべきです。
理由は２つあります。

- 理由１：パフォーマスが悪い
`@relation`はフォローする人、される人の関係を保存する為のインスタンス変数なのですが、片方のフォローする側はログインしているユーザーとなります。よって、ログインしているユーザーが関わる関係に限定するべきです。変更前は`Relation...`とRelationのテーブル全体から探し出そうとしています。
　一方で、変更後は`current_user...`とログインユーザーに関わるオブジェクトに絞ることで検索をかける母数に制限をかけています。限定することで全体ではなく、限られた中から探すので検索の手間を短縮できます。この制作しているサービスはDeviseというGemを導入しているので、ログインユーザーを`current_user`メソッドで取得できます。
<br/>

- 理由２：セキュリティに問題がある
理由１と内容が若干重複します。
変更前は`Relation...`とRelationテーブルの全体から探すように意図しているので、誤って他のユーザーのフォロー関係にアクセスしてしまう可能性があります。`current_user...`とログインユーザーに関わるフォロー関係に限定することで無関係のフォロー関係にアクセスする可能性を無くすことができます。

## 参考にしたサイト
[【Rails】buildメソッド結構有能かもしれない](https://qiita.com/PDC-Kurashinak/items/413059bf52c7ab14ed16)
[Railsモデルの関連付けで、buildを使うときのメソッド名](https://gist.github.com/seak0503/7291012e7e99fc8aca80)
[[Rails]フォロー、フォロワー機能](https://qiita.com/nakachan1994/items/e6107fe3003f6515e385)