目的
Rails Tutorial 14章の最難関、【サンプルアプリケーションの機能を拡張する】の注釈にある、
いいね機能を実装したので、Rails Tutorial風 に解説してみたいと思います。
環境
- Rails Tutorialと同じ環境
第14α章 マイクロポストをいいねする
この章では、サンプルアプリケーションの中にあればいいかな?という部分を完成させます。
具体的には、他のユーザのマイクロポストをいいね(およびいいね解除)できる機能を追加します。
また、1人のユーザが同じマイクロポストに複数回いいねすることもできないようにします。
本書の中で学んだ内容を思い出せば、この最終章(仮)もそれほど苦労はしないでしょう。
では、実装に入る前の事前準備として、以下にモック(完成版)を示します。
そして、まずはモデルから考え、その後コントローラ、ビュー、その他アクションの順で実装して
いきたいと思います。
14α.1 Likeモデル
マイクロポストをいいねする機能を実装する第一歩は、データモデルを構成することです。
前章を読み込んでいる読者であれば、has_many を使用すれば、実装できることが分かるでしょう。
では、まずはこれまで同様新しいトピックブランチを作成してください。
$ git checkout -b iine
14α.1.1 データモデルの構造
データモデルの構造は、ユーザをフォローする機能と少し似ています。簡単に言うと、Likeという”誰”が
”どのマイクロポスト”にいいねしたかを管理するモデルを追加すればよいのです。
このデータモデルを実装するために、マイグレーションを生成します。
$ rails generate model Like user_id:integer micropost_id:integer
また、追加したカラムでの検索が頻繁に行われるため、インデックスも追加します。
class CreateLikes < ActiveRecord::Migration[5.1]
def change
create_table :likes do |t|
t.integer :user_id, null: false
t.integer :micropost_id, null: false
t.timestamps
t.index :user_id
t.index :micropost_id
t.index [:user_id, :micropost_id], unique: true
end
end
end
relationshipsでも使用した、複合キーインデックスもあります。これにより、user_idとmicropost_idの
組み合わせが必ずユニークであることを保証し、1ユーザが同じマイクロポストに複数回いいねすることを防ぎます。
では、likesテーブルを作成するために、いつものようにデータベースのマイグレーションを行います。
$ rails db:migrate
14α.1.2 User/Like、Micropost/Likeの関連付け
1つのマイクロポストには1対多(has_many)のいいねがあり、1人のユーザには1対多(has_many)の
いいねがあります。いいねはその両方に属します(belongs_to)。
前章では、関連名をactive_relationshipsとしていたため、class_nameの記載が必要でしたが、
本章では余計なことをしないため、不要となります。
UserとLike、MicropostとLikeの関係をコードにまとめると、以下のようになります。
class User < ApplicationRecord
.
.
.
has_many :likes, dependent: :destroy
.
.
.
end
class Micropost < ApplicationRecord
.
.
.
has_many :likes, dependent: :destroy
.
.
.
end
class Like < ApplicationRecord
belongs_to :user
belongs_to :micropost
validates :user_id, presence: true
validates :micropost_id, presence: true
end
User、Micropostには、それぞれが削除されたらLikeも削除する、
Likeにはuser_id、micropost_idが必須である、という記載もしています。
14α.2 いいね機能を実装する
モデルの作成が完了したので、いよいよいいね機能に取りかかります。
ここでは、コントローラ、ビューについて作成していきましょう。
14α.2.1 ルーティング
まずはルーティングから考えましょう。Likesコントローラには、Maicroposts、Relationshipsと同様で、
createとdestoryがあれば十分です。コントローラを作成し、ルーティングを修正しましょう。
その際、コントローラ作成時に自動的に追加されるルーティングの削除を忘れないようにしてください。
$ rails generate controller Likes create destroy
Rails.application.routes.draw do
.
.
.
resources :likes, only: [:create, :destroy]
.
.
.
end
HTTPリクエスト | URL | アクション | 名前付きルート |
---|---|---|---|
POST | /likes | create | likes_path |
DELETE | /likes/1 | destroy | like_path(like) |
表 14α-1: Likesリソースが提供するRESTfulルート |
14α.2.2 いいね機能(仮)を実装する
ルーティングができたので、機能について考えましょう。
考え方のヒントは「”誰”が”どのマイクロポスト”にいいねしたか」となります。
”誰”がいいねをするかというと、ログインしているユーザとなるので、current_userです。
”どのマイクロポスト”にいいねするかというと、いいねボタンは、図 14α-1 を見て分かるとおり、
各マイクロポストに対して配置していますので、マイクロポストのいいね機能をMicropostモデルに
記載することが分かります。ユーザーのフォローの際は、フォローボタンをユーザーページに配置し、
フォロー機能をUserモデルに記載していましたが、それと同様です。
これで、likesテーブルのレコードを作る情報が全て出揃いましたので、いいね機能(仮)を実装できるはずです。
class Micropost < ApplicationRecord
.
.
.
# マイクロポストをいいねする
def iine(user)
likes.create(user_id: user.id)
end
# マイクロポストのいいねを解除する(ネーミングセンスに対するクレームは受け付けません)
def uniine(user)
likes.find_by(user_id: user.id).destroy
end
.
.
.
end
これらのメソッドをコントローラから呼び出せば(仮)の完成です。
class LikesController < ApplicationController
before_action :logged_in_user
def create
@micropost = Micropost.find(params[:micropost_id])
@micropost.iine(current_user)
end
def destroy
@micropost = Like.find(params[:id]).micropost
@micropost.uniine(current_user)
end
end
14α.3 いいねボタンを実装する
コントローラでの処理ができましたので、次はいいねボタンを実装していきます。
Bootstrapには便利なiconがありますので、それを利用していきたいと思います。
また、フォローボタンと同じように、いいねボタンを押した時にページの再ロードが起こらないように、
Ajaxを使用して非同期でリクエストを飛ばすようにします。
14α.3-1 いいねボタンの配置とデザイン
いいねボタンはマイクロポストの下に配置するので、MicropostビューにLikeビューを配置することが分かります。
<li id="micropost-<%= micropost.id %>" data-micropost-id="<%= micropost.id %>">
.
.
.
<span class="timestamp">
.
.
.
</span>
<%= render "likes/like", micropost: micropost %>
</li>
render時に見慣れない記述がありますね。
<%= render "likes/like", micropost: micropost %>
Likesコントローラではマイクロポストのidが必要になるため、Likeビューをパーシャル化する際に、
Micropostをビューに渡してあげる必要があります。
次に、上記で呼んでいるLikeのパーシャルは以下のようになります。
<% if !current_user?(micropost.user) %>
<span class="like">
<% if micropost.iine?(current_user) %>
<%= form_for(micropost.likes.find_by(user_id: current_user.id), method: :delete, remote: true) do |f| %>
<%= button_tag(class: "btn btn-default btn-xs") do %>
<%= content_tag :span, "#", class: "glyphicon glyphicon-heart" %>
<% end %>
<% end %>
<% else %>
<%= form_for(micropost.likes.build, remote: true) do |f| %>
<div><%= hidden_field_tag :micropost_id, micropost.id %></div>
<%= button_tag(class: "btn btn-default btn-xs") do %>
<%= content_tag :span, "#", class: "glyphicon glyphicon-heart-empty" %>
<% end %>
<% end %>
<% end %>
</span>
<% end %>
ざっくり説明すると以下の通りになります。
- いいねボタンは自分のマイクロポスト以外の場合に表示
- 既にいいねしている場合は、いいねボタンを塗りつぶされたハートとし、押下時にdestroyを呼ぶ
- いいねしていない場合は、いいねボタンを塗りつぶされていないハートとし、押下時にcreateを呼ぶ
button_tag配下にcontent_tagを指定し、classにBootstrapのiconのクラスを指定すると、
ボタンにクラスに応じた可愛いiconが表示されます。content_tagにある"#"の箇所に文字列を入れれば、
そのiconの後ろに該当の文字列が表示されます。ここには後ほど、いいね数を表示するようにします。
ここで、既にいいねされているかどうかを判断する必要が出てきました。
<% if micropost.iine?(current_user) %>
このメソッドをMicropostモデルに作成します。その際に、モデルの関連も追加したいと思います。
どのような関連かというと、「マイクロポストにいいねをしたユーザーの一覧」という関連です。
これを追加すれば、あとはそのユーザ一覧にcurrent_userがいれば、既にいいねしている、
いなければ、まだいいねしていないということが分かります。
class Micropost < ApplicationRecord
.
.
.
has_many :iine_users, through: :likes, source: :user
.
.
.
# 現在のユーザーがいいねしてたらtrueを返す
def iine?(user)
iine_users.include?(user)
end
.
.
.
end
14α.3-2 いいねボタン(Ajax化)
上記までの実装で、基幹部分の実装ができていますが、いいねボタン押下時の描画の実装をしていません。
その描画をAjaxを使用して実装していきます。これはフォローボタンのAjax化とほとんど同じです。
まずはコントローラをAjaxリクエストに対応させます。
class LikesController < ApplicationController
before_action :logged_in_user
def create
@micropost = Micropost.find(params[:micropost_id])
unless @micropost.iine?(current_user)
@micropost.iine(current_user)
respond_to do |format|
format.html { redirect_to request.referrer || root_url }
format.js
end
end
end
def destroy
@micropost = Like.find(params[:id]).micropost
if @micropost.iine?(current_user)
@micropost.uniine(current_user)
respond_to do |format|
format.html { redirect_to request.referrer || root_url }
format.js
end
end
end
end
ここで、既にいいねされているかどうかの確認を追加していることにも注意してください。likeテーブルは
user_id、micropost_idがPKとなっているため、2つのブラウザで同時にいいねされてもエラーと
ならないようにしています。
次に、js用のerbを作成します。
$("#micropost-<%= @micropost.id %> .like").html("<%= escape_javascript(render "likes/like", micropost: @micropost) %>");
$("#micropost-<%= @micropost.id %> .like").html("<%= escape_javascript(render "likes/like", micropost: @micropost) %>");
...どちらも実施していることは一緒ですね。
フォローの場合は、フォロー用パーシャルとアンフォロー用パーシャルが分かれていたかと思いますが、
いいねの場合は、パーシャルを分けていません。その理由は忘れましたが、分けて実装しようとした際に
出来なかった気がします。もし出来るようでしたらご教授いただきますよう、よろしくお願いいたします。
これで、いいね機能が正しく動作するようになりました。しかし、まだ終わりではありません。
いいねの数をいいねボタンの横に表示する必要があります。
14α.3-3 いいね数のカウントとcounter_culture
いいね数のカウント方法ですが、railsには counter_cache という機能があります。
しかし、どうやらより高機能である、 counter_culture というgemがあるみたいですので、
そちらを利用したいと思います。
counter_cultureのREADMEを読みながら実装していきます。
source 'https://rubygems.org'
.
.
.
gem 'counter_culture', '~> 1.8'
group :development, :test do
.
.
.
いつものようにbundle installを実行して、counter_cultureをインストールします。
$ bundle install
次に、必要なテーブルにカウント数を格納するカラムを追加します。今回は、Micropostテーブルに
いいね数を格納したいので、Micropostテーブルに追加します。
$ rails generate migration add_likes_count_to_microposts likes_count:integer
生成されたマイグレーションファイルを編集します。
class AddLikesCountToMicroposts < ActiveRecord::Migration[5.1]
class MigrationUser < ApplicationRecord
self.table_name = :microposts
end
def up
_up
rescue => e
_down
raise e
end
def down
_down
end
private
def _up
MigrationUser.reset_column_information
add_column :microposts, :likes_count, :integer, null: false, default: 0 unless column_exists? :microposts, :likes_count
end
def _down
MigrationUser.reset_column_information
remove_column :microposts, :likes_count if column_exists? :microposts, :likes_count
end
end
ここでは、筆者の練習のため、changeメソッドは使用せず、up、downメソッドを使用しています。
なぜ分割しているかというと、migrationファイルは冪等性(べきとうせい)を担保する必要があり、
upとdownで処理を分ける場合があるからです。
冪等性が担保されなかった場合、rollbackが正しく行われず、中途半端なDB状態となる恐れがあります。
(私たちのプロジェクトでも過去のmigrationファイルの冪等性が担保されていなかったために、
開発環境のDBが壊れ、バックアップから復元する羽目になりました。)
では、いつものようにデータベースのマイグレーションを行います。
$ rails db:migrate
ここで、冪等性が担保されているか確認するため、再度マイグレーションを行います。
$ rails db:migrate:redo
そして関連付けを行います。
class Like < ApplicationRecord
belongs_to :user
belongs_to :micropost
counter_culture :micropost
validates :user_id, presence: true
validates :micropost_id, presence: true
end
これでいいねのカウントを自動的に集計し、Micropostテーブルに持つことが出来るようになりました。
後はそれを表示するだけとなります。Bootstrapのiconを使用する際に、#としていた箇所を書き換えます。
<% if !current_user?(micropost.user) %>
<span class="like">
<% if micropost.iine?(current_user) %>
<%= form_for(micropost.likes.find_by(user_id: current_user.id), method: :delete, remote: true) do |f| %>
<%= button_tag(class: "btn btn-default btn-xs") do %>
<%= content_tag :span, "#{micropost.likes_count}", class: "glyphicon glyphicon-heart" %>
<% end %>
<% end %>
<% else %>
<%= form_for(micropost.likes.build, remote: true) do |f| %>
<div><%= hidden_field_tag :micropost_id, micropost.id %></div>
<%= button_tag(class: "btn btn-default btn-xs") do %>
<%= content_tag :span, "#{micropost.likes_count}", class: "glyphicon glyphicon-heart-empty" %>
<% end %>
<% end %>
<% end %>
</span>
<% end %>
これで、完成となります。実際に動かしてみましょう。
.
.
.
動かしてみましたか?
筆者は完成したと思って動かしてみたら、カウント数が正しくないことに気づきました。
「うぉぉおおおきたぁぁああああ」って喜んて恥ずかしい思いをしました。
原因はビューに渡しているMicropostが、いいねする前の状態となっているからです。
これは、コントローラでiine後にreloadをすれば解決します。
class LikesController < ApplicationController
before_action :logged_in_user
def create
@micropost = Micropost.find(params[:micropost_id])
unless @micropost.iine?(current_user)
@micropost.iine(current_user)
@micropost.reload
respond_to do |format|
format.html { redirect_to request.referrer || root_url }
format.js
end
end
end
def destroy
@micropost = Like.find(params[:id]).micropost
if @micropost.iine?(current_user)
@micropost.uniine(current_user)
@micropost.reload
respond_to do |format|
format.html { redirect_to request.referrer || root_url }
format.js
end
end
end
end
これで本当に完成となります。実際に動かして確認してみましょう。
※testについては、本項では記載いたしません。
14α.4 本番環境へデプロイする
testが完了したら、masterブランチに取り込みましょう。
$ rails test
$ git add -A
$ git commit -m "Add micropost iine"
$ git checkout master
$ git merge iine
コードをリポジトリにpushして、本番環境にデプロイしてみましょう。
$ git push
$ git push heroku
$ heroku pg:reset DATABASE
$ heroku run rails db:migrate
$ heroku run rails db:seed
感想
railsを勉強してから2ヶ月で、ほぼ自分一人で初めて1つの機能を作りましたが、
先人の知恵(gem)の探し方がとても難しいと感じました。
これを期に、どんな先人の知恵があるのか、トレンドなどを見るようにしたいと思います。