ユーザーをフォローする
ここでは、サンプルアプリケーションのコアとなる部分を完成させる。
具体的には、他のユーザーをフォロー(およびフォロー解除)できるソーシャルな仕組みの追加と、フォローしているユーザーの投稿をステータスフィードに表示する機能を追加する。
Relationshipモデル
ユーザーをフォローする機能を実装する第一歩は、データモデルを構成すること。
ただし、これは見た目ほど単純ではない。
素朴に考えれば、has_many(1対多)の関連付けを用いて「1人のユーザーが複数のユーザーをhas_manyとしてフォローし、1人のユーザーに複数のフォロワーがいることをhas_manyで表す」といった方法でも実装できそう。
しかし後ほど説明するが、この方法ではたちまち壁に突き当たってしまう。
これを解決するためのhas_many throughについてもこの後で説明する。
データモデルの問題 (および解決策)
ユーザーをフォローするデータモデル構成のための第一歩として、典型的な場合を検討してみる。
あるユーザーが、別のユーザーをフォローしているところを考えてみる。
具体例を挙げると、CalvinはHobbesをフォローしている。
これを逆から見れば、HobbesはCalvinからフォローされている。
CalvinはHobbesから見ればフォロワー(follower)であり、CalvinがHobbesをフォローした (followed) ことになる。
Railsにおけるデフォルトの複数形の慣習に従えば、あるユーザーをフォローしているすべてのユーザーの集合はfollowersとなり、user.followersはそれらのユーザーの配列を表すことになる。
しかし残念なことに、この名前付けは逆向きではうまくいかない(Railsというより英語の都合だが)。
あるユーザーがフォローしているすべてのユーザーの集合は、このままではfollowedsとなってしまい、英語の文法からも外れるうえに非常に見苦しいものになってしまう。
そこで、Twitterの慣習にならい、本チュートリアルではfollowingという呼称を採用する(例:“50 following, 75 followers”)。
したがって、あるユーザーがフォローしているすべてのユーザーの集合はcalvin.followingとなる。
これにより、followingテーブルとhas_many関連付けを使って、フォローしているユーザーのモデリングができる。
user.followingはユーザーの集合でなければならないため、followingテーブルのそれぞれの行は、followed_idで識別可能なユーザーでなければならない(これはfollower_idの関連付けについても同様)。
さらに、それぞれの行はユーザーなので、これらのユーザーに名前やパスワードなどの属性も追加する必要がある。
データモデルの問題点は、非常に無駄が多いこと。
各行には、フォローしているユーザーのidのみならず、名前やメールアドレスまである。
これらはいずれもusersテーブルに既にあるものばかり。
さらによくないことに、followersの方をモデリングするときにも、同じぐらい無駄の多いfollowersテーブルを別に作成しなければならなくなってしまう。
結論としては、このデータモデルはメンテナンスの観点から見てよくない。
ユーザー名を変更するたびに、usersテーブルのそのレコードだけでなく、followingテーブルとfollowersテーブルの両方について、そのユーザーを含むすべての行を更新しなければならなくなる。
この問題の根本は、必要な抽象化を行なっていないこと。
正しいモデルを見つけ出す方法の1つは、Webアプリケーションにおけるfollowingの動作をどのように実装するかをじっくり考えること。
RESTアーキテクチャは、作成されたり削除されたりするリソースに関連していた。
ここから、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に対して「受動的関係」を持っていることになる。
まずは、フォローしているユーザーを生成するために、能動的関係に焦点を当てていく(受動的関係については後で考えていく)。
フォローしているユーザーはfollowed_idがあれば識別することができるので、先ほどのfollowingテーブルをactive_relationshipsテーブルと見立ててみる。
ただしユーザー情報は無駄なので、ユーザーid以外の情報は削除する。
そして、followed_idを通して、usersテーブルのフォローされているユーザーを見つけるようにする。
このデータモデルを模式図にすると、以下のようになる。
能動的関係も受動的関係も、最終的にはデータベースの同じテーブルを使うことになる。
したがって、テーブル名にはこの「関係」を表す「relationships」を使う。
モデル名はRailsの慣習にならって、Relationshipとする。
作成したRelationshipデータモデルを下の図に示す。
1つのrelationshipsテーブルを使って2つのモデル(能動的関係と受動的関係)をシミュレートする方法については、後で説明する。
このデータモデルを実装するために、まずは次のように上の図に対応したマイグレーションを生成する。
rails generate model Relationship follower_id:integer followed_id:integer
このリレーションシップは今後follower_idとfollowed_idで頻繁に検索することになるので、それぞれのカラムにインデックスを追加する。
class CreateRelationships < ActiveRecord::Migration[5.0]
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テーブルを作成するために、いつものようにデータベースのマイグレーションを行う。
rails db:migrate
User/Relationshipの関連付け
フォローしているユーザーとフォロワーを実装する前に、UserとRelationshipの関連付けを行う。
1人のユーザーにはhas_many(1対多)のリレーションシップがあり、このリレーションシップは2人のユーザーの間の関係なので、フォローしているユーザーとフォロワーの両方に属す(belongs_to)。
マイクロポストのときと同様、次のようなユーザー関連付けのコードを使って新しいリレーションシップを作成する。
user.active_relationships.build(followed_id: ...)
この時点で、アプリケーションコードはUser/Micropostの関連付けのようになるのではないかと予測しかもしれない。
実際似ているのだが、2つの大きな違いがある。
まずは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属性があるので、これを辿って対応する所有者(ユーザー)を特定することができた。
データベースの2つのテーブルを繋ぐとき、このようなidは外部キー(foreign key)と呼ぶ。
すなわち、Userモデルに繋げる外部キーが、Micropostモデルのuser_id属性ということ。
この外部キーの名前を使って、Railsは関連付けの推測をしている。
具体的には、Railsはデフォルトでは外部キーの名前を_idといったパターンとして理解し、 に当たる部分からクラス名 (正確には小文字に変換されたクラス名) を推測する。
ただし、先ほどはユーザーを例として扱ったが、今回のケースではフォローしているユーザーをfollower_idという外部キーを使って特定しなくてはならない。
また、followerというクラス名は存在しないので、ここでもRailsに正しいクラス名を伝える必要が発生する。
先ほどの説明をコードにまとめると、UserとRelationshipの関連付けは以下のようになる。
class User < ApplicationRecord
has_many :microposts, dependent: :destroy
has_many :active_relationships, class_name: "Relationship",
foreign_key: "follower_id",
dependent: :destroy
.
.
.
end
(ユーザーを削除したら、ユーザーのリレーションシップも同時に削除される必要がある。そのため、関連付けにdependent: :destroyも追加している。)
class Relationship < ApplicationRecord
belongs_to :follower, class_name: "User"
belongs_to :followed, class_name: "User"
end
なお、followerの関連付けについては、後になるまでは使わない。
しかしfollowerとfollowedを対称的に実装しておくことで、構造に対する理解は容易になるはず。
上で定義した関連付けにより、以前紹介したような多くのメソッドが使えるようになった。
今回使えるようになったメソッドを表に示す。
メソッド | 用途 |
---|---|
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オブジェクトを返す |
ユーザーと能動的関係の関連付けによって使えるようになったメソッドのまとめ
Relationshipのバリデーション
先に進む前に、Relationshipモデルの検証を追加して完全なものにしておく。
テストコードとアプリケーションコードは素直な作りだが、User用のfixtureファイルと同じように、生成されたRelationship用のfixtureでは、マイグレーションで制約させた一意性を満たすことができない。
ということで、ユーザーのときと同じで(以前fixtureの内容を削除したように)、今の時点では生成されたRelationship用のfixtureファイルも空にしておく。
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
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
# 空にする
フォローしているユーザー
いよいよ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に伝える。
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)
(前述したように、<<演算子 (Shovel Operator) で配列の最後に追記することができる。)
followingメソッドで配列のように扱えるだけでも便利だが、Railsは単純な配列ではなく、もっと賢くこの集合を扱っている。
following.include?(other_user)
例えば上のようなコードでは、フォローしている全てのユーザーをデータベースから取得し、その集合に対してinclude?メソッドを実行しているように見えるが、しかし実際にはデータベースの中で直接比較をするように配慮している。
user.microposts.count
なお、上のようなコードでは、データベースの中で合計を計算したほうが高速になる。
次に、followingで取得した集合をより簡単に取り扱うために、followやunfollowといった便利メソッドを追加する。
これらのメソッドは、例えばuser.follow(other_user)といった具合に使う。
さらに、これに関連するfollowing?論理値メソッドも追加し、あるユーザーが誰かをフォローしているかどうかを確認できるようにする。
今回は、こういったメソッドはテストから先に書いていく。
というのも、Webインターフェイスなどで便利メソッドを使うのはまだ先なので、すぐに使える場面がなく、実装した手応えを得にくいから。
一方で、Userモデルに対するテストを書くのは簡単かつ今すぐできる。
そのテストの中で、これらのメソッドを使っていく。
具体的には、following?メソッドであるユーザーをまだフォローしていないことを確認、followメソッドを使ってそのユーザーをフォロー、following?メソッドを使ってフォロー中になったことを確認、最後にunfollowメソッドでフォロー解除できたことを確認、といった具合でテストをしていく。
作成したコードを以下に示す。
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自身を表すオブジェクト)を省略している。
class User < ApplicationRecord
.
.
.
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
フォロワー
リレーションシップというパズルの最後の一片は、user.followersメソッドを追加すること。
これは上のuser.followingメソッドと対になる。
フォロワーの配列を展開するために必要な情報は、relationshipsテーブルに既にある。
つまり、作成したactive_relationshipsのテーブルを再利用することができる。
follower_idとfollowed_idを入れ替えるだけで、フォロワーについてもフォローする場合と全く同じ方法が活用できる。
したがって、データモデルは図のようになる。
図を参考にしたデータモデルの実装を下に示すが、この実装はUserモデルにfollowingの関連付けを追加するときとまさに類似している。
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?メソッドを使って先ほどのデータモデルをテストしていく。
テストコードは以下のとおり。
ちなみに、following?と対照的なfollowed_by?メソッドを定義してもよかったのだが、サンプルアプリケーションで実際に使う場面がなかったのでこのメソッドは省略した。
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行だけ追加しているが、実際には多くの処理が正しく動いていなければパスしない。
つまり、この実装に対するテストは、実装の影響を受けやすいテストだといえる。
[Follow]のWebインターフェイス
ここでは、モックアップで示したようにフォロー/フォロー解除の基本的なインターフェイスを実装する。
また、フォローしているユーザーと、フォロワーにそれぞれ表示用のページを作成する。
フォローのサンプルデータ
前のときと同じように、サンプルデータを自動作成するrails db:seedを使って、データベースにサンプルデータを登録できるとやはり便利。
先にサンプルデータを自動作成できるようにしておけば、Webページの見た目のデザインから先にとりかかることができ、バックエンド機能の実装を後に回すことができる。
下のコードは、リレーションシップのサンプルデータを生成するためのコード。
ここでは、最初のユーザーにユーザー3からユーザー51までをフォローさせ、それから逆にユーザー4からユーザー41に最初のユーザーをフォローさせる。
ソースを見るとわかるように、このような設定を自由に行うことができる。
こうしてリレーションシップを作成しておけば、アプリケーションのインターフェイスを開発するには十分。
# ユーザー
User.create!(name: "Example User",
email: "example@railstutorial.org",
password: "foobar",
password_confirmation: "foobar",
admin: true,
activated: true,
activated_at: Time.zone.now)
99.times do |n|
name = Faker::Name.name
email = "example-#{n+1}@railstutorial.org"
password = "password"
User.create!(name: name,
email: email,
password: password,
password_confirmation: password,
activated: true,
activated_at: Time.zone.now)
end
# マイクロポスト
users = User.order(:created_at).take(6)
50.times do
content = Faker::Lorem.sentence(5)
users.each { |user| user.microposts.create!(content: content) }
end
# リレーションシップ
users = User.all
user = users.first
following = users[2..50]
followers = users[3..40]
following.each { |followed| user.follow(followed) }
followers.each { |follower| follower.follow(user) }
データベース上のサンプルデータを作り直すために、いつものコマンドを実行する。
rails db:migrate:reset
rails db:seed
統計と[Follow]フォーム
これでサンプルユーザーに、フォローしているユーザーとフォロワーができた。
プロフィールページとHomeページを更新して、これを反映する。
最初に、プロフィールページとHomeページに、フォローしているユーザーとフォロワーの統計情報を表示するためのパーシャルを作成する。
次に、フォロー用とフォロー解除用のフォームを作成する。
それから、フォローしているユーザーの一覧("following")とフォロワーの一覧("followers")を表示する専用のページを作成する。
先ほど指摘したように、Twitterの慣習に従ってフォロー数の単位には「following」を使い、例えば「50 following」といった具合に表示する。
この統計情報には、現在のユーザーがフォローしている人数と、現在のフォロワーの人数が表示されている。
それぞれの表示はリンクになっており、専用の表示ページに移動できる。
今までは、これらのリンクはダミーテキスト’#’を使って無効にしていた。
しかしルーティングについての知識もだいぶ増えてきたので、今回は実装することにする。
実際のページ作成は後に行うが、ルーティングは今実装する。
このコードでは、resourcesブロックの内側で:memberメソッドを使っている。
Rails.application.routes.draw do
root 'static_pages#home'
get '/help', to: 'static_pages#help'
get '/about', to: 'static_pages#about'
get '/contact', to: 'static_pages#contact'
get '/signup', to: 'users#new'
get '/login', to: 'sessions#new'
post '/login', to: 'sessions#create'
delete '/logout', to: 'sessions#destroy'
resources :users do
member do
get :following, :followers
end
end
resources :account_activations, only: [:edit]
resources :password_resets, only: [:new, :create, :edit, :update]
resources :microposts, only: [:create, :destroy]
end
この場合のURLは /users/1/following や /users/1/followers のようになるのではないかと推測した方もいると思う。
そして、上のコードはまさにそれを行なっている。
また、どちらもデータを表示するページなので、適切なHTTPメソッドはGETリクエストになる。
したがって、getメソッドを使って適切なレスポンスを返すようにする。
ちなみに、memberメソッドを使うとユーザーidが含まれているURLを扱うようになるが、idを指定せずにすべてのメンバーを表示するには、次のようにcollectionメソッドを使う。
resources :users do
collection do
get :tigers
end
end
このコードは /users/tigers というURLに応答する(アプリケーションにあるすべてのtigerのリストを表示する)。
上のコードによって生成されるルーティングテーブルを表に示す。
この表で示したフォロー用とフォロワー用の名前付きルートを、今後の実装で使っていく。
HTTPリクエスト | URL | アクション | 名前付きルート |
---|---|---|---|
GET | /users/1/following | following | following_user_path(1) |
GET | /users/1/followers | followers | followers_user_path(1) |
ルーティングを定義したので、統計情報のパーシャルを実装する準備が整った。
このパーシャルでは、divタグの中に2つのリンクを含めるようにする。
<% @user ||= current_user %>
<div class="stats">
<a href="<%= following_user_path(@user) %>">
<strong id="following" class="stat">
<%= @user.following.count %>
</strong>
following
</a>
<a href="<%= followers_user_path(@user) %>">
<strong id="followers" class="stat">
<%= @user.followers.count %>
</strong>
followers
</a>
</div>
このパーシャルはプロフィールページとHomeページの両方に表示されるので、最初の行では、次のコードで現在のユーザーを取得する。
<% @user ||= current_user %>
これは以前説明したとおり、@userがnilでない場合(つまりプロフィールページの場合)は何もせず、nilの場合(つまりHomeページの場合)には@userにcurrent_userを代入するコード。
その後、フォローしているユーザーの人数を、次のように関連付けを使って計算する。
@user.following.count
これはフォロワーについても同様。
@user.followers.count
上のコードは、マイクロポストの投稿数を表示した方法と同じ。
あのときは次のように書いた。
@user.microposts.count
なお、今回も以前と同様に、Railsは高速化のためにデータベース内で合計を計算している。
さて、一部の要素で、次のようにCSS idを指定している。
<strong id="following" class="stat">
...
</strong>
こうしておくと、後でAjaxを実装するときに便利。
そこでは、一意のidを指定してページ要素にアクセスしている。
これで統計情報パーシャルができあがる。
Homeページにこの統計情報を表示するには、以下のようにすると簡単。
<% if logged_in? %>
<div class="row">
<aside class="col-md-4">
<section class="user_info">
<%= render 'shared/user_info' %>
</section>
<section class="stats">
<%= render 'shared/stats' %>
</section>
<section class="micropost_form">
<%= render 'shared/micropost_form' %>
</section>
</aside>
<div class="col-md-8">
<h3>Micropost Feed</h3>
<%= render 'shared/feed' %>
</div>
</div>
<% else %>
.
.
.
<% end %>
統計情報にスタイルを与えるために、下のコードのようにSCSSを追加する(なお、このSCSSにはこの章で使うすべてのスタイルが含まれている)。
.
.
.
/* sidebar */
.
.
.
.gravatar {
float: left;
margin-right: 10px;
}
.gravatar_edit {
margin-top: 15px;
}
.stats {
overflow: auto;
margin-top: 0;
padding: 0;
a {
float: left;
padding: 0 10px;
border-left: 1px solid $gray-lighter;
color: gray;
&:first-child {
padding-left: 0;
border: 0;
}
&:hover {
text-decoration: none;
color: blue;
}
}
strong {
display: block;
}
}
.user_avatars {
overflow: auto;
margin-top: 10px;
.gravatar {
margin: 1px 1px;
}
a {
padding: 0;
}
}
.users.follow {
padding: 0;
}
/* forms */
.
.
.
この後すぐ、プロフィールにも統計情報パーシャルを表示するが、今のうちに次のように [Follow] / [Unfollow] ボタン用のパーシャルも作成する。
<% unless current_user?(@user) %>
<div id="follow_form">
<% if current_user.following?(@user) %>
<%= render 'unfollow' %>
<% else %>
<%= render 'follow' %>
<% end %>
</div>
<% end %>
このコードは、followとunfollowのパーシャルに作業を振っているだけ。
パーシャルでは、Relationshipsリソース用の新しいルーティングが必要。
これを、Micropostsリソースの例に従って作成する。
Rails.application.routes.draw do
root 'static_pages#home'
get 'help' => 'static_pages#help'
get 'about' => 'static_pages#about'
get 'contact' => 'static_pages#contact'
get 'signup' => 'users#new'
get 'login' => 'sessions#new'
post 'login' => 'sessions#create'
delete 'logout' => 'sessions#destroy'
resources :users do
member do
get :following, :followers
end
end
resources :account_activations, only: [:edit]
resources :password_resets, only: [:new, :create, :edit, :update]
resources :microposts, only: [:create, :destroy]
resources :relationships, only: [:create, :destroy]
end
フォロー/フォロー解除用のパーシャル自体は、以下のように示す。
<%= form_for(current_user.active_relationships.build) do |f| %>
<div><%= hidden_field_tag :followed_id, @user.id %></div>
<%= f.submit "Follow", class: "btn btn-primary" %>
<% end %>
<%= form_for(current_user.active_relationships.find_by(followed_id: @user.id),
html: { method: :delete }) do |f| %>
<%= f.submit "Unfollow", class: "btn" %>
<% end %>
これらの2つのフォームでは、いずれもform_forを使ってRelationshipモデルオブジェクトを操作している。
これらの2つのフォームの主な違いは、_follow.html.erbは新しいリレーションシップを作成するのに対し、_unfollow.html.erbは既存のリレーションシップを見つけ出すという点。
すなわち、前者はPOSTリクエストをRelationshipsコントローラに送信してリレーションシップをcreate(作成)し、後者はDELETEリクエストを送信してリレーションシップをdestroy(削除)するということ(これらのアクションは後で実装する)。
最終的に、このフォロー/フォロー解除フォームにはボタンしかないことを理解していただけたと思う。
しかし、それでもこのフォームはfollowed_idをコントローラに送信する必要がある。
これを行うために、hidden_field_tagメソッドを使います。
このメソッドは、次のフォーム用HTMLを生成する。
<input id="followed_id" name="followed_id" type="hidden" value="3" />
隠しフィールドのinputタグを使うことで、ブラウザ上に表示させずに適切な情報を含めることができる。
これでパーシャルとしてフォロー用フォームをプロフィールページに表示できるようになった。
プロフィールには、それぞれ [Follow]、[Unfollow] ボタンが表示される。
<% provide(:title, @user.name) %>
<div class="row">
<aside class="col-md-4">
<section class="user_info">
<h1>
<%= gravatar_for @user %>
<%= @user.name %>
</h1>
</section>
<section class="stats">
<%= render 'shared/stats' %>
</section>
</aside>
<div class="col-md-8">
<%= render 'follow_form' if logged_in? %>
<% if @user.microposts.any? %>
<h3>Microposts (<%= @user.microposts.count %>)</h3>
<ol class="microposts">
<%= render @microposts %>
</ol>
<%= will_paginate @microposts %>
<% end %>
</div>
</div>
これらのボタンはもうすぐ動作するようになる。
実はこのボタンの実装には2通りの方法がある。
1つは標準的な方法、もう1つはAjaxを使う方法。
でもその前に、フォローしているユーザーとフォロワーを表示するページをそれぞれ作成してHTMLインターフェイスを完成させる。
[Following]と[Followers]ページ
フォローしているユーザーを表示するページと、フォロワーを表示するページは、いずれもプロフィールページとユーザー一覧ページを合わせたような作りになるという点で似ている。
どちらにもフォローの統計情報などのユーザー情報を表示するサイドバーと、ユーザーのリストがある。
さらに、サイドバーには小さめのユーザープロフィール画像のリンクを格子状に並べて表示する予定。
ここでの最初の作業は、フォローしているユーザーのリンクとフォロワーのリンクを動くようにすること。 Twitterに倣って、どちらのページでもユーザーのログインを要求するようにする。
そこで前回のアクセス制御と同様に、まずはテストから書いていく。
今回使うテストは以下のとおり。
なお、 下のコードでは表でまとめた名前付きルートを使っている。
require 'test_helper'
class UsersControllerTest < ActionDispatch::IntegrationTest
def setup
@user = users(:michael)
@other_user = users(:archer)
end
.
.
.
test "should redirect following when not logged in" do
get following_user_path(@user)
assert_redirected_to login_url
end
test "should redirect followers when not logged in" do
get followers_user_path(@user)
assert_redirected_to login_url
end
end
この実装には1つだけトリッキーな部分がある。
それはUsersコントローラに2つの新しいアクションを追加する必要があるということ。
これは定義した2つのルーティングにもとづいており、これらはそれぞれfollowingおよびfollowersと呼ぶ必要がある。
それぞれのアクションでは、タイトルを設定し、ユーザーを検索し、@user.followingまたは@user.followersからデータを取り出し、ページネーションを行なって、ページを出力する必要がある。
作成したコードを示す。
class UsersController < ApplicationController
before_action :logged_in_user, only: [:index, :edit, :update, :destroy,
:following, :followers]
.
.
.
def following
@title = "Following"
@user = User.find(params[:id])
@users = @user.following.paginate(page: params[:page])
render 'show_follow'
end
def followers
@title = "Followers"
@user = User.find(params[:id])
@users = @user.followers.paginate(page: params[:page])
render 'show_follow'
end
private
.
.
.
end
Railsは慣習に従って、アクションに対応するビューを暗黙的に呼び出す。
例えば、showアクションの最後でshow.html.erbを呼び出す、といった具合。
一方で、上のコードのいずれのアクションも、renderを明示的に呼び出し、show_followという同じビューを出力している。
したがって、作成が必要なビューはこれ1つ。
renderで呼び出しているビューが同じである理由は、このERbはどちらの場合でもほぼ同じであり、上のコードで両方の場合をカバーできるためです。
<% provide(:title, @title) %>
<div class="row">
<aside class="col-md-4">
<section class="user_info">
<%= gravatar_for @user %>
<h1><%= @user.name %></h1>
<span><%= link_to "view my profile", @user %></span>
<span><b>Microposts:</b> <%= @user.microposts.count %></span>
</section>
<section class="stats">
<%= render 'shared/stats' %>
<% if @users.any? %>
<div class="user_avatars">
<% @users.each do |user| %>
<%= link_to gravatar_for(user, size: 30), user %>
<% end %>
</div>
<% end %>
</section>
</aside>
<div class="col-md-8">
<h3><%= @title %></h3>
<% if @users.any? %>
<ul class="users follow">
<%= render @users %>
</ul>
<%= will_paginate %>
<% end %>
</div>
</div>
users_controllerにあるアクションは、2通りの方法で上のビューを呼び出す。
このとき、上のコードでは現在のユーザーを一切使っていない。
したがって、他のユーザーのフォロワー一覧ページもうまく動く。
次に、show_followの描画結果を確認するため、統合テストを書いていく。
ただし今回の統合テストは基本的なテストだけに留めており、網羅的なテストにはしていない。
これは、HTML構造を網羅的にチェックするテストは壊れやすく、生産性を逆に落としかねないから。
したがって今回は、正しい数が表示されているかどうかと、正しいURLが表示されているかどうかの2つのテストを書く。
いつものように、統合テストを生成するところから始める。
rails generate integration_test following
今度はテストデータをいくつか揃える。
リレーションシップ用のfixtureにデータを追加する。
以前は、次のように書くことで、ユーザーとマイクロポストを関連付けできていた。
orange:
content: "I just ate an orange!"
created_at: <%= 10.minutes.ago %>
user: michael
また、上のコードではユーザー名を書いていたが、このようにユーザーidでも関連付けできる。
user: michael
user_id: 1
この例を参考にしてRelationship用のfixtureにテストデータを追加すると、下のコードのようになる。
one:
follower: michael
followed: lana
two:
follower: michael
followed: malory
three:
follower: lana
followed: michael
four:
follower: archer
followed: michael
上のfixtureでは、前半の2つでMichaelがLanaとMaloryをフォローし、後半の2つでLanaとArcherがMichaelをフォローしている。
あとは、正しい数かどうかを確認するために、assert_matchメソッドを使ってプロフィール画面のマイクロポスト数をテストする。
さらに、正しいURLかどうかをテストするコードも加えると、以下のようになる。
require 'test_helper'
class FollowingTest < ActionDispatch::IntegrationTest
def setup
@user = users(:michael)
log_in_as(@user)
end
test "following page" do
get following_user_path(@user)
assert_not @user.following.empty?
assert_match @user.following.count.to_s, response.body
@user.following.each do |user|
assert_select "a[href=?]", user_path(user)
end
end
test "followers page" do
get followers_user_path(@user)
assert_not @user.followers.empty?
assert_match @user.followers.count.to_s, response.body
@user.followers.each do |user|
assert_select "a[href=?]", user_path(user)
end
end
end
なお、上のコードでは、このようなコードを加えているが、
assert_not @user.following.empty?
このコードは次のコードを確かめるためのテストであって、無意味なテストではない(followersについても同様)。
@user.following.each do |user|
assert_select "a[href=?]", user_path(user)
end
つまり、もし@user.following.empty?の結果がtrueであれば、assert_select内のブロックが実行されなくなるため、その場合においてテストが適切なセキュリティモデルを確認できなくなることを防いでいる。
[Follow]ボタン(基本編)
いよいよ[Follow]/[Unfollow]ボタンを動作させる。
フォローとフォロー解除はそれぞれリレーションシップの作成と削除に対応しているため、まずはRelationshipsコントローラが必要。
いつものようにコントローラを生成する。
rails generate controller Relationships
Relationshipsコントローラのアクションでアクセス制御することはそこまで難しくない。
しかし、前回のアクセス制御のときと同様に最初にテストを書き、それをパスするように実装することでセキュリティモデルを確立させていく。
今回はまず、コントローラのアクションにアクセスするとき、ログイン済みのユーザーであるかどうかをチェックする。
もしログインしていなければログインページにリダイレクトされるので、Relationshipのカウントが変わっていないことを確認する。
require 'test_helper'
class RelationshipsControllerTest < ActionDispatch::IntegrationTest
test "create should require logged-in user" do
assert_no_difference 'Relationship.count' do
post relationships_path
end
assert_redirected_to login_url
end
test "destroy should require logged-in user" do
assert_no_difference 'Relationship.count' do
delete relationship_path(relationships(:one))
end
assert_redirected_to login_url
end
end
次に、上のテストをパスさせるために、logged_in_userフィルターをRelationshipsコントローラのアクションに対して追加する。
class RelationshipsController < ApplicationController
before_action :logged_in_user
def create
end
def destroy
end
end
[Follow]/[Unfollow]ボタンを動作させるためには、フォームから送信されたパラメータを使って、followed_idに対応するユーザーを見つけてくる必要がある。
その後、見つけてきたユーザーに対して適切にfollow/unfollowメソッドを使う。
このすべてを実装した結果を、以下に示す。
class RelationshipsController < ApplicationController
before_action :logged_in_user
def create
user = User.find(params[:followed_id])
current_user.follow(user)
redirect_to user
end
def destroy
user = Relationship.find(params[:id]).followed
current_user.unfollow(user)
redirect_to user
end
end
上のコードを見てみれば、先ほどのセキュリティ問題が実はそれほど重大なものではないことを理解いただけると思う。
もしログインしていないユーザーが(curlなどのコマンドラインツールなどを使って)これらのアクションに直接アクセスするようなことがあれば、current_userはnilになり、どちらのメソッドでも2行目で例外が発生する。
エラーにはなるが、アプリケーションやデータに影響は生じない。
このままでも支障はありませんが、やはりこのような例外には頼らない方がよいので、上ではひと手間かけてセキュリティのためのレイヤーを追加した。
これで、フォロー/フォロー解除の機能が完成した。
どのユーザーも、他のユーザーをフォローしたりフォロー解除したりできる。
振る舞いを検証する統合テストは後で実装することにする。
[Follow]ボタン(Ajax編)
フォロー関連の機能の実装はこのとおり完了したが、ステータスフィードに取りかかる前にもう一つだけ機能を洗練させてみたいと思う。
先ほどは、Relationshipsコントローラのcreateアクションと destroyアクションを単に元のプロフィールにリダイレクトしていた。
つまり、ユーザーはプロフィールページを最初に表示し、それからユーザーをフォローし、その後すぐ元のページにリダイレクトされるという流れになる。
ユーザーをフォローした後、本当にそのページから離れて元のページに戻らないといけないのだろうか。
この点を考えなおしてみる。
これはAjaxを使うことで解決できる。
Ajaxを使えば、Webページからサーバーに「非同期」で、ページを移動することなくリクエストを送信することができる。
WebフォームにAjaxを採用するのは今や当たり前になりつつあるので、RailsでもAjaxを簡単に実装できるようになっている。
フォロー用とフォロー解除用のパーシャルをこれに沿って更新するのは簡単。
form_for
上のコードを次のように置き換えるだけ。
form_for ..., remote: true
たったこれだけで、Railsは自動的にAjaxを使うようになる。
具体的な更新の結果を、以下に示す。
<%= form_for(current_user.active_relationships.build, remote: true) do |f| %>
<div><%= hidden_field_tag :followed_id, @user.id %></div>
<%= f.submit "Follow", class: "btn btn-primary" %>
<% end %>
<%= form_for(current_user.active_relationships.find_by(followed_id: @user.id),
html: { method: :delete },
remote: true) do |f| %>
<%= f.submit "Unfollow", class: "btn" %>
<% end %>
ERbによって実際に生成されるHTMLはそれほど重要ではないが、その核心部分を見せる。
<form action="/relationships/117" class="edit_relationship" data-remote="true"
id="edit_relationship_117" method="post">
.
.
.
</form>
ここでは、formタグの内部でdata-remote="true"を設定している。
これは、JavaScriptによるフォーム操作を許可することをRailsに知らせるためのもの。
Rails 2以前では、完全なJavaScriptのコードを挿入する必要があった。
しかし先ほどの例で見たように、現在のRailsではHTMLプロパティを使って簡単にAjaxが扱えるようになっている。
これは、JavaScriptを前面に出すべからずという哲学に従っている。
フォームの更新が終わったので、今度はこれに対応するRelationshipsコントローラを改造して、Ajaxリクエストに応答できるようにする。
こういったリクエストの種類によって応答を場合分けするときは、respond_toメソッドというメソッドを使う。
respond_to do |format|
format.html { redirect_to user }
format.js
end
この文法は少々変わっていて混乱を招く可能性があるが、上の(ブロック内の)コードのうち、いずれかの1行が実行されるという点が重要(このためrespond_toメソッドは、上から順に実行する逐次処理というより、if文を使った分岐処理に近いイメージ)。
RelationshipsコントローラでAjaxに対応させるために、respond_toメソッドをcreateアクションとdestroyアクションにそれぞれ追加してみる。
変更の結果を以下に示す。
このとき、ユーザーのローカル変数(user)をインスタンス変数(@user)に変更した。
これは、Ajaxを使ったフォローフォームやAjaxを使ったフォロー解除フォームを実装したことにより、インスタンス変数が必要になったため。
class RelationshipsController < ApplicationController
before_action :logged_in_user
def create
@user = User.find(params[:followed_id])
current_user.follow(@user)
respond_to do |format|
format.html { redirect_to @user }
format.js
end
end
def destroy
@user = Relationship.find(params[:id]).followed
current_user.unfollow(@user)
respond_to do |format|
format.html { redirect_to @user }
format.js
end
end
end
(ビューで変数を使うため、userが@userに変わった。)
上のコードでAjaxリクエストに対応したので、今度はブラウザ側でJavaScriptが無効になっていた場合(Ajaxリクエストが送れない場合)でもうまく動くようにする。
require File.expand_path('../boot', __FILE__)
.
.
.
module SampleApp
class Application < Rails::Application
.
.
.
# 認証トークンをremoteフォームに埋め込む
config.action_view.embed_authenticity_token_in_remote_forms = true
end
end
一方で、JavaScriptが有効になっていても、まだ十分に対応できていない部分がある。
というのも、Ajaxリクエストを受信した場合は、Railsが自動的にアクションと同じ名前を持つJavaScript用の埋め込みRuby(.js.erb)ファイル(create.js.erbやdestroy.js.erbなど)を呼び出すから。
ご想像のとおり、これらのファイルではJavaScriptと埋め込みRuby(ERb)をミックスして現在のページに対するアクションを実行することができる。
ユーザーをフォローしたときやフォロー解除したときにプロフィールページを更新するために、私たちがこれから作成および編集しなければならないのは、まさにこれらのファイル。
JS-ERbファイルの内部では、DOM(Document Object Model)を使ってページを操作するため、RailsがjQuery JavaScriptヘルパーを自動的に提供している。
これによりjQueryライブラリの膨大なDOM操作用メソッドが使えるようになるが、今回使うのはわずか2つ。
1つずつ見ていく。
まずはドル記号($)とCSS idを使って、DOM要素にアクセスする文法について知る必要がある。
例えばfollow_formの要素をjQueryで操作するには、次のようにアクセスする。
$("#follow_form")
これはフォームを囲むdivタグであり、フォームそのものではない。
jQueryの文法はCSSの記法から影響を受けており、#シンボルを使ってCSSのidを指定する。
ご想像のとおり、jQueryはCSSと同様、ドット.を使ってCSSクラスを操作できる。
次に必要なメソッドはhtml。
これは、引数の中で指定された要素の内側にあるHTMLを更新する。
例えばフォロー用フォーム全体を"foobar"という文字列で置き換えたい場合は、次のようなコードになる。
$("#follow_form").html("foobar")
純粋なJavaScriptと異なり、JS-ERbファイルでは組み込みRuby(ERb)が使える。
create.js.erbファイルでは、フォロー用のフォームをunfollowパーシャルで更新し、フォロワーのカウントを更新するのにERbを使っている(もちろんこれは、フォローに成功した場合の動作)。
変更の結果を以下に示す。
このコードではescape_javascriptメソッドを使っている。
このメソッドは、JavaScriptファイル内にHTMLを挿入するときに実行結果をエスケープするために必要。
$("#follow_form").html("<%= escape_javascript(render('users/unfollow')) %>");
$("#followers").html('<%= @user.followers.count %>');
各行の末尾にセミコロン ; がある。
これはプログラミング言語によくある文法で、古くは1950年代中ごろに開発されたALGOLまで遡る。
destroy.js.erbファイルの方も同様。
$("#follow_form").html("<%= escape_javascript(render('users/follow')) %>");
$("#followers").html('<%= @user.followers.count %>');
これらのコードにより、プロフィールページを更新させずにフォローとフォロー解除ができるようになったはず。
フォローをテストする
フォローボタンが動くようになったので、バグを検知するためのシンプルなテストを書いていく。
ユーザーのフォローに対するテストでは、 /relationshipsに対してPOSTリクエストを送り、フォローされたユーザーが1人増えたことをチェックする。
具体的なコードは次のとおり。
assert_difference '@user.following.count', 1 do
post relationships_path, params: { followed_id: @other.id }
end
これは標準的なフォローに対するテストではあるが、Ajax版もやり方は大体同じ。
Ajaxのテストでは、xhr :trueオプションを使うようにするだけ。
assert_difference '@user.following.count', 1 do
post relationships_path, params: { followed_id: @other.id }, xhr: true
end
ここで使っているxhr(XmlHttpRequest)というオプションをtrueに設定すると、Ajaxでリクエストを発行するように変わる。
したがって、respond_toでは、JavaScriptに対応した行が実行されるようになる。
また、ユーザーをフォロー解除するときも構造はほとんど同じで、postメソッドをdeleteメソッドに置き換えてテストする。
つまり、そのユーザーのidとリレーションシップのidを使ってDELETEリクエストを送信し、フォローしている数が1つ減ることを確認する。
したがって、実際に加えるテストは、
assert_difference '@user.following.count', -1 do
delete relationship_path(relationship)
end
上の従来どおりのテストと、下のAjax用のテストの2つになる。
assert_difference '@user.following.count', -1 do
delete relationship_path(relationship), xhr: true
end
これらのテストをまとめた結果を、以下に示す。
require 'test_helper'
class FollowingTest < ActionDispatch::IntegrationTest
def setup
@user = users(:michael)
@other = users(:archer)
log_in_as(@user)
end
.
.
.
test "should follow a user the standard way" do
assert_difference '@user.following.count', 1 do
post relationships_path, params: { followed_id: @other.id }
end
end
test "should follow a user with Ajax" do
assert_difference '@user.following.count', 1 do
post relationships_path, xhr: true, params: { followed_id: @other.id }
end
end
test "should unfollow a user the standard way" do
@user.follow(@other)
relationship = @user.active_relationships.find_by(followed_id: @other.id)
assert_difference '@user.following.count', -1 do
delete relationship_path(relationship)
end
end
test "should unfollow a user with Ajax" do
@user.follow(@other)
relationship = @user.active_relationships.find_by(followed_id: @other.id)
assert_difference '@user.following.count', -1 do
delete relationship_path(relationship), xhr: true
end
end
end
ステータスフィード
最後の難関、ステータスフィードの実装に取りかかる。
ここで扱われている内容は、本書の中でも最も高度なもの。
完全なステータスフィードは、以前扱ったプロトフィードをベースにする。
現在のユーザーにフォローされているユーザーのマイクロポストの配列を作成し、現在のユーザー自身のマイクロポストと合わせて表示する。
このセクションを通して、複雑さを増したフィードの実装に進んでいく。
これを実現するためには、RailsとRubyの高度な機能の他に、SQLプログラミングの技術も必要。
手強い課題に挑むのですから、ここで実装すべき内容を慎重に見直すことが重要。
動機と計画
ステータスフィードの基本的なアイデアはシンプル。
図に、micropostsのサンプルデータ付きのデータモデルとその結果を示す。
図の矢印で示されているように、この目的は、現在のユーザーによってフォローされているユーザーに対応するユーザーidを持つマイクロポストを取り出し、同時に現在のユーザー自身のマイクロポストも一緒に取り出すこと。
どのようにフィードを実装するのかはまだ明確ではないが、テストについてはやや明確そうなので、(ガイドラインに従って)まずはテストから書いていくことにする。
このテストで重要なことは、フィードに必要な3つの条件を満たすこと。
具体的には、
- フォローしているユーザーのマイクロポストがフィードに含まれていること
- 自分自身のマイクロポストもフィードに含まれていること
- フォローしていないユーザーのマイクロポストがフィードに含まれていないこと
の3つ。
まずはMichaelがLanaをフォローしていて、Archerをフォローしていないという状況を作ってみる。
この状況のMichaelのフィードでは、Lanaと自分自身の投稿が見えていて、Archerの投稿は見えないことになる。
先ほどの3つの条件をアサーションに変換して、Userモデルにfeedメソッドがあることに注意しながら、更新したUserモデルに対するテストを書いてみる。
require 'test_helper'
class UserTest < ActiveSupport::TestCase
.
.
.
test "feed should have the right posts" do
michael = users(:michael)
archer = users(:archer)
lana = users(:lana)
# フォローしているユーザーの投稿を確認
lana.microposts.each do |post_following|
assert michael.feed.include?(post_following)
end
# 自分自身の投稿を確認
michael.microposts.each do |post_self|
assert michael.feed.include?(post_self)
end
# フォローしていないユーザーの投稿を確認
archer.microposts.each do |post_unfollowed|
assert_not michael.feed.include?(post_unfollowed)
end
end
end
フィードを初めて実装する
ステータスフィードに対する要件定義は上のテストで明確になったので(つまりこのテストにパスすれば良いので)、早速フィードの実装に着手してみる。
最終的なフィードの実装はやや込み入っているため、細かい部品を1つずつ確かめながら導入していく。
最初に、このフィードで必要なクエリについて考える。
ここで必要なのは、micropostsテーブルから、あるユーザー(つまり自分自身)がフォローしているユーザーに対応するidを持つマイクロポストをすべて選択(select)すること。
このクエリを模式的に書くと次のようになる。
SELECT * FROM microposts
WHERE user_id IN (<list of ids>) OR user_id = <user id>
上のコードを書く際に、SQLがINというキーワードをサポートしていることを前提にしている(実際にサポートされている)。
このキーワードを使うことで、idの集合の内包 (set inclusion) に対してテストを行える。
プロトフィードでは、上のような選択を行うためにActive Recordのwhereメソッドを使っていた。
このときに選択すべき対象はシンプルで、現在のユーザーに対応するユーザーidを持つマイクロポストを選択すればよかった。
Micropost.where("user_id = ?", id)
今回必要になる選択は、上よりも少し複雑で、例えば次のような形になる。
Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id)
これらの条件から、フォローされているユーザーに対応するidの配列が必要であることがわかってきた。
これを行う方法の1つは、Rubyのmapメソッドを使うこと。
このメソッドはすべての「列挙可能(enumerable)」なオブジェクト(配列やハッシュなど、要素の集合で構成されるあらゆるオブジェクト)で使える。
なお、このメソッドは以前も出てきた。
他の例題として、mapメソッドを使って配列を文字列に変換すると、次のようになる。
$ rails console
>> [1, 2, 3, 4].map { |i| i.to_s }
=> ["1", "2", "3", "4"]
上に示したような状況では、各要素に対して同じメソッドが実行される。
これは非常によく使われる方法であり、次のようにアンパサンド(Ampersand) & と、メソッドに対応するシンボルを使った短縮表記が使える。
この短縮表記であれば、変数iを使わずに済む。
>> [1, 2, 3, 4].map(&:to_s)
=> ["1", "2", "3", "4"]
この結果に対してjoinメソッドを使うと、idの集合をカンマ区切りの文字列として繋げることができる。
>> [1, 2, 3, 4].map(&:to_s).join(', ')
=> "1, 2, 3, 4"
上のコードを使えば、user.followingにある各要素のidを呼び出し、フォローしているユーザーのidを配列として扱うことができる。
例えばデータベースの最初のユーザーに対して実行すると、次のような結果になる。
>> User.first.following.map(&:id)
=> [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22,
23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41,
42, 43, 44, 45, 46, 47, 48, 49, 50, 51]
実際、この手法は実に便利なので、Active Recordでは次のようなメソッドも用意されている。
>> User.first.following_ids
=> [3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22,
23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41,
42, 43, 44, 45, 46, 47, 48, 49, 50, 51]
このfollowing_idsメソッドは、has_many :followingの関連付けをしたときにActive Recordが自動生成したもの。
これにより、user.followingコレクションに対応するidを得るためには、関連付けの名前の末尾に_idsを付け足すだけで済む。
結果として、フォローしているユーザーidの文字列は、次のようにして取得することができる。
>> User.first.following_ids.join(', ')
=> "3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22,
23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41,
42, 43, 44, 45, 46, 47, 48, 49, 50, 51
なお、以上は説明のためのコードであり、実際にSQL文字列に挿入するときは、このように記述する必要はない。
実は、?を内挿すると自動的にこの辺りの面倒を見てくれる。
さらに、データベースに依存する一部の非互換性まで解消してくれる。
つまり、ここではfollowing_idsメソッドをそのまま使えばよいだけ。
結果、最初に想像していたとおり、
Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id)
というコードが無事に動いた。
作成したコードを以下に示す。
class User < ApplicationRecord
.
.
.
# パスワード再設定の期限が切れている場合はtrueを返す
def password_reset_expired?
reset_sent_at < 2.hours.ago
end
# ユーザーのステータスフィードを返す
def feed
Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id)
end
# ユーザーをフォローする
def follow(other_user)
active_relationships.create(followed_id: other_user.id)
end
.
.
.
end
サブセレクト
フィードの実装は、投稿されたマイクロポストの数が膨大になったときにうまくスケールしない。
つまり、フォローしているユーザーが5,000人程度になるとWebサービス全体が遅くなる可能性がある。
ここでは、フォローしているユーザー数に応じてスケールできるように、ステータスフィードを改善していく。
上で示したコードの問題点は、following_idsでフォローしているすべてのユーザーをデータベースに問い合わせし、さらに、フォローしているユーザーの完全な配列を作るために再度データベースに問い合わせしている点。
上のコードの条件では、集合に内包されているかどうかだけしかチェックされていないため、この部分はもっと効率的なコードに置き換えられるはず。
また、SQLは本来このような集合の操作に最適化されている。
実際、このような問題は、SQLのサブセレクト(subselect)を使うと解決できる。
まずはコードを若干修正し、フィードをリファクタリングすることから始める。
class User < ApplicationRecord
.
.
.
# ユーザーのステータスフィードを返す
def feed
Micropost.where("user_id IN (:following_ids) OR user_id = :user_id",
following_ids: following_ids, user_id: id)
end
.
.
.
end
上の実装では、これまでのコード
Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id)
を次のように置き換えた。
Micropost.where("user_id IN (:following_ids) OR user_id = :user_id",
following_ids: following_ids, user_id: id)
前者の疑問符を使った文法も便利だが、同じ変数を複数の場所に挿入したい場合は、後者の置き換え後の文法を使う方がより便利。
上の説明が暗に示すように、これからSQLクエリにもう1つのuser_idを追加する。
following_ids
特に、上のRubyコードは、このようなSQLに置き換えることができる。
following_ids = "SELECT followed_id FROM relationships
WHERE follower_id = :user_id"
このコードをSQLのサブセレクトとして使う。
つまり、「ユーザー1がフォローしているユーザーすべてを選択する」というSQLを既存のSQLに内包させる形になり、結果としてSQLは次のようになる。
SELECT * FROM microposts
WHERE user_id IN (SELECT followed_id FROM relationships
WHERE follower_id = 1)
OR user_id = 1
このサブセレクトは集合のロジックを(Railsではなく)データベース内に保存するので、より効率的にデータを取得することができる。
これで基礎を固めることができたので、次のコードのようにもっと効率的なフィードを実装する準備ができた。
(ここに記述されているコードは生のSQLを表す文字列であり、following_idsという文字列はエスケープされているのではなく、見やすさのために式展開しているだけ。)
class User < ApplicationRecord
.
.
.
# ユーザーのステータスフィードを返す
def feed
following_ids = "SELECT followed_id FROM relationships
WHERE follower_id = :user_id"
Micropost.where("user_id IN (#{following_ids})
OR user_id = :user_id", user_id: id)
end
.
.
.
end
このコードはRailsとRubyとSQLのコードが複雑に絡み合っていて厄介だが、ちゃんと動作する。
もちろん、サブセレクトを使えばいくらでもスケールできるなどということはない。
大規模なWebサービスでは、バックグラウンド処理を使ってフィードを非同期で生成するなどのさらなる改善が必要。
ただし、Webサービスをスケールさせる技術は非常に高度かつデリケートな問題なので、ここではここまでの改善で止めておく。
この時点で、masterブランチに変更を取り込む準備ができた。
rails test
git add -A
git commit -m "Add user following"
git checkout master
git merge following-users
コードをリポジトリにpushして、本番環境にデプロイしてみる。
git push
git push heroku
heroku pg:reset DATABASE
heroku run rails db:migrate
heroku run rails db:seed
最後に
ステータスフィードが追加され、Ruby on Railsチュートリアルのサンプルアプリケーションがとうとう完成した。
このサンプルアプリケーションには、Railsの主要な機能(モデル、ビュー、コントローラ、テンプレート、パーシャル、beforeフィルター、バリデーション、コールバック、has_many/belongs_to/has_many through関連付け、セキュリティ、テスティング、デプロイ)が多数含まれている。
これだけでもかなりの量だが、Web開発について学ぶべきことはまだまだたくさんある。
今後の学習の手始めとするために、より踏み込んだ学習方法を紹介する。
学習方法に関してはここを参照。