【Rails】フォロー機能の設計と実装

フォロー機能の設計から実装までの情報を整理しました。

実現したいこと

次の2つの機能を実装したい。

  • 自分以外のユーザーをフォローできる
  • フォローしているユーザーフォローされているユーザー を取得できる

フォロー機能のテーブル設計

上記のフォロー機能は次の2つのテーブルで実現できます。

  • ユーザーテーブル: 全ユーザーのIDを保存
  • フォロー関係テーブル: フォローしてる人フォローされる人 にそれぞれユーザーテーブルのIDを保存

フォロー関係テーブルは中間テーブルと呼ばれ、多:多を表現するのに使われます。 フォローしてる人フォローされる人の関係性をこのフォロー関係テーブルに保存します。 中間テーブルについては下記の記事が非常に分かりやく解説されています。

やさしい図解で学ぶ 中間テーブル 多対多 概念編 - Qiita

1. フォローする関係

フォローする関係はフォローしてる人が自分、フォローされる人が相手として表現できます。

フォローする関係は能動的なフォロー関係なのでactive_friendships とします。

  • フォローしてる人: follow_source
  • フォローされる人: follow_target

2. フォローされる関係

フォローされる関係はフォローされる人が自分、フォローしてる人が相手として表現できます。

フォローする関係は受動的なフォロー関係なのでpassive_friendships とします。

  • フォローしてる人: follow_source
  • フォローされる人: follow_target

3. フォローしているユーザー

フォローしているユーザー はフォロー関係テーブルからフォローしてる人 を経由してフォローされる人 を探すことで取得できます。

フォローしているユーザーはfollowings とします。

4. フォローされているユーザー

フォローされているユーザー はフォロー関係テーブルからフォローされる人 を経由してフォローしてる人 を探すことで取得できます。

フォローされているユーザーはfollowers とします。

5. 設計の全体像

上記を踏まえた上でフォローしているユーザーfollowings とフォローされているユーザーfollowers を取得する処理の流れは次のようになります。

フォローする関係active_friendshipsとフォローされる関係passive_friendships はフォローしているユーザーfollowingsとフォローされているユーザーfollowers を取得する為に必要な関係性だということが分かります。

実際のER図は次のようになります。

フォロー機能を実装する

上記のテーブル設計を元に実際に実装します。

1. DB作成

Usersテーブルのみ作成されている既存のアプリにフォロー機能を追加するという前提で解説します。 Friendshipsテーブルを作成するためのマイグレーションファイルを作成します。 references型を使った外部キー制約付きカラムの作成はこちらの記事で詳しく解説しています。

【Rails】外部キー制約とは? - 時々とおまわり

# マイグレーションファイル

class CreateFriendships < ActiveRecord::Migration[6.1]
  def change
    create_table :friendships do |t|
      t.references :follow_source, foreign_key: { to_table: :users }, null: false
      t.references :follow_target, foreign_key: { to_table: :users }, null: false

      t.timestamps
    end
    add_index :friendships, %i[follow_source_id follow_target_id], unique: true
  end
end
  • null: false: 空データの保存を禁止する
  • unique: true: テーブル内で重複するデータを禁止する

add_index :friendships, %i[follow_source_id follow_target_id], unique: trueの部分はfollow_source_idfollow_target_id の組み合わせが必ずユニークにする為に指定します(複合キーインデックス)。 このあたりはまだ理解が曖昧なので解説しません笑

2. User、Friendshipモデルへの関連付け

Usersテーブル、Friendshipsテーブルが準備できたので作成されたuserモデルとfriendshipモデルを関連付けます。

# user.rb

class User < ApplicationRecord
  has_many :active_friendships, class_name: 'Friendship', foreign_key: 'follow_source_id', dependent: :destroy, inverse_of: :follow_source
  has_many :passive_friendships, class_name: 'Friendship', foreign_key: 'follow_target_id', dependent: :destroy, inverse_of: :follow_target
  has_many :followings, through: :active_friendships, source: :follow_target
  has_many :followers, through: :passive_friendships, source: :follow_source
end
# friendship.rb

class Friendship < ApplicationRecord
  belongs_to :follow_source, class_name: 'User'
  belongs_to :follow_target, class_name: 'User'
end

下記のオプションについて調べました。

  • class_name
  • foreign_key
  • dependent
  • inverse_of
  • throughsource

class_name

関連付けの相手となるオブジェクト名を関連付け名から生成できない事情がある場合、class_nameオプションを用いてモデル名を直接指定できます。

Active Record の関連付け - Railsガイド

アソシエーションにおけるclass_nameの定義! - Qiita

userモデルで関連先のfriendshipモデルのオブジェクト名をactive_friendshipspassive_friendships として元のモデル名と異なる関連名に変更する為にclass_name で関連先のモデルを指定します。 class_name オプションが無いとactive_friendshipspassive_friendships と同名のモデルを探してしまうのでclass_name: 'Friendship' が必要です。

# user.rb

class User < ApplicationRecord
  has_many :active_friendships, class_name: 'Friendship', ... # 関連名のモデル名をFriendshipに指定
  has_many :passive_friendships, class_name: 'Friendship', ... # 関連名のモデル名をFriendshipに指定
  ...
end

userモデルと同様にfriendshipモデルでは関連先のuserモデルのオブジェクト名をfollow_sourcefollow_target として元のモデル名と異なる名前に指定するのでclass_name: 'User' として関連先のモデルを指定します。

# friendship.rb

class Friendship < ApplicationRecord
  belongs_to :follow_source, class_name: 'User' # 関連名のモデル名をUserに指定
  belongs_to :follow_target, class_name: 'User' # 関連名のモデル名をUserに指定
end

foreign_key

規約名以外の外部キーカラム名をつかうときは、モデルでhas_many, belongs_toを書くときにオプションを追加する必要があります。

[ActiveRecord] foreign_key, class_name

userモデルから参照するfriendshipモデルの外部キーをfollow_sourcefollow_target に変更しているのでforeign_key オプションで参照するカラム名を指定します。

Friendshipsテーブルに保存されたレコードから自分を探す対象のカラム名を下記のように設定します。

  • active_friendships: follow_source の中から自分を探す外部キー指定するのでforeign_key: 'follow_source_id'
  • passive_friendships: follow_target の中から自分を探す外部キーを指定するのでforeign_key: 'follow_target_id'
# user.rb

class User < ApplicationRecord
  has_many :active_friendships, ..., foreign_key: 'follow_source_id', ... # 外部キーを指定
  has_many :passive_friendships, ..., foreign_key: 'follow_target_id', ... # 外部キーを指定
  ...
end

inverse_of

ActiveRecordは規約通りであればこのように自動で双方向関連付けを行いますが、規約から外れた名前をつかうとき、たとえば:foreign_keyや:throughオプションをつかうときには、双方向関連付けを自動認識しません。

has_many, has_one, belongs_to で:foreign_keyオプションなどをつかって規約とは違う命名をするときには、:inverse_ofにつづけて関連先モデルからの関連名を書くことで双方向の関連をActiveRecordへ教えることができます。

[ActiveRecord] 双方向関連付けとinverse_of

Active Record の関連付け - Railsガイド

  • foreign_keythrough オプションを使用した双方向の関連付けの場合はinverse_of オプションで関連先モデルの関連名を明示的に記述することが必要。
  • inverse_of オプションを付与しない場合、rubocopでRails/InverseOf: Specify an :inverse_of option. の警告が出る。

userモデルのactive_friendshipspassive_friendshipsforeign_key で外部キーを指定しているのでinverse_of を使って関連先モデルの関連名を明示的に記述する必要があります。 userモデルからfriendshipモデルのfollow_sourcefollow_target カラムを参照したいので下記を設定します。

  • active_friendships: フォローしてる人 と関連付けたいのでfollow_source に設定
  • passive_friendships: フォローされる人 と関連付けたいのでfollow_target に設定
# user.rb

class User < ApplicationRecord
  has_many :active_friendships, class_name: 'Friendship', ..., inverse_of: :follow_source # 関連先モデルの関連名を指定
  has_many :passive_friendships, class_name: 'Friendship', ..., inverse_of: :follow_target # 関連先モデルの関連名を指定
  ...

inverse_offoreign_key でカラムと外部キーを設定することで最終的に次のことができるようになります。

  • active_friendships: follow_source カラムから外部キーfollow_source_idフォローしてる人 を取得
  • passive_friendships: follow_target カラムから外部キーfollow_target_idフォローされる人 を取得

dependent

オーナーオブジェクトがdestroyされたときに、オーナーに関連付けられたオブジェクトの扱いを制御します。

Active Record の関連付け - Railsガイド

userオブジェクトが削除された場合に関連したfriendshipオブジェクトの扱いを指定しています。 dependent: :destroy とすることで親オブジェクトであるuserオブジェクトが削除されると子オブジェクトのfriendshipオブジェクトも削除されるようになります。

# user.rb

class User < ApplicationRecord
  has_many :active_friendships, class_name: 'Friendship',..., dependent: :destroy, ... # 関連したfriendshipオブジェクトも削除
  has_many :passive_friendships, class_name: 'Friendship',..., dependent: :destroy, ... # 関連したfriendshipオブジェクトも削除
  ...
end

dependent については次の記事がとても参考になります。

dependentオプションまとめ[Ruby on Rails] - Qiita

through、source

多対多の関連をつくるときは、中間テーブルおよびモデルと、has_many :through をつかいます。

:source オプションには:throughで指定したモデルから、取得したいモデルへたどるための関連名を書きます。

:sourceオプションはhas_many関連と、has_one関連で:throughオプションを書いていて、かつ、規約から外れた命名をしているときに指定します。

[ActiveRecud] through, source

N:Nの関連付けで中間テーブルやモデルを通して関連先の値を取得する際に次のことを指定します。

  • 探す対象の中間テーブルやモデルをthroughで指定
  • 取得したいカラムをsourceで指定
through

オブジェクトを取得する際に経由する関連名をthrough で指定します。

userモデルのfollowingsfollowers はそれぞれ次のような流れでオブジェクトを取得します。

  • followings: active_friendships を経由し、follow_target を取得
  • followers: passive_friendships を経由し、follow_source を取得

userモデルでは関連するfriendshipの関連名をactive_friendships(フォローしている関係)、passive_friendship(フォローされている関係) の2つに分けて関連付けています。 なのでfollowingsfollowersthrough に指定する関連名はfriendshipではなく、active_friendshipspassive_friendships の2つの関連名を指定することに注意してください。

# user.rb

class User < ApplicationRecord
  has_many :active_friendships, class_name: 'Friendship', ...
  has_many :passive_friendships, class_name: 'Friendship', ...
  has_many :followings, through: :active_friendships, ... # 経由する関連名を指定
  has_many :followers, through: :passive_friendships, ... # 経由する関連名を指定
end
source

through を経由し、取得したいカラムをsourceで指定します。

userモデルのfollowingsfollowers はそれぞれ次のような流れでオブジェクトを取得します。

  • followings: active_friendships を経由し、follow_target を取得
  • followers: passive_friendships を経由し、follow_source を取得

それぞれの取得したいカラム名source で指定します。

# user.rb

class User < ApplicationRecord
  ...
  has_many :followings, through: :active_friendships, source: :follow_target # 取得したいカラム名を指定
  has_many :followers, through: :passive_friendships, source: :follow_source # 取得したいカラム名を指定
end

上記のようにthroughsource で経由する関連名と取得するカラムを設定することで下記画像の流れでfollowingsfollowers を取得することができるようになります。

Railsコンソールで動かしてみる

Railsコンソールを下記の動作を確認してみます。

  1. フォロー関係の作成(create)
  2. フォロー関係の削除(destroy)
  3. フォローしているユーザーの取得(followings)
  4. フォローされてるユーザーの取得(followers)

1. フォロー関係の作成(create)

フォローする関係(active_friendships)のレコードを作成(create)します。 フォローするユーザーをfollow_source、フォローされるユーザーをfollow_target とします。 下記の条件でレコードを作成します。

  • follow_source_id: 51
  • follow_target_id: 1
# フォローするユーザーの取得
> follow_source = User.find(51)
   (0.7ms)  SELECT sqlite_version(*)
  TRANSACTION (0.1ms)  begin transaction
  User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 51], ["LIMIT", 1]]
=> #<User id: 51, email: "karlley.jp@gmail.com", created_at: "2023-01-27 07:08:33.658026000 +0000", updated_at: "2023-01-27 07:08:33.658026000 +0000", name: "kar...

# フォローされるユーザーの取得
> follow_target = User.find(1)
  User Load (1.6ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
=> #<User id: 1, email: "sample-0@example.com", created_at: "2023-01-27 07:07:30.723455000 +0000", updated_at: "2023-01-27 07:07:30.723455000 +0000", name: "丸山...

# フォロー関係の作成
> follow_source.active_friendships.create(follow_target: follow_target)
  TRANSACTION (0.1ms)  SAVEPOINT active_record_1
  Friendship Create (0.5ms)  INSERT INTO "friendships" ("follow_source_id", "follow_target_id", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["follow_source_id", 51], ["follow_target_id", 1], ["created_at", "2023-01-27 07:26:43.494427"], ["updated_at", "2023-01-27 07:26:43.494427"]]
  TRANSACTION (0.0ms)  RELEASE SAVEPOINT active_record_1
=>
#<Friendship:0x000000010de1f830
 id: 1,
 follow_source_id: 51,
 follow_target_id: 1,
 created_at: Fri, 27 Jan 2023 07:26:43.494427000 UTC +00:00,
 updated_at: Fri, 27 Jan 2023 07:26:43.494427000 UTC +00:00>

# フォロー関係の作成を確認
> friendship = Friendship.all
  Friendship Load (0.6ms)  SELECT "friendships".* FROM "friendships"
=>
[#<Friendship:0x000000010e28ef88
...
> friendship
=>
[#<Friendship:0x000000010e28ef88
  id: 1,
  follow_source_id: 51,
  follow_target_id: 1,
  created_at: Fri, 27 Jan 2023 07:26:43.494427000 UTC +00:00,
  updated_at: Fri, 27 Jan 2023 07:26:43.494427000 UTC +00:00>]

ちなみにcreate メソッドの引数はuserオブジェクトでなくidを渡すことでもレコードを作成することもできます。

# createの引数にidを渡す 
follow_source.active_friendships.create(follow_target_id: follow_target.id)

この場合に指定するカラム名follow_target_id: になることに注意してください。

2. フォロー関係の削除(destroy)

フォローする関係(active_friendships)のレコードを削除(destroy)します。 フォロー解除するユーザーをfollow_source、フォロー解除されるユーザーをfollow_target とします。 下記のレコードを検索し、削除します。

  • follow_source_id: 51
  • follow_target_id: 1
# フォロー解除を行うユーザーの取得(createと同じ)
> follow_source = User.find(51)
   (0.7ms)  SELECT sqlite_version(*)
  TRANSACTION (0.1ms)  begin transaction
  User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 51], ["LIMIT", 1]]
=> #<User id: 51, email: "karlley.jp@gmail.com", created_at: "2023-01-27 07:08:33.658026000 +0000", updated_at: "2023-01-27 07:08:33.658026000 +0000", name: "kar...

# フォロー解除されるユーザーの取得(createと同じ)
> unfollow_target = User.find(1)
  User Load (1.6ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
=> #<User id: 1, email: "sample-0@example.com", created_at: "2023-01-27 07:07:30.723455000 +0000", updated_at: "2023-01-27 07:07:30.723455000 +0000", name: "丸山...

# フォロー解除を行うフォロー関係の検索
> friendship = follow_source.active_friendships.find_by(follow_target: unfollow_target)
  Friendship Load (0.2ms)  SELECT "friendships".* FROM "friendships" WHERE "friendships"."follow_source_id" = ? AND "friendships"."follow_target_id" = ? LIMIT ?  [["follow_source_id", 51], ["follow_target_id", 1], ["LIMIT", 1]]
=>
#<Friendship:0x000000010e3c5348
...

# フォロー関係の削除
> friendship.destroy
  TRANSACTION (0.2ms)  SAVEPOINT active_record_1
  Friendship Destroy (0.2ms)  DELETE FROM "friendships" WHERE "friendships"."id" = ?  [["id", 1]]
  TRANSACTION (0.1ms)  RELEASE SAVEPOINT active_record_1
=>
#<Friendship:0x000000010e3c5348
 id: 1,
 follow_source_id: 51,
 follow_target_id: 1,
 created_at: Fri, 27 Jan 2023 07:44:37.779486000 UTC +00:00,
 updated_at: Fri, 27 Jan 2023 07:44:37.779486000 UTC +00:00>

# フォロー関係の削除を確認
> friendship = follow_source.active_friendships.find_by(follow_target: follow_target)
  Friendship Load (0.6ms)  SELECT "friendships".* FROM "friendships" WHERE "friendships"."follow_source_id" = ? AND "friendships"."follow_target_id" = ? LIMIT ?  [["follow_source_id", 51], ["follow_target_id", 1], ["LIMIT", 1]]
=> nil

上記の条件のレコードが削除されました。

3. フォローしているユーザーの取得(followings)

フォローしているユーザーをfollowings を取得します。 followings を取得するユーザーをuser とします。 下記の条件でfollowings が取得できるか確認します。

  • user: 51
  • followings: 1, 2, 3
# followingsを取得するユーザーを取得
> user = User.find(51)
   (0.6ms)  SELECT sqlite_version(*)
  TRANSACTION (0.0ms)  begin transaction
  User Load (0.6ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 51], ["LIMIT", 1]]
=> #<User id: 51, email: "karlley.jp@gmail.com", created_at: "2023-01-27 07:08:33.658026000 +0000", updated_at: "2023-01-27 07:08:33.658026000 +0000", name: "kar...

# followingsの取得
> user.followings
  User Load (0.7ms)  SELECT "users".* FROM "users" INNER JOIN "friendships" ON "users"."id" = "friendships"."follow_target_id" WHERE "friendships"."follow_source_id" = ?  [["follow_source_id", 51]]
=>
[#<User id: 1, email: "sample-0@example.com", created_at: "2023-01-27 07:07:30.723455000 +0000", updated_at: "2023-01-27 07:07:30.723455000 +0000", name: "丸山 翔", postal_code: "123-0000", address: "2859 碧, 美桜村, 42 029-4442", self_introduction: "こんにちは、丸山 翔です。">,
 #<User id: 2, email: "sample-1@example.com", created_at: "2023-01-27 07:07:30.966294000 +0000", updated_at: "2023-01-27 07:07:30.966294000 +0000", name: "中野 悠人", postal_code: "123-0001", address: "35116 愛美, 横山区, 6 626-0017", self_introduction: "こんにちは、中野 悠人です。">,
 #<User id: 3, email: "sample-2@example.com", created_at: "2023-01-27 07:07:31.209164000 +0000", updated_at: "2023-01-27 07:07:31.209164000 +0000", name: "青木 大輝", postal_code: "123-0002", address: "Suite 955 98423 山田, 西今井村, 31 080-0861", self_introduction: "こんにちは、青木 大輝です。">]

フォローしているユーザーfollowings が取得できました。

4. フォローされてるユーザーの取得(followers)

フォローされてるユーザーfollowers を取得します。 followers を取得するユーザーをuser とします。 下記の条件でfollowers が取得できるか確認します。

  • user: 1
  • followings: 51
# followersを取得するユーザーを取得
> user = User.find(1)
  User Load (0.2ms)  SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
=> #<User id: 1, email: "sample-0@example.com", created_at: "2023-01-27 07:07:30.723455000 +0000", updated_at: "2023-01-27 07:07:30.723455000 +0000", name: "丸山...

# followersの取得
> user.followers
  User Load (2.0ms)  SELECT "users".* FROM "users" INNER JOIN "friendships" ON "users"."id" = "friendships"."follow_source_id" WHERE "friendships"."follow_target_id" = ?  [["follow_target_id", 1]]
=> [#<User id: 51, email: "karlley.jp@gmail.com", created_at: "2023-01-27 07:08:33.658026000 +0000", updated_at: "2023-01-27 07:08:33.658026000 +0000", name: "karlley", postal_code: "0000000", address: "", self_introduction: "">]

フォローされてるユーザーfollowers が取得できました。

まとめ

ややこしくて複雑だったフォロー関係のリレーションについての情報が整理できたので良かった。 理解が曖昧な部分は追って調べていこうと思います。

参照

やさしい図解で学ぶ 中間テーブル 多対多 概念編 - Qiita

【Rails】外部キー制約とは? - 時々とおまわり

Active Record の関連付け - Railsガイド

アソシエーションにおけるclass_nameの定義! - Qiita

[ActiveRecord] foreign_key, class_name

[ActiveRecord] 双方向関連付けとinverse_of

Active Record の関連付け - Railsガイド

Active Record の関連付け - Railsガイド

dependentオプションまとめ[Ruby on Rails] - Qiita

[ActiveRecud] through, source