静的型付け言語原理主義者による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
アクションを追加してルーティングを実装する
- Users コントローラに
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
アクションを追加してルーティングを実装する
- Users コントローラに
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 を正しく使えない自分が悪い、という話なのだと思う。
元々は自社のプロダクトコードをより理解したいという動機で始めた本チュートリアルだったので、これから戦場に飛び込んでみよう。いざ参らん。