noggy’s blog

自分用の備忘録です。。。

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で識別可能なユーザーでなければならない。

さらに、それぞれの行はユーザーなので、これらのユーザーに名前やパスワードなどの属性を追加する。

f:id:nogicchi:20210821100404p:plain
フォローしているユーザーの素朴な実装例

上の図のデータモデルの問題点は、非常に無駄が多いこと。
各行には、フォローしているユーザーの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テーブルのフォローされているユーザーを見つけるようにする。このデータモデルを模式図にすると、以下のようになる。

f:id:nogicchi:20210821103132p:plain
能動的関係をとおしてフォローしているユーザーを取得する模式図

能動的関係も受動的関係も、最終的にはデータベースの同じテーブルを使うことになる。
したがって、テーブル名にはこの「関係」を表す「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_idfollowed_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_idfollowed_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の関連付けの核心、followingfollowersに取りかかる。

今回は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で取得した集合をより簡単に取り扱うために、followunfollowといった便利メソッドを追加する。これらのメソッドは、例えば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による関連付けを使ってfollowunfollowfollowing?メソッドを実装していく。このとき、可能な限り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_idfollowed_idを入れ替えるだけで、フォロワーについてもフォローする場合と全く同じ活用が出来る。

データモデルは次のとおり。

f:id:nogicchi:20210821134801p:plain
Relationshipモデルのカラムを入れ替えてつくった、フォロワーのモデル


上のデータモデルの実装を次に示す。
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

上のテストでは、実際には多くの処理が正しく動いていなければパスしない。つまり、受動的関係に対するテストは実装の影響を受けやすい。
この時点で、全てのテストはパスする。