第14章ユーザーをフォローする
他のユーザーをフォロー(およびフォロー解除)できるソーシャルな仕組みの追加と、
フォローしているユーザーの投稿をステータスフィードに表示する機能を追加します。
ここで学んだデータモデルは、今後自分用のWebアプリケーションを開発するときに必ず役に立ちます。
14.1 Relationshipモデル
ユーザーをフォローする機能を実装する第一歩は、データモデルを構成することです。ただし、これは見た目ほど単純ではありません。
素朴に考えれば、has_many
(1対多) の関連付けを用いて「1人のユーザーが複数のユーザーをhas_many
としてフォローし、1人のユーザーに複数のフォロワーがいることをhas_many
で表す」といった方法でも実装できそうです。しかし後ほど説明しますが、この方法ではたちまち壁に突き当たってしまいます。これを解決するためのhas_many through
についてもこの後で説明します。
14.1.1 データモデルの問題 (および解決策)
あるユーザーをフォローしているすべてのユーザーの集合はfollowersとなりuser.followersはそれらのユーザーの配列を表すことになります。
Twitterの慣習にならい、本チュートリアルではfollowingという
呼称を採用します (例: “50 following, 75 followers”)。
したがって、あるユーザーがフォローしているすべてのユーザーの集合はcalvin.followingとなります。
followingテーブルと has_many関連付けを使って、
フォローしているユーザーのモデリングができます。
user.followingはユーザーの集合でなければならないため、
followingテーブルのそれぞれの行は、followed_idで識別可能なユーザーでなければなりません (これはfollower_idの関連付けについても同様です)。
さらに、それぞれの行はユーザーなので、
これらのユーザーに名前やパスワードなどの属性も追加する必要があるでしょう。
2つの疑問が生じます。
1. あるユーザーが別のユーザーをフォローするとき、何が作成される? 2. あるユーザーが別のユーザーをフォロー解除するとき、何が削除される?。
1人のユーザーは1対多の関係を持つことができ、
さらにユーザーはリレーションシップを経由して多くのfollowing
(またはfollowers) と関係を持つことができるということです。
Facebookのような友好関係 (Friendships) では本質的に
左右対称のデータモデルが成り立ちますが、
Twitterのようなフォロー関係では左右非対称の性質があります。
すなわち、CalvinはHobbesをフォローしていても、
HobbesはCalvinをフォローしていないといった関係性が成り立つのです。
左右非対称な関係性を見分けるために、
それぞれを能動的関係 (Active Relationship)と
受動的関係 (Passive Relationship)と呼ぶことにします
CalvinがHobbesをフォローしているが、
HobbesはCalvinをフォローしていない場合では、
CalvinはHobbesに対して「能動的関係」を持っていることになります。
つまりアイドルとファンの関係のようなものでCalvinはアイドルなわけよ
逆に、HobbesはCalvinに対して「受動的関係」を持っていることになります。
つまりアイドルとファンの関係のようなものでHobbesはファンなわけよ
フォローしているユーザーを生成するために、能動的関係に焦点を当てていきます
フォローしているユーザーはfollowed_idがあれば識別することができるので、
先ほどのfollowingテーブルをactive_relationshipsテーブル
と見立ててみましょう。
ただしユーザー情報は無駄なので、ユーザーid以外の情報は削除します。そして、followed_idを通して、
usersテーブルのフォローされているユーザーを見つけるようにします。
テーブル名にはこの「関係」を表す「relationships」を使いましょう。モデル名はRailsの慣習にならって、Relationshipとします。
マイグレーションを生成します。
rails generate model Relationship follower_id:integer followed_id:integer
このリレーションシップは今後follower_idとfollowed_idで頻繁に
検索することになるので、それぞれのカラムにインデックスを追加します。
relationshipsテーブルにインデックスを追加する
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回以上フォローすることを防ぎます。インターフェイス側の実装でも注意を払います。
relationshipsテーブルを作成するために、
いつものようにデータベースのマイグレーションを行います。
rails db:migrate
演習
1:id=1のユーザーに対してuser.following.map(&:id)を実行すると、
結果はどのようになるでしょうか? 想像してみてください。
map(&:method_name)のパターンを思い出してください。
例えばuser.following.map(&:id)の場合、idの配列を返します。
user.following.map(id:1) 2 7 10 8
引数で受け取ったid=1にフォローされている
ユーザー(id: 2,7,10,8)のidをそれぞれ1つずつ返す
2:id=2のユーザーに対してuser.followingを実行すると、
結果はどのようになるでしょうか?
また、同じユーザーに対してuser.following.map(&:id)を実行すると、
結果はどのようになるでしょうか? 想像してみてください。
user.following(id:2) => [id:1,name:Michael Hartl,email:mhartl@example.com] >> user.following.map(id:2) => 1
14.1.2 User/Relationshipの関連付け
UserとRelationshipの関連付けを行います。
1人のユーザーにはhas_many (1対多) のリレーションシップがあり、
このリレーションシップは2人のユーザーの間の関係なので、
フォローしているユーザーとフォロワーの両方に属します (belongs_to)。
microposts
テーブルにはuser_id
属性があるので、これを辿って対応する所有者 (ユーザー) を特定することができました。
データベースの2つのテーブルを繋ぐとき、
このようなidは外部キー(foreign key)と呼びます。
すなわち、Userモデルに繋げる外部キーが、
Micropostモデルのuser_id属性ということです。
この外部キーの名前を使って、Railsは関連付けの推測をしています。
具体的には、Railsはデフォルトでは外部キーの名前を<class>_idといったパターンとして理解し、 <class>に当たる部分からクラス名
(正確には小文字に変換されたクラス名) を推測します。
ただし、先ほどはユーザーを例として扱いましたが、
今回のケースではフォローしているユーザーをfollower_idという外部キーを使って特定しなくてはなりません。
また、followerというクラス名は存在しないので、
ここでもRailsに正しいクラス名を伝える必要が発生します。
先ほどの説明をコードにまとめると、
UserとRelationshipの関連付けは↓のようになります。
能動的関係に対して1対多 (has_many) の関連付けを実装する
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 . . . end
ユーザーを削除したら、ユーザーのリレーションシップも同時に削除される必要があります。
そのため、関連付けにdependent: :destroyも追加しています。
リレーションシップ/フォロワーに対してbelongs_toの関連付けを追加する
app/models/relationship.rb
class Relationship < ApplicationRecord belongs_to :follower, class_name: "User" belongs_to :followed, class_name: "User" end
上で定義した関連付けにより、以前紹介したような多くのメソッドが使えるようになりました。
メソッド 用途 active_relationship.follower フォロワーを返します active_relationship.followed フォローしているユーザーを返します user.active_relationships.create(followed_id:other_user.id) userと紐付けて能動的関係を作成/登録する user.active_relationships.create!(followed_id: other_user.id) userを紐付けて能動的関係を作成/登録する (失敗時にエラーを出力) user.active_relationships.build(followed_id: other_user.id) userと紐付けた新しいRelationshipオブジェクトを返す
演習
1:コンソールを開き、createメソッドを使ってActiveRelationshipを
作ってみましょう。データベース上に2人以上のユーザーを用意し、
最初のユーザーが2人目のユーザーをフォローしている状態を作ってみてください。
user = User.first usage = User.last user.active_relationships.create(followed_id: 2) (0.1ms) begin transaction User Load (0.6ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]] User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]] SQL (4.1ms) INSERT INTO "relationships" ("follower_id", "followed_id", "created_at", "updated_at") VALUES (?, ?, ?, ?) [["follower_id", 1], ["followed_id", 2], ["created_at", "2020-04-11 06:06:56.970355"], ["updated_at", "2020-04-11 06:06:56.970355"]] (8.9ms) commit transaction => #<Relationship id: 1, follower_id: 1, followed_id: 2, created_at: "2020-04-11 06:06:56", updated_at: "2020-04-11 06:06:56">
2:先ほどの演習を終えたら、active_relationship.followedの値
とactive_relationship.followerの値を確認し、
それぞれの値が正しいことを確認してみましょう。
Relationship id: 1, follower_id: 1, followed_id: 2, なのでOK
14.1.3 Relationshipのバリデーション
Relationshipモデルの検証を追加して完全なものにしておきましょう。テストコードとアプリケーションコードは素直な作りです。
ただし、User用の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 follower_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
# 空にする
テストは greenになるはずです。
rails test
演習
バリデーションをコメントアウトしても、テストが成功したままに
なっていることを確認してみましょう。
(以前のRailsのバージョンでは、このバリデーションが必須でしたが、Rails 5から必須ではなくなりました。
今回はフォロー機能の実装を優先しますが、
この手のバリデーションが省略されている可能性があることを頭の片隅で覚えておくと良いでしょう。)
Greenだった
14.1.4 フォローしているユーザー
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に伝えます。
Userモデルにfollowingの関連付けを追加する
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 . . . end
フォローしているユーザーを配列の様に扱えるようになりました。
例えば、include?メソッドを使ってフォローしているユーザーの集合を調べてみたり、関連付けを通してオブジェクトを探しだせるようになります。
user.following.include?(other_user) user.following.find(other_user)
followingで取得したオブジェクトは、
配列のように要素を追加したり削除したりすることができます。
user.following << other_user user.following.delete(other_user)
followingメソッドで配列のように扱えるだけでも便利ですが、
Railsは単純な配列ではなく、もっと賢くこの集合を扱っています。
例えば次のようなコードでは、
following.include?(other_user)
followやunfollowといった便利メソッドを追加しましょう。
これらのメソッドは、例えばuser.follow(other_user)といった具合に
使います。さらに、これに関連するfollowing?論理値メソッドも追加し、あるユーザーが誰かをフォローしているかどうかを確認できるようにします。
1:following?メソッドであるユーザーをまだフォローしていないことを確認 2:followメソッドを使ってそのユーザーをフォロー、 3:following?メソッドを使ってフォロー中になったことを確認、 4:最後にunfollowメソッドでフォロー解除できたことを確認、 といった具合でテストをしてきます。
“following” 関連のメソッドをテストする red
test/models/user_test.rb
require 'test_helper' class UserTest < ActiveSupport::TestCase . . . 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 end
followingによる関連付けを使ってfollow、unfollow、following?メソッドを実装していきましょう
このとき、可能な限りself (user自身を表すオブジェクト)
を省略している点に注目してください。
“following” 関連のメソッド green
app/models/user.rb
class User < ApplicationRecord . . . # ユーザーをフォローする def follow(other_user) following << other_user 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
rails test
演習
1:コンソールを開き、先のuser_test.rbのコードを順々に実行してみましょう。
michael = User.find(3) archer = User.find(4) michael.following?(archer) => false michael.follow(archer) michael.following?(archer) => true michael.unfollow(archer) Relationship Load (0.2ms) SELECT "relationships".* FROM "relationships" WHERE "relationships"."follower_id" = ? AND "relationships"."followed_id" = ? LIMIT ? [["follower_id", 3], ["followed_id", 4], ["LIMIT", 1]] (0.0ms) begin transaction SQL (1.4ms) DELETE FROM "relationships" WHERE "relationships"."id" = ? [["id", 2]] michael.following?(archer) => false User Exists (0.2ms) SELECT 1 AS one FROM "users" INNER JOIN "relationships" ON "users"."id" = "relationships"."followed_id" WHERE "relationships"."follower_id" = ? AND "users"."id" = ? LIMIT ? [["follower_id", 3], ["id", 4], ["LIMIT", 1]] => false
2:先ほどの演習の各コマンド実行時の結果を見返してみて、
実際にはどんなSQLが出力されたのか確認してみましょう。
SQL (0.4ms) DELETE FROM "relationships" WHERE "relationships"."id" = ?
14.1.5 フォロワー
リレーションシップというパズルの最後の一片は、
user.followersメソッドを追加することです。
これは上のuser.followingメソッドと対になります。
フォロワーの配列を展開するために必要な情報は、
relationshipsテーブルに既にあります。
つまり、作成したactive_relationshipsのテーブルを再利用することができそうです。
実際、follower_idとfollowed_idを入れ替えるだけで、
フォロワーについてもフォローする場合と全く同じ方法が活用できます。
データモデルの実装を↓に示しますが、
この実装は前実装したものとまさに類似しています。
user.followers
を実装する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
次のように参照先 (followers) を指定するための
:sourceキーを省略してもよかったという点です。
has_many :followers, through: :passive_relationships
これは:followers属性の場合、Railsが「followers」を単数形にして
自動的に外部キーfollower_idを探してくれるからです。
必要のない:sourceキーをそのまま残しているのは、
has_many :followingとの類似性を強調させるためです。
followers.include?メソッドを使って先ほどのデータモデルをテストします。
followersに対するテスト green
test/models/user_test.rb
require 'test_helper' class UserTest < ActiveSupport::TestCase . . . 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 end
演習
1:コンソールを開き、何人かのユーザーが最初のユーザーをフォローしている状況を作ってみてください。
最初のユーザーをuserとすると、user.followers.map(&:id)の値は
どのようになっているでしょうか?
user = User.first user_second = User.secound user.followers.map(&:id) User Load (0.2ms) SELECT "users".* FROM "users" INNER JOIN "relationships" ON "users"."id" = "relationships"."follower_id" WHERE "relationships"."followed_id" = ? [["followed_id", 1]] => [5, 4]
2:上の演習が終わったら、user.followers.countの実行結果が、先ほどフォローさせたユーザー数と一致していることを確認してみましょう。
user.followers.count => 2
3:user.followers.countを実行した結果、出力されるSQL文はどのような内容になっているでしょうか?
(0.3ms) SELECT COUNT(*) FROM "users" INNER JOIN "relationships" ON "users"."id" = "relationships"."follower_id" WHERE "relationships"."followed_id" = ? [["followed_id", 1]]
また、user.followers.to_a.countの実行結果と違っている箇所はありますか? もしuserに100万人のフォロワーがいた場合、
どのような違いがあるでしょうか? 考えてみてください。
user.followers.to_a.count => 2
フォロワーが100万人いたらそのまま100万と言う数値が返されるが配列を生成する為、時間が掛かるしDBにも負担が掛かる。