静的型付け言語原理主義者によるRailsチュートリアル(第14章)
第14章 ユーザーをフォローする
- 他のユーザーをフォロー/フォロー解除できるソーシャルな仕組みの追加と、フォローしているユーザーと投稿をステータスフィードに表示する機能を追加する
14.1 Relationship モデル
has_many
の関連付けを用いて「1人のユーザーが複数のユーザーをhas_many
としてフォローし、1人のユーザーにフォロワーがいることをhas_many
で表す」という方法で実装はできそうだが、問題が発生するhas_many through
で解決
14.1.1 データモデルの問題(および解決策)
following
とfollowers
の関係性をリレーションシップというモデルで表現する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回以上フォローすることを防ぐ
relationship
テーブルを作成するためにいつもの通りマイグレーション
14.1.2 User/Relationship の関連付け
- 1人のユーザーには
has_many
のリレーションシップがあり、このリレーションシップは2人のユーアー間の関係なので、フォローしているユーザーとフォロワーの両方に属する(belongs_to
) 能動的関係に対して1対多(
has_many
)の関連付けを実装するclass User < ApplicationRecord has_many :microposts, dependent: :destroy has_many :active_relationships, class_name: "Relationship", foreign_key: "follower_id", dependent: :destroy . . . end
リレーションシップ/フォロワーに対して
belongs_to
の関連付けを追加するclass Relationship < ApplicationRecord belongs_to :follower, class_name: "User" belongs_to :followed, class_name: "User" end
14.1.3 Relationship のバリデーション
Relationship モデルのバリデーションをテストする
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
Relationship モデルに対してバリデーションを追加する
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
User 用の fixture ファイルと同様に、生成された Relationship 用の fixutre ではマイグレーションで成約させた一意性を満たすことができないため、現時点では生成された Relationship 用の fixutre は空にしておく
14.1.4 フォローしているユーザー
has_many through
を使って 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 :following, through: :active_relationships, source: :followed . . . end
これによりフォローしているユーザーを
following
という配列のように扱えるようになるTDD で
follow
やunfollow
というメソッドを作るfollowing?
メソッドであるユーザーを未フォローであることを確認follow
メソッドでそのユーザーをフォローfollowing?
メソッドでフォロー中になったことを確認unfollow
メソッドでフォロー解除を確認という手順で following 関連のメソッドをテストする
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 関連のメソッドを実装する
class User < ApplicationRecord . . . def feed . . . end # ユーザーをフォローする 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
これでテストが通る
14.1.5 フォロワー
user.following
メソッドと対になるuser.followers
メソッドを追加するactive_relationship
テーブルを再利用し、受動的関係を使って実装する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.include?
メソッドを使ってfollowers
に対するテストを追加する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
14.2 [Follow] の Web インターフェイス
- フォロー/フォロー解除の基本的なインターフェイスを実装し、フォローしているユーザーと、フォロワーにそれぞれ表示用のページを作成する
14.2.1 フォローのサンプルデータ
サンプルデータを自動作成する
rails db:seed
を使って DB にサンプルデータを登録できると便利なので、サンプルデータに following/follower の関係性を追加する# ユーザー 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) }
DB 上のサンプルデータを作り直して完了
14.2.2 統計と [Follow] フォーム
- サンプルユーザーにフォローしているユーザーとフォロワーができたので、プロフィールページと Home ページを更新してこれを反映する
- 最初にプロフィールページと Home ページにフォローしているユーザーとフォロワーの統計情報表示用のパーシャルを作成する
- 次にフォロー用とフォロー解除用のフォームを作成する
- 最後にフォローしているユーザーの一覧とフォロワーの一覧を表示するページを作成する
統計情報の表示はリンクなっており、専用の表示ページに移動できる
Users コントローラに
following
アクションとfollowers
アクションを追加してルーティングを実装する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
ルーティングを定義したので、フォロワーの統計情報を表示するパーシャルを実装する
<% @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 ページにフォロワーの統計情報を表示する
<% 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 を追加してスタイルを整えて完成
14.2.2 統計と [Follow] フォーム
- サンプルユーザーにフォローしているユーザーとフォロワーができたので、プロフィールページと Home ページを更新してこれを反映する
- 最初にプロフィールページと Home ページにフォローしているユーザーとフォロワーの統計情報表示用のパーシャルを作成する
- 次にフォロー用とフォロー解除用のフォームを作成する
- 最後にフォローしているユーザーの一覧とフォロワーの一覧を表示するページを作成する
統計情報の表示はリンクなっており、専用の表示ページに移動できる
Users コントローラに
following
アクションとfollowers
アクションを追加してルーティングを実装する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
ルーティングを定義したので、フォロワーの統計情報を表示するパーシャルを実装する
<% @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 ページにフォロワーの統計情報を表示する
<% 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 を追加してスタイルを整えて完成
フォロー/フォロー解除フォームのパーシャルも作成しておく
<% unless current_user?(@user) %> <div id="follow_form"> <% if current_user.following?(@user) %> <%= render 'unfollow' %> <% else %> <%= render 'follow' %> <% end %> </div> <% end %>
上記コードは
follow
とunfollow
のパーシャルに作業を振っているだけパーシャルでは Relationship リソース用の新しいルーティングが必要
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 %>
最後にプロフィールページにフォロー用フォームとフォロワーの統計情報を追加する
<% 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>
14.2.3 [Following] と [Followers] ページ
- フォローしているユーザーを表示するページとフォロワーを表示するページは、いずれもプロフィールページとユーザー一覧ページを合わせたような作りになるという点で似ている
まずはフォローしているユーザーのリンクとフォロワーのリンクを動くようにする
- どちらのページでもログインを要求する
まずはテストから
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
Users コントローラに2つの新しいアクションを追加する必要がある
ルーティングに基づいた
following
及び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
フォローしているユーザーとフォロワーの両方を表示する
show_follow
ビューを作成する<% 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>
show_follow
の描画結果を確認するための統合テストを書いていくRelationship 用の fixture にテストデータを追加し、following/follower ページをテストする
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
14.2.4 [Follow] ボタン (基本編)
- [Follow]/[Unfollow] ボタンを動作させるため、まずは 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 コントローラのアクションに対して追加し、リレーションシップのアクセス制御を行う[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
14.2.5 [Follow] ボタン (Ajax編)
- ユーザーをフォローした後、ページから離れて元のページに戻る仕様が微妙
- Ajax で解決できる
- Web ページからサーバーに非同期でページを移動することなくリクエストを送信可能
- Ajax で解決できる
フォローフォームを 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 %>
フォロー解除フォームを Ajax 使用に変更する
<%= 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 %>
フォームの更新が終わったので、これに対応する Relationship コントローラを 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
ブラウザ側で JavaScript が無効になっていた場合も動作するようにする
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 と埋め込み Ruby を使ってフォローの関係性を作成する
create.js.erb
とdestroy.js.erb
を更新
14.2.6 フォローをテストする
フォローボタンが動くようになったので、テストを書いていく
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
14.3 ステータスフィード
- 現在のユーザーにフォローされているユーザーのマイクロポストの配列を作成し、現在のユーザー自身のマイクロポストと合わせて表示する
14.3.1 動機と計画
- 現在のユーザーによってフォローされているユーザーに対応するユーザー id を持つマイクロポストを取り出し、同時に現在のユーザー自身のマイクロポストも一緒に取り出すことが目的
以下の条件を満たすテストを書いていく
- フォローしているユーザーのマイクロポストがフィードに含まれていること
- 自分自身のマイクロポストもフィードに含まれていること
フォローしていないユーザーのマイクロポストがフィードに含まれていないこと
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
14.3.2 フィードを初めて実装する
ここでは
microposts
テーブルからあるユーザーがフォローしているユーザーに対する 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) following << other_user end . . . end
14.3.3 サブセレクト
- 今のつくりだとユーザーが5000人程度になると Web サービス全体が遅くなる可能性があるので、改善していく
where
メソッド内の変数にキーと値のペアを使い、DB への問い合わせ数を減らす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
ここからさらに following_ids を SQLに置き換えて最終的な実装とする
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
サンプルアプリケーションの完成!
14.4 最後に
- 完成したよ!!
所感
チマチマと進めていた Rails チュートリアルもようやく終わった。最後はちょっと飽きてきていたものの、とりあえず完走できたので良かった。
Rails に対する悪評はよく耳にしていたが、用法用量を守って Rails の機能を使わないと振り回されてしまう点や、密に結合したMVCのアーキテクチャと継続的に変更を加えていくプロダクト開発との相性が悪そうな点が、その悪評の根底にあるのかなと本チュートリアルを通して認識できた。Rails が悪いのではなく、Rails を正しく使えない自分が悪い、という話なのだと思う。
元々は自社のプロダクトコードをより理解したいという動機で始めた本チュートリアルだったので、これから戦場に飛び込んでみよう。いざ参らん。