今回は、TwitterやInstagramのようなユーザーのフォロー機能を実装し、多対多のモデルの関連付けを学びました。
ここでは「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_id
、followed_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
Ruby on Railsで用意されているActive Recordのメソッドの1つで、1つのモデルが別のモデルに属することを示す。
つまり上記は、belongs_toとは?▶︎
follower
とfollowed
がUserモデルに属することを定義している。
validates :follower_id, uniqueness: { scope: :followed_id }
- モデル側のバリデーションによる一意性チェック。
follower_id
とfollowed_id
の組み合わせが一意であることを検証するために使用。 - このvalidates文がUserモデルで使われる場合、
follower_id
とfollowed_id
はUserのカラムの一部であることを意味する。follower_id
がUser Aで、followed_id
がUser Bの場合、このvalidates文により、同じ組み合わせで複数のレコードが作成されなくなる。つまり、あるユーザーが同じユーザーを2回以上フォローすることを防ぐ。(follower_id
とfollowed_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つの関連性を定義している。 Userモデルのデータリソースが削除されるとそれに紐づくFriendshipモデルのデータリソースも同時に削除される。 2つのオブジェクト間の相互参照を定義する際に使用。このオプションは、Rails4以降のバージョンで利用可能で、関連付けを正確に反映させるために使用。 このように、アクティブなフレンドシップと受動的なフレンドシップの2つの関連性を定義することで、ユーザーモデルが自身がフォローしているユーザーとフォローされているユーザーの両方を表現できる。この処理が示していること▶︎
has_many
は、Active Recordのメソッドで、1つのモデルが複数の他のモデルと関連付けられることを示す。ここでは、Userモデルが複数のFriendshipモデルと関連付けられることを定義している。
class_name
has_many
で指定した関連付け名と実際のモデル名が違う場合に使用。今回のように一つのFriendship
モデルに対して二つの関連(active_friendship
と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
と対応づけることができる。foreign_key
foreign_key
パラメータは、UserモデルとFriendshipモデルの関連付けに使用される外部キーを指定。今回の場合、Railsは自動的にfriendships
テーブルのuser_id
を探しに行くが、friendships
テーブルにuser_id
カラムはないため、foreign_key
オプションで明示的にfollower_id
(またはfollowed_id
)を指定する必要がある。dependent: :destroy
inverse_of
inverse_of
を使用すると、関連するモデルを取得する際に、Railsがキャッシュを使用して関連するモデルのインスタンスを再検索する必要がなくなる。これにより、クエリの回数が減り、アプリケーションのパフォーマンスが向上。
rails4.1以降では指定せずともrails側が自動でつけてくれるが、自分で定義した関連には明示的にオプションを付ける必要がある。今回の場合、Friendshipモデルにもfollower
とfollowed
の逆方向の関連性を定義することで、より効率的なクエリーを生成することができる。
参考
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対多の関係であることが自明になる。
source: :followed
has_many :through
アソシエーションにおいて、中間モデルで参照する先のモデルを指定するために使用される。source
オプションがないと、Railsはfriendships
テーブルからfollowing_id
を探しに行ってしまうので、source: :followed
によりfriendships
テーブルのfollowed_id
を対象とする。
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
module: :users
HTTP動詞 | パス | コントローラ#アクション | 名前付きルーティングヘルパー |
---|---|---|---|
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><%= 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
resource
HTTP動詞 | パス | コントローラ#アクション | 名前付きルーティングヘルパー |
---|---|---|---|
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!
&.
演算子は、レシーバーがnil
の場合に、メソッド呼び出しを行わずにnil
を返すためのもの。つまり、friendship
がnil
の場合には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 を特定することができるため、Friendship
のid
をURLに含める必要はない。
まとめ
応用的な関連付け、ルーティング、画面設計等を学ぶことができました。 まだ不慣れですが、こちらも便利な機能なので、使いこなせるように実務でもしっかりと学んでいきたいです!