14.1 Relationshipモデル
ユーザーをフォローする機能を実装する第一歩は、データモデルを構成すること。
ただし、見た目ほど単純ではない。
素朴に考えれば、has_many
(1対多)の関連付けを用いて「1人のユーザーが複数のユーザーをhas_many
としてフォローし、1人のユーザーに複数のフォロワーがいることをhas_many
で表す」
といった方法でも実装できそう。
しかし、この方法ではたちまち壁に突き当たってしまう。これを解決する為のhas_many_through
についても解説する。
14.1.1 データモデルの問題(および解決策)
ユーザーをフォローするデータモデル構成のための第一歩として、典型的な場合を検討してみる。
あるユーザーが、別のユーザーをフォローしているところを考えてみる。具体例を挙げる、
・CalvinはHobbesをフォローしている。イメージ:Calvin→Hobbes
・逆から見れば、HobbesはCalvinからフォローされている。イメージ:Hobbes←Calvin
・CalvinはHobbesから見ればフォロワー(follower)であり、CalvinがHobbesをフォローした(followed)したことになる。
・Railsにおけるデフォルトの複数形の慣習に従えば、あるユーザーをフォローしているすべてのユーザーの集合はfollowers
となり、user.followers
はそれらのユーザーの配列を表すことになる。
・しかし、この名前付けは逆向きではうまくいかない。あるユーザーをフォローしているすべてのユーザーの集合は、followeds
となり、英語の文法からも外れるうえに見苦しいものになる。
・そこで、Twitterの慣習にならい、following
という呼称を採用する(例:"50following、75followers")。
・したがって、あるユーザーがフォローしているすべてのユーザーの集合はcalvin.following
となる。つまり、自分がF1、F2、…をフォローしている(自分→F1、自分→F2、…)のとき、集合{F1,F2,…}がfollowingで、例えばF1さんが、A、B、…にフォローされているとき、{A、B、…}がfollowers
これによりfollowing
テーブルとhas_many
関連付けを使って、フオローしているユーザーのモデリングができる。user.following
はユーザーの集合でなければならないため、following
テーブルのそれぞれの行は、followed_id
で識別可能なユーザーでなければならない。
さらに、それぞれの行はユーザーなので、これらのユーザーに名前やパスワードなどの属性を追加する。
上の図のデータモデルの問題点は、非常に無駄が多いこと。
各行には、フォローしているユーザーのidのみならず、名前やメールアドレスまである。これらはいずれもusers
テーブルに既にあるものばかり。
さらによくないことに、followers
の方をモデリングする時にも、同じくらい無駄の多いfollowers
テーブルを別に作成しなければならなくなってしまう。
結論としては、このデータモデルはメンテナンスの観点から見て悪夢。ユーザー名を変更するたびに、users
テーブルのそのレコードだけでなく、following
テーブルとfollowers
テーブルの両方について、そのユーザーを含む全ての行を更新しなければならなくなる。
この問題の根本は、必要な抽象化を行なっていないことである。
正しいモデルを見つけ出す方法の1つは、Webアプリにおけるfollowing
の動作をどのように実装するかをじっくり考えること。
7.1.2において、RESTアーキテクチャは、作成されたり削除されたりするリソースに関連していたことを思い出してみる。
ここから、2つの疑問点が生ずる。
1.あるユーザーが別のユーザーをフォローする時、何が作成されるか?
2.あるユーザーが別のユーザーをフォロー解除する時、何が削除されるか?
(RESTfulに従って、何をcreateし、何をdestroyするかをしっかり考えよう、ということ?)
この2点を踏まえて考えると、この場合アプリによって作成または削除されるのは、2人のユーザーの「関係(リレーションシップ)」であることがわかる。つまり、1人のユーザーは1対多の関係を持つことができ、さらにユーザーはリレーションシップを経由して多くのfollowing
(またはfollowers
)と関係を持つことができるということ。
このデータモデルには他にも解決しなくてはいけない問題がある。
Facebookのような友好関係(Friendships)では、本質的に左右対称のデータモデルが成り立つが、Twitterのようなフォロー関係では左右非対称の性質がある。すなわち、CalvinはHobbesをフォローしていても、HobbesはCalvinをフォローしていないといった関係性が立つ。
このような左右非対称な関係性を見分けるために、それぞれを能動的関係(Active Relationship)と受動的関係(Passive Relationship)と呼ぶことにする。
例えば先ほどの事例のような、CalvinがHobbesをフォローしているが、HobbesはCalvinをフォローしていない場合では、CalvinはHobbesに対して「能動的関係」を持っていることになる。逆に、HobbesはCalvinに対して「受動的関係」を持っていることになる。
能動的関係
まずは、フォローしているユーザーを生成するために、能動的関係に焦点を当てていく。(受動的関係についてはのちに考える)
先ほどのfollowing
データモデルは実装のヒントになる。
フォローしているユーザーはfollowed_id
があれば識別することができるので、先ほどのfollowing
テーブルをactive_relationships
(能動的関係)テーブルと見立ててみる。ただし、ユーザー情報は無駄なので、ユーザーid以外の情報は削除する。
そして、followed_id
を通して、users
テーブルのフォローされているユーザーを見つけるようにする。このデータモデルを模式図にすると、以下のようになる。
能動的関係も受動的関係も、最終的にはデータベースの同じテーブルを使うことになる。
したがって、テーブル名にはこの「関係」を表す「relationships
」を使う。モデル名はRailsの慣習にならって、Relationshipとする。作成したRelationshipデータモデルを以下に示す。
1つのrelationshipsテーブルを使って2つのモデル(能動的関係と受動的関係)をシミュレートする方法については後で説明する。
relationships | |
---|---|
id | integer |
follower_id | integer |
followed_id | integer |
created_at | datetime |
update_at | datetime |
$ rails g model Relationship follower_id:integer followed_id:integer
このリレーションシップは今後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
①:複合キーインデックスという行もあることに注目!これはfollower_id
とfollowed_id
の組み合わせが必ずユニークであることを保証する仕組み。これにより、あるユーザーが同じユーザーを2回以上フォローすることを防ぐ。もちろん、このような重複(2回以上フォローするなど)が起きないよう、インタフェース側の実装でも注意を払う。
しかし、ユーザーが何らかの方法で(例えばcurl
などのコマンドラインツールを使って)Relationshipのデータを操作するようなことも起こり得る。そのような場合でも、一意なインデックスを追加していれば、エラーを発生させて重複を防ぐことができる。
relationships
テーブルを作成するために、いつものようにDBのマイグレーションを行う。
$ rails db:migrate
14.1.2 User/Relationshipの関連付け
フォローしているユーザーとフォロワーを実装する前に、UserとRelationshipの関連付けを行う。
1人のユーザーにはhas_many
(1対多)のリレーションシップがあり、このリレーションシップは2人のユーザーの間の関係なので、フォローしているユーザーとフォロワーの両方に属している(belongs_to
)。
13.1.3のマイクロポストのときと同様、次のようなユーザー関連付けのコードを使って新しいリレーションシップを作成する。
user.active_relationships.build(followed_id: ...)
この時点で、User/Micropostの関連付けのモデルのようにはならない。
まずは、1つ目の違いについて。以前、ユーザーとマイクロポストの関連付けをしたときは、次を書いた。
class User < ApplicationRecord has_many :microposts end
引数の:microposts
シンボルから、Railsはこれに対応するMicropostモデルを探し出し、見つけることができた。
しかし今回のケースで同じように書くと、
has_many :active_relationships
となってしまい、(ActiveRelationshipモデルを探してしまい)Relationshipモデルを見つけることができない。
このため、今回のケースでは、Railsに探して欲しいモデルのクラス名を明示的に伝える必要がある。
2つ目の違いは、先ほどの逆のケースについて。以前はMicropostモデルで次のように書いた。
class Micropost < ApplicationRecord belongs_to :user : end
microposts
テーブルにはuser_id
属性があるので、これを辿って対応する所有者(ユーザー)を特定できた。
DBの2つのテーブルを繋ぐとき、このようなidは外部キー(foreign key)と呼ぶ。すなわち、Userモデルに繋げる外部キーが、Micropostモデルのuser_id
属性ということ。この外部キーの名前を使って、Railsは関連付けの推測をしている。
具体的には、Railsはデフォルトでは外部キーの名前を< class >_id
といったパターンとして理解し、< class >
に当たる部分からクラス名(正確には小文字に変換されたクラス名)を推測する。
ただし、先ほどはユーザーを例として扱ったが、今回のケースではフォローしているユザーをfollower_id
という外部キーを持って特定しなくてはならないまた、followerというクラス名は存在しないので、ここでもRailsに正しいクラス名を伝える必要が発生する。
先ほどの説明をコードにまとめると、UserとRelationshipの関連付けは以下のようになる。
models/user.rb
class User < ApplicationRecord has_many :microposts, dependent: :destroy has_many :active_relationships, class_name: "Relationship", foreign_key: "follower_id", dependent: :destroy
(ユーザーを削除したら、ユーザーのリレーションシップも同時に削除される必要がある。そのため、関連付けにdependent: :destroy
も追加)
models/relationship.rb
class Relationship < ApplicationRecord belongs_to :follower, class_name: "User" belongs_to :followed, class_name: "User" end
なお、follower
の関連付けはまだ使わない。
上で定義した関連付けにより、次の多くのメソッドが使えるようになった。
メソッド | 用途 |
---|---|
active_relationship.follower | フォロワーを返す |
active_relationship.followed | フォローしているユーザーを返す |
user.active_relationship.create(followed_ir: other_user.id) | userと紐づけて能動的関係を作成/登録 |
user.active_relationship.create!(followed_ir: other_user.id) | userと紐づけて能動的関係を作成/登録(失敗時にエラーを出力) |
user.active_relationship.build(followed_ir: other_user.id) | userと紐づけた新しいRelationsshipオブジェクトを返す |
14.1.3 Relationshipのバリデーション
ここで、Relationshipモデルの検証を追加して完全なものにしておく。
テストコードとアプリケーションコードは素直な作り。
ただし、User用のfixtureファイルと同じように、生成されたRelationship用のfixtureでは、マイグレーションで制約させた一意性を満たすことができない。ということで、今の時点では生成されたRelationship用のfixtureファイルを空にしておく。
test/models/relationship_test.rb
require 'test_helper' class RelationshipTest < ActiveSupport::TestCase def setup @relationship = Relationship.new(follower_id: users(:michael).id, followed_id: users(:archer).id ) end test "should be valid" do assert @relationship.valid? end test "should require a follwer_id" do @relationship.follower_id = nil assert_not @relationship.valid? end test "should require a followed_id" do @relationship.followed_id = nil assert_not @relationship.valid? end end
app/models/relationship.rb
class Relationship < ApplicationRecord belongs_to :follower, class_name: "User" #追加 belongs_to :followed, class_name: "User" validates :follower_id, presence: true #追加 validates :followed_id, presence: true #追加 end
test/fixtures/relationships.yml
# 空にする
これでテストは成功する。
$ rails test
14.1.4 フォローしているユーザー
いよいよRelationshipの関連付けの核心、following
とfollowers
に取りかかる。
今回はhas_many through
を使う。1人のユーザーにはいくつもの「フォローする」「フォローされる」といった関係性がある(こういった関係性を「多対多」と呼ぶ)。デフォルトのhas_many through
という関連付けでは、Railsはモデル名(単数形)に対応する外部キーを探す。つまり、次のコードでは、
has_many :followeds, through: :active_relationships
Railsは「followeds」というシンボル名を見て、これを「followed」という単数形に変え、relationships
テーブルのfollowed_id
を使って対象のユーザーを取得してくる。
しかし、先で指摘したように、user.followeds
という名前は英語としては不適切。代わりに、user.following
という名前を使うことにする。
そのためには、Railsのデフォルトを上書きする必要がある。ここでは:source
パラメーターを使って、「following
配列の元はfollowed
idの集合である」ということを明示的にRailsに伝える。
app/models/user.rb
class User < ApplicationRecord has_many :microposts, dependent: :destroy has_many :active_relationships, class_name: "Relationship", foreign_key: "follower_id", dependent: :destroy has_many :following, through: :active_relationships, source: :followed #追加 :
上で定義した関連付けにより、フォローしているユーザーを配列のように扱える様になった。
例えば、include?
メソッドを使ってフォローしているユーザーの集合を調べてみたり、関連付けを通してオブジェクトを探しだせるようになる。
user.following.include?(other_user) user.following.find(other_user)
following
で取得したオブジェクトは、配列のように要素を追加したり削除したりすることができる。
user.following << other_user user.following.delete(other_user)
<<演算子(Shovel Operator)で配列の最後に追記することができる。
following
メソッドで配列のように扱えるだけでも便利だが、Railsは単純な配列ではなく、もっと賢くこの集合を扱っている。例えば次のようなコードでは、
following.include?(other_user)
フォローしている全てのユーザーをDBから取得し、その集合に対してinclude?
メソッドを実行しているように見えるが、しかし実際はDBの中で直接比較をするように配慮している。
なお、次のようなコードでは
user.microposts.count
DBの中で合計を計算した方が高速になる点に注意する。
次に、followingで取得した集合をより簡単に取り扱うために、follow
やunfollow
といった便利メソッドを追加する。これらのメソッドは、例えばuser.follow(other_user)
といった具合に使う。
さらに、これに関連するfollowing?
論理値メソッドも追加し、あるユーザーが誰かをフォローしているかどうかを確認できるようにする。
今回は、こういったメソッドはテストから先に書いていく。と言うのも、Webインターフェイスなどで便利メソッドを使うのはまだ先なので、すぐに使える場面がなく、実装した手応えを得にくいから。
一方で、Userモデルに対するテストを書くのは簡単かつ今すぐできる。そのテストの中で、これらのメソッドを使っていく。
具体的には、
・following?
メソッドであるユーザーをまだフォロしていないことを確認
・follow
メソッドを使ってそのユーザーをフォローできたことを確認
・following?
メソッドを使ってフォロー中になったことを確認
・unfollow
メソッドでフォロー解除できたことを確認
といった具合でテストしていく。
test/models/user_test.rb
: test "should follow and unfollow a user" do michael = users(:michael) archer = users(:archer) assert_not michael.following?(archer) michael.follow(archer) assert michael.following?(archer) michael.unfollow(archer) assert_not michael.following?(archer) end
following
による関連付けを使ってfollow
、unfollow
、following?
メソッドを実装していく。このとき、可能な限りself
を省略している点に注目。
app/models/user.rb
: def feed : end # ユーザーをフォローする #追加 def follow(other_user) active_relationships.create(followed_id: other_user.id) end # ユーザーをフォロー解除する #追加 def unfollow(other_user) active_relationships.find_by(followed_id: other_user.id).destroy end # 現在のユーザーがフォローしてたらtrueを返す #追加 def following?(other_user) following.include?(other_user) end private : end
上記コードを追加することで、テストは成功する。
14.1.5 フォロワー
リレーションシップというパズルの最後の一片は、user.followers
メソッドを追加すること。
これは上のuser.following
メソッドの対となる。フォロワーの配列を展開するために必要な情報は、relationships
テーブルに既にある。つまり、active_relationships
のテーブルを再利用できそう。
実際、follower_id
とfollowed_id
を入れ替えるだけで、フォロワーについてもフォローする場合と全く同じ活用が出来る。
データモデルは次のとおり。
上のデータモデルの実装を次に示す。
app/models/user.rb
class User < ApplicationRecord has_many :microposts, dependent: :destroy has_many :active_relationships, class_name: "Relationship", foreign_key: "follower_id", dependent: :destroy has_many :passive_relationships, class_name: "Relationship", #追加 foreign_key: "followed_id", dependent: :destroy has_many :following, through: :active_relationships, source: :followed has_many :followers, through: :passive_relationships, source: :follower #追加 : end
1点注意すべきは、次のように参照先(followers
)を指定するための:source
キーを省略してもよかった点。
has_many :followers, through: :passive_relationships
これは、:followers
属性の場合、Railsが「followers」を単数形にして自動的に外部キーfollower_id
を探してくれるから。ただ、必要のない:source
キーをそのまま残しているのは、has_many :following
との類似性を強調させるため。
次に、followers.include?
メソッドを使って先ほどのデータモデルをテストしていく。
テストコードは次の通り。ちなみにfollowing?
と対照的なfollowed_by?
メソッドを定義してもよかったが、サンプルアプリで実際に使う場面がなかったのでこのメソッドは省略している。
test/models/user_test.rb
: test "should follow and unfollow a user" do michael = users(:michael) archer = users(:archer) assert_not michael.following?(archer) michael.follow(archer) assert michael.following?(archer) assert archer.followers.include?(michael) #追加 michael.unfollow(archer) assert_not michael.following?(archer) end
上のテストでは、実際には多くの処理が正しく動いていなければパスしない。つまり、受動的関係に対するテストは実装の影響を受けやすい。
この時点で、全てのテストはパスする。