ユーザーをフォローする
■第14章
他のユーザーをフォロしたりフォロー解除したり、フォローしているユーザーの投稿をステータスフィードに表示する機能を追加する。
ここで学んだデータモデルは、今後自分用のWebアプリケーションを開発するときに必ず役に立つらしいので、理解できるよう頑張ります。。
14.1 Relationshipモデル
has_manyを使えば実現できるようなものではない。問題は生じてしまう。
それを解決するためにhas_many throughについて説明する。
14.1.1 データモデルの問題 (および解決策)
いい加減なモデルを作ってしまうと、とにかく面倒で悲惨なことになる。
データの関係についてしっかり考えて作成する。
userとuserを繋ぐテーブルactive_relationshipsを作成する。
マイグレーション生成。
$ rails generate model Relationship follower_id:integer followed_id:integer
このリレーションシップは今後follower_idとfollowed_idで頻繁に検索することになるので、それぞれのカラムにインデックスを追加。
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
一番下のadd_indexは、uniqueと書かれている。これは必ずユニークであることを保証する仕組み。
relationshipsテーブルを作成するためにマイグレーション。
rails db:migrate
14.1.2 User/Relationshipの関連付け
UserとRelationshipの関連付けを行う。
能動的関係に対して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のバリデーション
まずはテストを作成する。fixtureはいったん空にしておく。
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
無事テストも成功。
14.1.4 フォローしているユーザー
followingがフォロー先ユーザー、followersはフォロー元ユーザー。
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
これにより、フォローしているユーザーを配列の様に扱えるようになった。
followや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
実際にフォローするメソッド、フォロー解除するメソッド、
現在のユーザーがフォローしてたらtrueを返すメソッドを追加する。
# ユーザーをフォローする
  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
テストも通過しました。
14.1.5 フォロワー
user.followersメソッドを追加する。フォローと似たようなことをしていく。
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
テストを書いていく。
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
テストもGREENに。今回は順調です。
14.2 [Follow] のWebインターフェイス
フォロー/フォロー解除の基本的なインターフェイスを実装していく。また、フォローしているユーザーと、フォロワーにそれぞれ表示用のページを作成する。
14.2.1 フォローのサンプルデータ
先にサンプルデータを自動作成できるようにしておけば、Webページの見た目のデザインから先にとりかかることができ、バックエンド機能の実装を後に回すことができるらしい。
サンプルデータにfollowing/followerの関係性を追加する。以下のコードを追加。
# リレーションシップ
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) }
14.2.2 統計と [Follow] フォーム
最初に、プロフィールページとHomeページに、フォローしているユーザーとフォロワーの統計情報を表示するためのパーシャルを作成する。
次に、フォロー用とフォロー解除用のフォームを作成する。それから、フォローしているユーザーの一覧 ("following") とフォロワーの一覧 ("followers") を表示する専用のページを作成する。
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
ルーティングを定義したので、統計情報のパーシャルを実装する準備が整った。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にも追加する。
<% 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 %>
見た目を整えるためにCSSも弄る。
[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リソース用の新しいルーティングが必要。
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 btn-default" %>
<% end %>
れらの2つのフォームの主な違いは、上は新しいリレーションシップを作成するのに対し、下は既存のリレーションシップを見つけ出すという点。
プロフィールにフォローとフォロー解除のボタンをそれぞれ表示するようにする。
<% 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
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
Railsは慣習に従って、アクションに対応するビューを暗黙的に呼び出す。
フォローしているユーザーとフォロワーの両方を表示する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>
無事テストもGREEENに。
show_followの描画結果を確認するため、統合テストを書いていく。
$ rails generate integration_test following
fixtureも書いていく。
one:
  follower: michael
  followed: lana
two:
  follower: michael
  followed: malory
three:
  follower: lana
  followed: michael
four:
  follower: archer
  followed: michael
さらに、正しい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
テストも無事GREENに。
14.2.4 [Follow] ボタン (基本編)
ボタンが動作するようにしていく。Relationshipsコントローラが必要なので生成する。
$ rails generate controller 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
Relationshipsコントローラのアクションに対してbeforeを追加する。
class RelationshipsController < ApplicationController
  before_action :logged_in_user
  def create
  end
  def destroy
  end
end
ボタンを正常動作させるために、対応するユーザーを見つけてこなければならない。
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ページからサーバーに「非同期」で、ページを移動することなくリクエストを送信することができる。WebフォームにAjaxを採用するのは今や当たり前になりつつあるので、RailsでもAjaxを簡単に実装できるようになっているらしい。
form_for
これを
form_for ..., remote: true
こう置き換えるだけで、Railsは自動的にAjaxを使うようになる。該当箇所を修正。
Ajaxに対応させるために、respond_toメソッドをcreateアクションとdestroyアクションにそれぞれ追加する。
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を動かせるようにするためには以下も必要。
JavaScriptと埋め込みRubyを使ってフォローの関係性を作成する。
$("#follow_form").html("<%= escape_javascript(render('users/unfollow')) %>");
$("#followers").html('<%= @user.followers.count %>');
Ruby JavaScript (RJS) を使ってフォローの関係性を削除する。
$("#follow_form").html("<%= escape_javascript(render('users/follow')) %>");
$("#followers").html('<%= @user.followers.count %>');
これで、プロフィールページを更新させずにフォローとフォロー解除ができるようになった。
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 動機と計画
テストを書いていく中で重要なのは、フィードに必要な以下の3つの条件を満たすこと。
- 
フォローしているユーザーのマイクロポストがフィードに含まれていること。
 - 
自分自身のマイクロポストもフィードに含まれていること。
 - 
フォローしていないユーザーのマイクロポストがフィードに含まれていないこと
 
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
今のままだとまだテストはRED。
14.3.2 フィードを初めて実装する
ユーザーのステータスフィードを返す。
とりあえず動くフィードの実装。
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 サブセレクト
フィードの実装は、投稿されたマイクロポストの数が膨大になったときにうまくスケールしないらしい。
フォローしているユーザーが5,000人程度になるとWebサービス全体が遅くなる可能性があるから、フォローしているユーザー数に応じてスケールできるように、ステータスフィードを改善していく。
効率的にコードを置き換えるために、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
テストも無事完了!
まとめ
できたアプリがこちら
無事Railsチュートリアルを完走することができました。エラーが出た時はかなりしんどかったですが、力技でなんとか押し切れました。
Webアプリケーションがどのように出来ているか、なんとなくですが掴めた気がします。
思ったことがいくつかあるのですが、うまくまとめらないので、箇条書きにして綴っていきます。
所感
・Railsチュートリアルは多くの人が取り組んでいるので、エラーや詰まった時に参考になる記事が多い。
・プログラミングを学んでいくなら、成果物を作りながらの方が性に合う。手法を学んでいくだけだと作業感が強い。
・自分で調べながら全部できるようになるのまでは、時間がかかりそう。
・パソコンと向き合う時間が長いので、視力低下や疲れの対策を早めに取っておきたい。
・Rubyの書籍の目次などに目を通してみると、ほとんどチュートリアルでやったことだった。
・一周でも得るものは多かったが、二周目、三周目とやって理解を深めた方が良さそう。
疑問
・人やタスク、チームの規模によりけりな気はするが、大体テストを作るだとか、何か一つタスクを終えるのにはどのくらいの時間がかかるのか
・チームでやっていく中で分担して作業していくとどのような感じになるのか
・フロント、バックエンドと分けて考えることが基本だが、どちらにも必須な知識はどのようなことなのか
やりたいこと
・上の疑問を解消していきたい。
・人とコミュニケーションを取りながら作業することの経験を積みたい。
・将来的に、自分の興味のある組み込み系のことが少しでもできたらありがたい。
・エンジニアをジャンル分けした時に、Web系と組み込み系は分かれるらしいが、どちらにも活きる知識を身につけたい。
・自分でもWebアプリケーションが開発できるようになりたい。
拙い文章ですが、以上です。