この記事では、RailsでTwitterやInstagramのようなユーザーフォロー機能を実装する際のポイントを紹介します。
1. バージョン情報
- macOS:12.6
- Ruby:3.1.2
- Rails:6.1.6
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_idとfollowed_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_nameclass_nameオプションはhas_manyで指定した関連付け名と実際のモデル名が違う場合に使用するもの。今回のように一つのFriendshipモデルに対して二つの関連(active_friendshipsとpassive_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- 今回の場合、Railsは自動的に
friendshipsテーブルのuser_idを探しに行くが、friendshipsテーブルにuser_idカラムはないので、foreign_keyオプションで明示的にfollower_id(またはfollowed_id)を指定する必要がある - Railsガイド: Active Record の関連付け - 4.1.2.5 :foreign_key
- 今回の場合、Railsは自動的に
dependent: :destroy- Userモデルのデータリソースが削除されるとそれに紐づくFriendshipモデルのデータリソースも同時に削除される
- Railsガイド: Active Record の関連付け - 4.1.2.4 :dependent
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
has_many :through- フォローするユーザーとフォローされるユーザーの多対多
- Railsガイド: Active Record の関連付け - 2.4 has_many :through関連付け
source: :followedsourceオプションがないと、Railsはfriendshipsテーブルからfollowing_idを探しに行ってしまうので、source: :followedによりfriendshipsテーブルのfollowed_idを対象とする- Railsガイド: Active Record の関連付け - 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>
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
module: :users
| 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
find_or_create_by!friendship&.destroy!
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], ~ )form_withはURLを生成するのにpolymorphic_pathを使っている
【参考】