##はじめに
この記事では、railsチュートリアル第14章の復習として行程をようやくしたものとなっています。
##ユーザーをフォローする
第14章では他のユーザーをフォロー (およびフォロー解除) できるソーシャルな仕組みの追加と、フォローしているユーザーの投稿をステータスフィードに表示する機能を追加していく。
###Relationshipモデル
ユーザーをフォローする機能を実装する第一歩は、データモデルを構成することとなる。
「1人のユーザーが複数のユーザーをhas_manyとしてフォローし、1人のユーザーに複数のフォロワーがいることをhas_manyで表す」
といった方法でも実装できそうだが、これでは問題が出てきてしまう。
上記のようにテーブル構造すると、非常に無駄が多く更新も大きな手間となってしまう事になる。
この問題の根本は、必要な抽象化を行なっていないことであり、正しいモデルを見つけ出す方法の1つは、Webアプリケーションにおける following
の動作をどのように実装するかを観察することである。
ここを観察すると、フォローまたはアンフォローで作成または削除されるのは、つまるところ2人のユーザーの**「関係 (リレーションシップ)」**であることがわかる。
リレーションシップを経由することによって1人のユーザーは1対多の関係を持つことができ、さらにユーザーは多くのfollowing (またはfollowers) と関係を持つことができるということになる。
また、Facebookのような友好関係 (Friendships) では本質的に左右対称のデータモデルが成り立つが、Twitterのようなフォロー関係では左右非対称の性質がある。
このような左右非対称な関係性を見分けるために、それぞれを**能動的関係 (Active Relationship)と受動的関係 (Passive Relationship)**と呼ぶことにする。
まずは、フォローしているユーザーを生成するために、能動的関係に焦点を当てていく。
フォローしているユーザーはfollowed_idがあれば識別することができるので、active_relationshipsテーブル
を作り、そこを経由することによって効率的なモデル構造を作る。
このデータモデルを実装するために、まずは次のようなマイグレーションを生成する。
$ 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
↑のマイグレーションファイルの、
add_index :relationships, [:follower_id, :followed_id], unique: true
という複合キーインデックスは、follower_idとfollowed_idの組み合わせが必ずユニークであることを保証する仕組みであり、これにより、あるユーザーが同じユーザーを2回以上フォローすることを防ぐ。
relationshipsテーブル
を作成するために、いつものようにデータベースのマイグレーションを行う。
$ rails db:migrate
###User/Relationshipの関連付け
フォローしているユーザーとフォロワーを実装する前に、UserとRelationshipの関連付けを行う。
1人のユーザーにはhas_many (1対多) のリレーションシップがあり、このリレーションシップは2人のユーザーの間の関係となるから、フォローしているユーザーとフォロワーの両方に属す。 (belongs_to)
今回のケースではフォローしているユーザーを follower_id
という外部キーを使って特定しなくてはならない。
また、followerというクラス名は存在しないので、ここでもRailsに正しいクラス名を伝える必要がある。
能動的関係に対して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
上記では、ユーザーを削除したら、ユーザーのリレーションシップも同時に削除される必要あるため、関連付けにdependent: :destroy
も追加している。
followerの関連付けについては現段階では使わないが、follower
と followed
を対称的に実装しておくことで、構造に対する理解は容易になる。
class Relationship < ApplicationRecord
belongs_to :follower, class_name: "User"
belongs_to :followed, class_name: "User"
end
上記で定義した関連付けにより下のようなメソッドが使用できるようになった。
メソッド | 用途 |
---|---|
active_relationship.follower | フォロワーを返す |
active_relationship.followed | フォローしているユーザーを返す |
user.active_relationships.create(followed_id: other_user.id) | userと紐付けて能動的関係を作成/登録する |
user.active_relationships.create!(followed_id: other_user.id) | userを紐付けて能動的関係を作成/登録する (失敗時にエラーを出力) |
user.active_relationships.build(followed_id: other_user.id) | userと紐付けた新しいRelationshipオブジェクトを返す |
###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
###フォローしているユーザー
followingとfollowersに関して今回はhas_many through
を使う。
デフォルトのhas_many throughという関連付けでは、Railsはモデル名 (単数形) に対応する外部キーを探す。
has_many :followeds, through: :active_relationships
上のコードの場合、Railsは「followeds」というシンボル名を見て、これを「followed」という単数形に変え、 relationshipsテーブルのfollowed_idを使って対象のユーザーを取得してくる。
ただ、user.followedsという名前は英語として不適切となる。
代わりに、user.following
という名前を使うことにする。
そのためには、Railsのデフォルトを上書きする必要があり、ここでは:sourceパラメーター
を使って**「following配列の元はfollowed idの集合である」**ということを明示的にRailsに伝える必要がある。
Userモデルにfollowingの関連付けを追加する↓
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で取得した集合をより簡単に取り扱うために、follow
や unfollow
といった便利メソッドを追加する。
"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
###フォロワー
ここでuser.followersメソッドを追加していく。
これは上のuser.followingメソッドと対になり、フォロワーの配列を展開するために必要な情報は、relationshipsテーブル
に既にある。
よって、follower_idとfollowed_idを入れ替えるだけで、フォロワーについてもフォローする場合と全く同じ方法が活用できる。
受動的関係を使って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
:followers属性
の場合、Railsが「followers」を単数形にして自動的に外部キーfollower_idを探してくれるから :sourceキー
を省略してもイイが has_many :following
との類似性を強調させるため残しておく。
##[Follow] のWebインターフェイス
この節では、フォロー/フォロー解除の基本的なインターフェイスを実装、また、フォローしているユーザーと、フォロワーにそれぞれ表示用のページを作成する。
###フォローのサンプルデータ
前の章のときと同じように、サンプルデータを自動作成する rails db:seed
を使って、データベースにサンプルデータを登録しておく。
先にサンプルデータを自動作成できるようにしておけば、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) }
ここでは、最初のユーザーにユーザー3からユーザー51までをフォローさせ、それから逆にユーザー4からユーザー41に最初のユーザーをフォローさせる。
データベース上のサンプルデータを作り直すために、いつものコマンドを実行する。
$ rails db:migrate:reset
$ rails db:seed
###統計と [Follow] フォーム
これでサンプルユーザーに、フォローしているユーザーとフォロワーができた。
プロフィールページと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
↑によって生成されるルーティングテーブルを下に示す
HTTPリクエスト | URL | アクション | 名前付きルート |
---|---|---|---|
GET | /users/1/following | following | following_user_path(1) |
GET | /users/1/followers | followers | followers_user_path(1) |
ルーティングを定義したので、統計情報のパーシャルを実装する。
<% @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>
<% @user ||= current_user %>
このコードは、@userがnilでない場合 (つまりプロフィールページの場合) は何もせず、nilの場合 (つまりHomeページの場合) には@userにcurrent_userを代入するコードとなる。
また、
...
</strong>
こうしておくと、Ajaxを実装するときに便利となる。
これを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にスタイルを加える。
次の行程として**[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リソース用の新しいルーティングを追加し、フォロー/フォロー解除用のパーシャルを個別に用意する必要がある。
Relationshipリソース用のルーティングを追加する↓
#省略
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>
この[Follow] / [Unfollow] ボタンの実装には標準的な方法とAjaxを使う方法の2つがある。
その前に、フォローしているユーザーとフォロワーを表示するページをそれぞれ作成してHTMLインターフェイスを完成させる。
###[Following] と [Followers] ページ
フォローしているユーザーを表示するページと、フォロワーを表示するページは、いずれもプロフィールページとユーザー一覧ページを合わせたもののようになる。
ここでの最初の作業は、フォローしているユーザーのリンクとフォロワーのリンクを動くようにすることとなる。
Twitterに倣って、どちらのページでもユーザーのログインを要求するようにする。
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は慣習に従って、アクションに対応するビューを暗黙的に呼び出す。
上記でも、renderを明示的に呼び出し、show_followという同じビューを出力している。したがって、作成が必要なビューはこれ1つですむ。
フォローしているユーザーとフォロワーの両方を表示する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>
###[Follow] ボタン (基本編)
フォローとフォロー解除はそれぞれリレーションシップの作成と削除に対応しているため、まずはRelationshipsコントローラ
が必要となる。
$ rails generate controller Relationships
次に、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メソッド
を使う。
Relationshipsコントローラ↓
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
これにより、フォロー/フォロー解除の機能が完成した。
###[Follow] ボタン (Ajax編)
上記ではRelationshipsコントローラの createアクション
と destroyアクション
を単に元のプロフィールにリダイレクトしていた。
ここで、Ajax
を使えば、Webページからサーバーに**「非同期」**で、ページを移動することなくリクエストを送信することができる。
WebフォームにAjaxを採用するのは今や当たり前になりつつあるので、RailsでもAjaxを簡単に実装できるようになっている。
form_for
というコードを
form_for ..., remote: true
と置き換えるだけで、Railsは自動的に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 %>
フォームの更新が終わったので、今度はこれに対応するRelationshipsコントローラ
を改造して、Ajaxリクエストに応答できるようにする。
こういったリクエストの種類によって応答を場合分けするときは、respond_toメソッド
というメソッドを使うようにする。
respond_to do |format|
format.html { redirect_to user }
format.js
end
上の (ブロック内の) コードのうち、いずれかの1行が実行される。
Relationshipsコントローラで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
ビューで変数を使うため、userが@userに変わっている
今度はブラウザ側で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
###.js.erbファイル
JavaScriptが有効になっていても、まだ十分に対応できていない部分がある。
というのも、Ajaxリクエストを受信した場合は、Railsが自動的にアクションと同じ名前を持つJavaScript用の埋め込みRuby (.js.erb) ファイル (create.js.erbやdestroy.js.erbなど) を呼び出指定しまうからである。
なのでファイルを新たに作成する必要がある。
JS-ERbファイルの内部では、DOM (Document Object Model) を使ってページを操作するため、RailsがjQuery 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 %>');
これらのコードにより、プロフィールページを更新させずにフォローとフォロー解除ができるようになった。
##ステータスフィード
現在のユーザーにフォローされているユーザーのマイクロポストの配列を作成し、現在のユーザー自身のマイクロポストと合わせて表示する。
ステータスフィードを実装するには現在のユーザーによってフォローされているユーザーに対応するユーザーidを持つマイクロポストを取り出し、同時に現在のユーザー自身のマイクロポストも一緒に取り出すようにする必要がある。
フィードに必要な3つの条件を下に示す。
・フォローしているユーザーのマイクロポストがフィードに含まれていること。
・自分自身のマイクロポストもフィードに含まれていること。
・フォローしていないユーザーのマイクロポストがフィードに含まれていないこと
最初に、このフィードで必要なクエリについて考えてみる。
ここで必要なのは、micropostsテーブル
から、あるユーザー (つまり自分自身) がフォローしているユーザーに対応するidを持つマイクロポストをすべて選択 (select) することである。
このクエリを模式的に書くと次のようになる。
WHERE user_id IN (<list of ids>) OR user_id = <user id>```
↑を参考に、今回必要になる選択は、上よりも少し複雑で、例えば次のような形になる。
```Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id)```
この```following_idsメソッド```は、**has_many :followingの関連付けをしたときにActive Recordが自動生成したもの**である。
これにより、user.followingコレクションに対応するidを得るためには、関連付けの名前の末尾に**_ids**を付け足すだけで済む。
```app/models/user.rb
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
###サブセレクト
問題点として上記のフィードの実装は、投稿されたマイクロポストの数が膨大になったときにうまくスケールしない、つまり、フォローしているユーザーが5,000人程度になるとWebサービス全体が遅くなる可能性がある。
上に示したコードの問題点は、following_idsでフォローしているすべてのユーザーをデータベースに問い合わせし、さらに、フォローしているユーザーの完全な配列を作るために再度データベースに問い合わせしている点である。
このような問題は、**SQLのサブセレクト(subselect)**を使うと解決できる。
まずはコードを若干修正し、フィードをリファクタリングすることから始める。
whereメソッド内の変数に、キーと値のペアを使う↓
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のサブセレクトとして使う。
つまり、「ユーザー1がフォローしているユーザーすべてを選択する」というSQLを既存のSQLに内包させる形になり、結果としてSQLは次のようになる。
WHERE user_id IN (SELECT followed_id FROM relationships
WHERE follower_id = 1)
OR user_id = 1
これでもっと効率的なフィードを実装する準備がきた。
フィードの最終的な実装↓
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
(ここに記述されているコードは生のSQLを表す文字列であり、following_idsという文字列はエスケープされているのではなく、見やすさのために式展開している)
これでステータスフィードの実装は完了。
##さいごに
これでrailsチュートリアルを全て完走した。
ここで出てくる知識はサービスを作る上で基本になるところだろうからしっかり復習して自分の知識にしようと思う。
ここにプラスアルファで返信機能などもつけることを演習として推奨されているのでまた挑戦してみようと思う。