あまブログ

ドキドキ......ドキドキ2択クイ〜〜〜〜〜〜〜ズ!!

【Rails】ユーザーフォロー機能を実装する

この記事では、RailsTwitterInstagramのようなユーザーフォロー機能を実装する際のポイントを紹介します。

1. バージョン情報

2. 実装時のポイント

2-1. モデルの関連

Friendshipモデルの作成

$ rails g model Friendship follower_id:integer followed_id:integer --no-fixture --no-test-framework
  • follower_id:フォロー(という行為)をしているユーザーのid
    • 自分にとってのフォロワーじゃなくてフォローをしている人という意味のフォロワー(⇄フォロイー)
  • followed_id:フォローされてるユーザーのid
    • 意味的にはfollowee_idでもいい

生成されたdb/migrate/XXXX_create_friendships.rbに以下を追記。

add_index :friendships, :followed_id
add_index :friendships, [:follower_id, :followed_id], unique: true
  • add_index :friendships, [:follower_id, :followed_id], unique: true:follower_idで始まっているため、add_index :friendships, :follower_idは定義不要
  • unique: trueでデータベース側からユニーク制約をつけ、あるユーザーが同じユーザーを2回以上フォローすることを防ぐ(モデル側のバリデーションによる一意性チェックは後述)
$ rails db:migrate

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
  • validates :follower_id, uniqueness: { scope: :followed_id }
    • モデル側のバリデーションによる一意性チェック
    • scopeを指定することでfollower_idfollowed_idの組み合わせの一意性を保つ(あるユーザーが同じユーザーを2回以上フォローすることを防ぐ)

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
  • class_name
    • class_nameオプションはhas_manyで指定した関連付け名と実際のモデル名が違う場合に使用するもの。今回のように一つのFriendshipモデルに対して二つの関連(active_friendshipspassive_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と対応付けることができる。
    • Railsガイド: Active Record の関連付け - 4.1.2.2 :class_name
  • foreign_key
  • dependent: :destroy
  • inverse_of
# inverse_ofの挙動
irb> a = User.first
irb> b = a.active_friendships.first
irb> a.name == b.follower.name
=> true
irb> a.name = 'David'
irb> a.name == b.follower.name
=> true # inverse_ofを指定しないとfalse
# user(自分)が相手をフォローした場合
user.active_friendships.first.follower #=> 自分
user.active_friendships.first.followed #=> 相手
user.passive_friendships.first.follower #=> 相手
user.passive_friendships.first.followed #=> 自分

2-2. フォロー数・フォロワー数の表示

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

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>

config/locales/ja.yml

ja:
  users:
    stats:
      followings: "%{count} フォロー"
      followers: "%{count} フォロワー"

2-3. フォロー一覧・フォロワー一覧画面

config/routes.rb

  resources :users, only: %i[index show] do
    resources :followings, only: [:index], module: :users
    resources :followers, only: [:index], module: :users
  end
HTTP verb Path Controller#Action Helper
GET /users/:user_id/followings users/followings#index user_followings_path
GET /users/:user_id/followers users/followers#index user_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></th>
    </tr>
    </thead>

    <tbody>
    <% users.each do |user| %>
      <tr>
        <td><%= user.email %></td>
        <td><%= user.name %></td>
        <td><%= link_to t('views.common.show'), user %></td>
      </tr>
    <% end %>
    </tbody>
  </table>
<% else %>
  <p>データがありません。</p>
<% end %>

config/locales/ja.yml

ja:
  activerecord:
    models:
      user: ユーザ
    attributes:
      user:
        email: Eメール
        name: 氏名
  views:
    common:
      show: 詳細
      back: 戻る
  users:
    followings:
      index:
        title: フォロー
    followers:
      index:
        title: フォロワー

2-4. フォロー・フォロー解除機能

パターン1:resource(idなし)

config/routes.rb

  resources :users, only: %i[index show] do
    resource :friendships, only: %i[create destroy]
    # 省略
  end
HTTP verb Path Controller#Action Helper
DELETE /users/:user_id/friendships friendships#destroy user_friendships_path
POST /users/:user_id/friendships friendships#create user_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

app/controllers/friendships_controller.rb

class FriendshipsController < ApplicationController
  before_action :set_user

  def create
    current_user.follow(@user)
    redirect_to user_path(@user), notice: t('.notice')
  end

  def destroy
    current_user.unfollow(@user)
    redirect_to user_path(@user), notice: t('.notice')
  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

<% if current_user.following?(user) %>
  <%= form_with(url: user_friendships_path(user), method: :delete, local: true) do |f| %>
    <%= f.submit t('.destroy') %>
  <% end %>
<% elsif current_user != user %>
  <%= form_with(url: user_friendships_path(user), local: true) do |f| %>
    <%= f.submit t('.create') %>
  <% end %>
<% end %>

config/locales/ja.yml

ja:
  users:
    follow_form:
      create: フォローする
      destroy: フォロー解除する
  friendships:
    create:
      notice: フォローしました。
    destroy:
      notice: フォロー解除しました。

パターン2:resources(idあり)

config/routes.rb

  resources :users, only: %i[index show] do
    resources :friendships, only: %i[create destroy]
    # 省略
  end
HTTP verb Path Controller#Action Helper
POST /users/:user_id/friendships friendships#create user_friendships_path
DELETE /users/:user_id/friendships/:id friendships#destroy user_friendship_path

app/models/user.rb

class User < ApplicationRecord
  # 省略
  def following?(user)
    followings.include?(user)
  end
end

app/controllers/friendships_controller.rb

class FriendshipsController < ApplicationController
  before_action :set_user

  def create
    current_user.active_friendships.create!(followed: @user)
    redirect_to @user, notice: t('.notice')
  end

  def destroy
    current_user.active_friendships.find(params[:id]).destroy
    redirect_to @user, notice: t('.notice')
  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

<% if current_user.following?(user) %>
  <% friendship = current_user.active_friendships.find_by(followed: user) %>
  <%= form_with(model: [user, friendship], method: :delete, local: true) do |f| %>
    <%= f.submit t('.destroy') %>
  <% end %>
<% elsif current_user != user %>
  <%= form_with(model: [user, current_user.active_friendships.build], local: true) do |f| %>
    <%= f.submit t('.create') %>
  <% end %>
<% end %>
  • form_with(model: [user, friendship], ~ )

【参考】