0
0

More than 3 years have passed since last update.

Railsチュートリアル 第14章 ユーザーをフォローする - Relationshipモデルにおけるデータモデルの問題 (および解決策)

Posted at

「フォロー」という関係の定義

今回開発を行うサンプルアプリケーションでは、ユーザーとユーザーの関係性として、「フォロー(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という感じですね。

「ユーザーが多数のフォロワーを持っている」という方向性はすぐに行き詰まる

「ユーザーが多数のフォロワーを持っている」という関係性を素朴にモデリングすると、以下のようなモデルになります。

Naive followship.png

このモデルは何が問題なのでしょう。Railsチュートリアル本文では、「非常に無駄が多いことが問題である」としています。より具体的には、以下のような問題があります。

  • followingテーブル側にも、nameemailといった属性が存在する
    • これらはいずれも、usersテーブルに既に存在する属性である
  • このままで行くと、followersをモデリングする際にも、同じくらい無駄が多いテーブルが定義されてしまうのが避けられない
    • 「ユーザーは多数のユーザーにフォローされている」という関係のモデリング

このような無駄の多い実装の存在は、運用側から見れば悪夢といえます。ユーザー名やメールアドレスを変更するたびに、usersテーブルの当該属性のみならず、followingテーブルやfollowersテーブルの当該属性を更新しなければならない…考えたくない実装ですね。トリガー等を使わなければならなそうです。リレーションシップ外の結合が発生しそうです。

ユーザーにfollowingfollowersといったカラムをもたせるわけにもいかない

「ユーザーにfollowingfollowersというカラムをもたせ、フォローしている・フォローされているユーザーのIDをカンマ区切りで入れていく」という実装は、「Jaywalking」と呼ばれ、RDBのテーブル設計として著名なアンチパターンです。

このような実装がもたらす弊害については、例えば以下のような記事にて言及されています。

どんな実装が適切なのか

適切な実装を実現するために、「followingの動作を、RESTアーキテクチャを使ってどのように実装するか」ということを考えてみます。

RESTアーキテクチャというのは、「リソースの生成や削除に対して何らかのアクションを割り当てる」というのが基本方針でしたね。「ユーザーをフォローする」という操作では、「何らかのリソースが生成される」という動作が発生するのが自然です。また、「ユーザーのフォローを解除する」という操作では、「何らかのリソースが削除される」という動作が発生するのが自然です。

ユーザーのフォロー状態の変化によって生成ないしは削除されるリソース。それは、「任意の2人のユーザー間における関係(リレーションシップ)」とするのが妥当でしょう。すなわち、以下のような関係性ですね。

  • 1人のユーザーは、多くの関係を持つことができる
  • リレーションシップを通じて、1人のユーザーは多くのfollowing(またはfollowers)という関係を持つことができる

多対多の関係性

ユーザー同士のfollowingおよびfollowersという関係は、より詳しく説明すると以下のようになります。

  • 1人のユーザーは、0または1人以上のユーザーをフォローすることができる
  • 1人のユーザーは、0または1人以上のユーザーからフォローされることができる

Followship.png

このような関係は「多対多」といいます。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_idfollowed_idのみとなります。

これを踏まえて、「1人のユーザーが0人以上の複数のユーザーをフォローし、1人のユーザーが0人以上の複数のユーザーにフォローされる」という関係をER図にすると以下のようになります。

Followship with Cross Table.png

能動的関係の実際のユースケースは、以下のような図で模式化することができます。

Active_Relationship.png

上記模式図には、Railsで用いるキーワードも含まれています。

throughは初めて見るキーワードですね。「Rails上で、多対多のリレーションシップを交差テーブルを用いて表現する」という場合に用いるキーワードです。より詳細な意味については後で説明します。Railsガイドにおける、has_many :through関連付けについての説明も参考になるでしょう。

ここまでの内容を踏まえて、新たに生成するテーブルrelationshipsの全体像

能動的関係も受動的関係も、最終的には同じ構造のテーブルを使うことになります。したがって、テーブルの名前は「relationships」とするのが自然です。Railsの慣習に従い、モデル名はRelationshipとしましょう。relationshipsテーブルの全体像は以下のようになります。

relationships.png

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の値の組は一意である」という制約も必要です。「あるユーザーが別の同一ユーザーを多重にフォローする」というユースケースは想定しない仕様であるためです。

db/migrate/[timestamp]_create_relationships.rb
  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) =====================

実は、最初マイグレーションの記述でミスをしてしまった

私自身は、このセクションに取り掛かったとき、最初マイグレーションの反映に失敗しました。理由は、以下のようなコードの記述間違いをしたためです。

db/migrate/[timestamp]_create_relationships.rb(NG)
  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

存在しないテーブルに対してインデックスは生成できませんよね。はい。


  1. 中間テーブルや関連テーブルなどとも呼ばれます。 

  2. より正確には、「Active Recordの、当該複合キーインデックスを定義したいテーブルの生成に対するマイグレーション内」となります。 

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0