「フォロー」という関係の定義
今回開発を行うサンプルアプリケーションでは、ユーザーとユーザーの関係性として、「フォロー(follow)」という関係を定義します。フォロー関係のより具体的な定義は以下の通りです。
- 1人のユーザーは複数のユーザーをフォローする
- 1人のユーザーは複数のユーザーによってフォローされる
- フォロー関係は左右非対称である
- 「AがBをフォローする」と「BがAをフォローする」は異なるものである
- この点は、例えばFacebook等における「友好(friendship)」とは異なる関係である
テーブル名の定義
Railsチュートリアル本文では、フォロー関係のテーブル名の定義として、以下のようなルールを採用することとしています。
- あるユーザーをフォローしているユーザーの集合は
followers
とする - あるユーザーがフォローしているユーザーの集合は
following
とする
「AはBのフォロワーである」を英語にすると「A is a follower of B」となります。一方、「BはAによってフォローされている」を英語にすると「B is followed by A」となります。
followerの複数形はfollowersであり、user.followers
というテーブルは「user
をフォローしているユーザーの集合」として自然な名前です。しかし、followedという語を複数形にはできません。「user
がフォローしているユーザーの集合」というテーブルの名前はどうすればいいのでしょうか。followeds
ですか?いや、それはさすがにまずいでしょう。英語の文法から逸脱しますし、見苦しいですし…
というわけで、「あるユーザーがフォローしているユーザーの集合」というテーブルは、Twitterに倣ってfollowing
という呼称を採用することとします。user.following
という感じですね。
「ユーザーが多数のフォロワーを持っている」という方向性はすぐに行き詰まる
「ユーザーが多数のフォロワーを持っている」という関係性を素朴にモデリングすると、以下のようなモデルになります。
このモデルは何が問題なのでしょう。Railsチュートリアル本文では、「非常に無駄が多いことが問題である」としています。より具体的には、以下のような問題があります。
-
following
テーブル側にも、name
やemail
といった属性が存在する- これらはいずれも、
users
テーブルに既に存在する属性である
- これらはいずれも、
- このままで行くと、
followers
をモデリングする際にも、同じくらい無駄が多いテーブルが定義されてしまうのが避けられない- 「ユーザーは多数のユーザーにフォローされている」という関係のモデリング
このような無駄の多い実装の存在は、運用側から見れば悪夢といえます。ユーザー名やメールアドレスを変更するたびに、users
テーブルの当該属性のみならず、following
テーブルやfollowers
テーブルの当該属性を更新しなければならない…考えたくない実装ですね。トリガー等を使わなければならなそうです。リレーションシップ外の結合が発生しそうです。
ユーザーにfollowing
やfollowers
といったカラムをもたせるわけにもいかない
「ユーザーにfollowing
やfollowers
というカラムをもたせ、フォローしている・フォローされているユーザーのIDをカンマ区切りで入れていく」という実装は、「Jaywalking」と呼ばれ、RDBのテーブル設計として著名なアンチパターンです。
このような実装がもたらす弊害については、例えば以下のような記事にて言及されています。
どんな実装が適切なのか
適切な実装を実現するために、「following
の動作を、RESTアーキテクチャを使ってどのように実装するか」ということを考えてみます。
RESTアーキテクチャというのは、「リソースの生成や削除に対して何らかのアクションを割り当てる」というのが基本方針でしたね。「ユーザーをフォローする」という操作では、「何らかのリソースが生成される」という動作が発生するのが自然です。また、「ユーザーのフォローを解除する」という操作では、「何らかのリソースが削除される」という動作が発生するのが自然です。
ユーザーのフォロー状態の変化によって生成ないしは削除されるリソース。それは、「任意の2人のユーザー間における関係(リレーションシップ)」とするのが妥当でしょう。すなわち、以下のような関係性ですね。
- 1人のユーザーは、多くの関係を持つことができる
- リレーションシップを通じて、1人のユーザーは多くの
following
(またはfollowers
)という関係を持つことができる
多対多の関係性
ユーザー同士のfollowing
およびfollowers
という関係は、より詳しく説明すると以下のようになります。
- 1人のユーザーは、0または1人以上のユーザーをフォローすることができる
- 1人のユーザーは、0または1人以上のユーザーからフォローされることができる
このような関係は「多対多」といいます。Railsチュートリアルの第13章までには登場しなかった関係ですね。多対多の関係は、そのままではRDB上に表現することはできません。このような関係をRDB上で表現する場合、交差テーブル1を使って「複数の1対多の関係」に分解する必要があります。
「フォロー」という関係は左右非対称である
Twitterのようなフォロー関係においては、例えば「CalvinがHobbesをフォローしていても、HobbesはCalvinをフォローしていない」という関係が発生する場合があります。このような関係を「左右非対称」と呼ぶこととします。
左右非対称な関係をアプリケーションで実装するためには、「AはBをフォローしている」という関係と、「AはBにフォローされている」という関係は明確に区別する必要があります。それぞれに対応する実装が必要になるのですね。
Railsチュートリアル本文では、それぞれの関係性を指す語を以下のように定義しています。
- 「AはBをフォローしている」というような関係…「能動的関係(Active Relationship)」
- 「AはBにフォローされている」というような関係…「受動的関係(Passive Relationship)」
「能動的関係」をどう実装していくか
受動的関係の実装は後にして、まずは能動的関係の実装を行っていきましょう。
前述「『ユーザーが多数のフォロワーを持っている』という関係性の素朴なモデリング」において、以下の事実は重要です。
- フォローしているユーザーは、
followed_id
があれば識別することができる
一方で、フォロー関係の表現にあたっては、ユーザーid以外のユーザー情報は必要ありません。なので、「フォロー関係を表現するためのテーブル」に必要な列は、follower_id
とfollowed_id
のみとなります。
これを踏まえて、「1人のユーザーが0人以上の複数のユーザーをフォローし、1人のユーザーが0人以上の複数のユーザーにフォローされる」という関係をER図にすると以下のようになります。
能動的関係の実際のユースケースは、以下のような図で模式化することができます。
上記模式図には、Railsで用いるキーワードも含まれています。
through
は初めて見るキーワードですね。「Rails上で、多対多のリレーションシップを交差テーブルを用いて表現する」という場合に用いるキーワードです。より詳細な意味については後で説明します。Railsガイドにおける、has_many :through
関連付けについての説明も参考になるでしょう。
ここまでの内容を踏まえて、新たに生成するテーブルrelationships
の全体像
能動的関係も受動的関係も、最終的には同じ構造のテーブルを使うことになります。したがって、テーブルの名前は「relationships
」とするのが自然です。Railsの慣習に従い、モデル名はRelationshipとしましょう。relationships
テーブルの全体像は以下のようになります。
Relationshipモデルに対応するマイグレーションを生成する
上記Relationshipモデルに対応するマイグレーションを生成するコマンドは以下のようになります。
# rails generate model Relationship follower_id:integer followed_id:integer
上記のコマンドの実行結果は以下のようになるはずです。
# rails generate model Relationship follower_id:integer followed_id:integer
invoke active_record
create db/migrate/[timestamp]_create_relationships.rb
create app/models/relationship.rb
invoke test_unit
create test/models/relationship_test.rb
create test/fixtures/relationships.yml
relationships
テーブルに必要なインデックスを追加する
relationships
テーブルに対しては、今後follower_id
およびfollowed_id
で頻繁に検索を実行することになります。これらの情報を検索するのに、いちいちテーブル全体を検索するのでは、「将来テーブル規模が大きくなった場合のパフォーマンス」という点で話になりません。
また、「follower_id
の値とfollowed_id
の値の組は一意である」という制約も必要です。「あるユーザーが別の同一ユーザーを多重にフォローする」というユースケースは想定しない仕様であるためです。
class CreateRelationships < ActiveRecord::Migration[5.1]
def change
create_table :relationships do |t|
t.integer :follower_id
t.integer :followed_id
t.timestamps
end
+ add_index :relationships, :follower_id
+ add_index :relationships, :followed_id
+ add_index :relationships, [:follower_id, :followed_id], unique: true
end
end
複合キーインデックス
RDB側で「follower_id
の値とfollowed_id
の値の組は一意である」という制約を実装するためには、「複合キーインデックス」というテクニックを使います。Rails2で複合キーインデックスを定義するためには、add_index
の第2引数に「複合キーインデックスを構成する全てのカラムの組」を配列で与えます。「一意である」という制約は、オプションハッシュのunique:true
により与えられます。
add_index :relationships, [:follower_id, :followed_id], unique: true
実際にrelationships
テーブルを生成する
ここまでの実装が完了すれば、実際にrelationships
テーブルを生成することができます。いつものrails db:migrate
コマンドですね。
記述したマイグレーションの内容に問題がなければ、以下のようなログを出力してマイグレーションが完了します。
# rails db:migrate
== [timestamp] CreateRelationships: migrating ==============================
-- create_table(:relationships)
-> 0.0143s
-- add_index(:relationships, :follower_id)
-> 0.0025s
-- add_index(:relationships, :followed_id)
-> 0.0053s
-- add_index(:relationships, [:follower_id, :followed_id], {:unique=>true})
-> 0.0053s
== [timestamp] CreateRelationships: migrated (0.0360s) =====================
実は、最初マイグレーションの記述でミスをしてしまった
私自身は、このセクションに取り掛かったとき、最初マイグレーションの反映に失敗しました。理由は、以下のようなコードの記述間違いをしたためです。
class CreateRelationships < ActiveRecord::Migration[5.1]
def change
create_table :relationships do |t|
t.integer :follower_id
t.integer :followed_id
t.timestamps
+ end # <- endの位置はこっちが正しい
add_index :relationships, :follower_id
add_index :relationships, :followed_id
add_index :relationships, [:follower_id, :followed_id], unique: true
- end # <- このendは間違い
end
end
存在しないテーブルに対してインデックスは生成できませんよね。はい。