##はじめに
Railsチュートリアルで作った SAMPLE APP を拡張して、SNSサービスを作ってみました。
詰まった所や解決方法を共有することで、同じくらいの境遇の方の参考になればと思います。
完成したサイトはこのような感じになりました。
https://susumeru.herokuapp.com/
##環境
Railsチュートリアルの環境と全く同じです。新しいgemなどは入れていません。
参考:https://railstutorial.jp/chapters/user_microposts?version=5.1#code-final_gemfile
Rails のバージョンは 5.1.6 、AWS Cloud9 で開発を行いました。
##拡張した機能
- プロフィールの説明欄
- ツイッターリンク
- トップページ
- micropost の拡張(タイトル・カテゴリー・ジャンルなどの追加)
- ミュート
- 投稿の編集
- 検索
- お気に入り
- 通知
この中から特に苦労した5つをピックアップして、実装までの過程を紹介していきたいと思います。なお、コードの内容は SAMPLE APP を完成させていることを前提としています。
1. トップページ
ログインしていなくても閲覧できるトップページを作ります。
カテゴリーごとに違うページを表示させたいのですが、中身のコードはほとんど同じなので1つのビューファイルとアクションで対応できそうです。
ここではもともとリソースの一覧を表示する役割を持つ、index アクションを利用します。
<% unless logged_in? %>
<%= link_to "新規登録", signup_path, class: "btn btn-primary" %>
<%= link_to "ログイン", login_path, class: "btn btn-primary" %>
<% end %>
<%= render 'head' %>
<% if @micropost_items.any? %>
<ol class="microposts">
<%= render @micropost_items %>
</ol>
<%= will_paginate @micropost_items %>
<% end %>
<h3>みんなのおすすめ作品</h3>
<p> <%= link_to "TOP", "/index" %> |
<%= link_to "漫画", "/index/comic" %> |
<%= link_to "web漫画", "/index/web_comic" %> |
<%= link_to "小説", "/index/novel" %> |
<%= link_to "web小説", "/index/web_novel" %> |
<%= link_to "アニメ", "/index/anime" %> |
<%= link_to "映画", "/index/movie" %> </p>
...
このようにリンクを飛ばすことで、URLに情報を埋め込みます。
Rails.application.routes.draw do
...
get '/index', to: 'static_pages#index'
get '/index/:id', to: 'static_pages#index'
...
埋め込まれた情報があれば /:id
でキャッチして、indexアクションに渡します。
class StaticPagesController < ApplicationController
...
def index
if params[:id]
@micropost_items = Micropost.where("category = '#{params[:id]}'")
.paginate(page: params[:page], per_page: 10)
else
@micropost_items = Micropost.all.paginate(page: params[:page], per_page: 10)
end
end
...
end
params[:id]
の有無で条件分岐をして、完成です。
実は当初、params[:id]
の :id
の中には数字しか埋め込めないと勘違いしていてかなり遠回りをしていました。この通り英字もparams
として渡すことができます。また /index/:category
としてルーティングして params[:category]
で受け取ることも可能のようです。
2. 検索
単数ワードのみ対応で、micropost との部分一致で結果を返す検索機能を実装します。
まずはビューファイルに検索フォームを設置します。
...
<%= form_tag(index_path, method: :get) do %>
<div class="input-group">
<%= search_field_tag "q", params[:q], class: "form-control", placeholder: "キーワード検索" %>
<span class="input-group-btn">
<%= submit_tag "検索", class: "btn btn-primary" %>
</span>
</div>
<% end %>
検索結果はトップページで表示できそうなので、検索キーワードを params[:q]
に保存してindexアクションに送っています。
class StaticPagesController < ApplicationController
...
def index
if params[:q]
@micropost_items = Micropost.search_by_keyword(params[:q])
.paginate(page: params[:page], per_page: 10)
elsif params[:id]
@micropost_items = Micropost.where("category = '#{params[:id]}'")
.paginate(page: params[:page], per_page: 10)
else
@micropost_items = Micropost.all.paginate(page: params[:page], per_page: 10)
end
end
...
end
indexアクションに追記して、条件分岐を追加しました。params[:q]
を持っている時が最優先されます。
Micropost テーブルから該当要素を探し出すのに search_by_keyword
メソッドを使用していますが、これは where句などをメソッドとして定義することができる、Rails の scope という機能を用いたものです。
この scope 機能は 各モデルで定義することができます。
class Micropost < ApplicationRecord
...
scope :search_by_keyword, -> (keyword) {
where("microposts.content LIKE :keyword
or microposts.title LIKE :keyword
or microposts.genre LIKE :keyword
or microposts.genre2 LIKE :keyword
or microposts.genre3 LIKE :keyword", keyword: "%#{sanitize_sql_like(keyword)}%") if keyword.present?
}
end
ここでは Micropost テーブルから検索ワードがコンテンツ・タイトル・ジャンルに部分一致する要素を探しています。
sanitize_sql_like
は\
, _
, %
をエスケープしてくれる Rails の便利メソッドです。
これにて簡素ではありますが、検索機能が実装できました。
参考:RailsTutorial 検索機能拡張を実装してみた
3. お気に入り(Ajax使用)
まず始めに Progate Rails 編のいいね機能を参考に、Ajax無しのお気に入り機能を実装してみます。
class CreateLikes < ActiveRecord::Migration[5.1]
def change
create_table :likes do |t|
t.references :user, foreign_key: true
t.references :micropost, foreign_key: true
t.timestamps
end
end
end
それぞれの要素にリファレンスをつけて、likeテーブルを作成しました。
このリファレンスをつけることで、各カラムに index 機能が付与されたり、micropost.likes.count
や user.likes.count
でお気に入りカウントなどができるようになります。
参考:Railsの外部キー制約とreference型について
class LikesController < ApplicationController
before_action :logged_in_user
def create
@micropost = Micropost.find(params[:micropost_id])
if Like.find_by(user_id: current_user.id,
micropost_id: params[:micropost_id]) == nil #重複チェック
@like = Like.new(user_id: current_user.id,
micropost_id: params[:micropost_id])
@like.save
redirect_to request.referrer || root_url
end
end
def destroy
@micropost = Micropost.find(params[:micropost_id])
@like = Like.find_by(user_id: current_user.id,
micropost_id: params[:micropost_id])
@like.destroy
redirect_to request.referrer || root_url
end
end
...
<% if @current_user != nil %>
<% if Like.find_by("micropost_id = #{micropost.id} AND user_id = #{@current_user.id}") %>
<%= link_to("/likes/#{micropost.id}/destroy", method: :post) do %>
<i class="fas fa-star fa-fw star"></i>
<% end %>
<% else %>
<%= link_to("/likes/#{micropost.id}/create", method: :post) do %>
<i class="fas fa-star fa-fw unstar"></i>
<% end %>
<% end %>
<%= micropost.likes.count %>
<% else %>
<span class="fas fa-star fa-fw unstar"></span>
<%= micropost.likes.count %>
<% end %>
...
このような感じで、簡単なお気に入り機能を実装しました。
ビューファイルでは未ログインのユーザーがトップページを閲覧するときのことを考慮して、@current_user
の有無で条件分岐をしています。
当初はこれで完成にする予定だったのですが、お気に入りする度にリダイレクトが挟まるのが予想以上に不便だったので Ajax 機能を利用することにしました。
まずは Ajax で書き換えたいビューの要素を id で指定します。ここはかなり苦戦したのですが、お気に入り機能のコードをパーシャル化して id を割り振ることで何とか形になりました。
...
<span id="like-of-<%= micropost.id %>">
<%= render partial: 'microposts/like', locals: {micropost: micropost } %>
</span>
...
そして各リンクに remote: true
を追記、各アクションにrespond_to
メソッドを用意して Ajax に対応させました。
class LikesController < ApplicationController
before_action :logged_in_user
def create
@micropost = Micropost.find(params[:micropost_id])
if Like.find_by(user_id: current_user.id,
micropost_id: params[:micropost_id]) == nil #重複チェック
@like = Like.new(user_id: current_user.id,
micropost_id: params[:micropost_id])
@like.save
respond_to do |format|
format.html { redirect_to request.referrer || root_url }
format.js
end
end
end
...
最後にアクションと同じ名前を持つJavaScript用の埋め込みRuby (.js.erb) ファイルを作成し、完成です。
(destroyアクションも全く同じコードで大丈夫です)
$("#like-of-<%= @micropost.id %>").html("<%= escape_javascript(render partial: 'microposts/like',
locals: { micropost: @micropost}) %>");
これでお気に入りが完了するまでの流れは以下のようになります。
- お気に入りを押すとリクエストが Ajax で送信される
- createアクションでデータが更新される。
- お気に入り部分のパーシャルが
@micropost
データを受け取り、リダイレクト無しに再描画される。
参考:Railsチュートリアル:14.2.5 [Follow] ボタン (Ajax編)
4. 通知
ツイッターの通知機能みたいなものを実装します。
まずは Notification テーブルを作ります。
class CreateNotifications < ActiveRecord::Migration[5.1]
def change
create_table :notifications do |t|
t.integer :user_id
t.integer :notified_by_id
t.integer :micropost_id
t.integer :like_id
t.integer :relationship_id # フォロー時の通知用
t.boolean :read # 既読/未読を判断
t.string :notified_type # "お気に入り" or "フォロー"
t.timestamps
end
add_index :notifications, :user_id
add_index :notifications, :notified_by_id
add_index :notifications, :like_id
add_index :notifications, :relationship_id
end
end
モデルのリレーションは以下の通りです。まとめて書いてます。
class User < ApplicationRecord
has_many :notifications, dependent: :destroy
class Micropost < ApplicationRecord
has_many :notifications, dependent: :destroy
class Relationship < ApplicationRecord
has_many :notifications, dependent: :destroy
class Like < ApplicationRecord
has_many :notifications, dependent: :destroy
class Notification < ApplicationRecord
belongs_to :user
belongs_to :notified_by, class_name: 'User'
belongs_to :relationship, optional: true
belongs_to :like, optional: true
belongs_to :micropost, optional: true
ここで注意しないといけないのは、下の3つの要素には optional: true
を記述する必要があるということです。
例えばフォローの通知をテーブルに保存するとき like_id
カラムと micropost_id
カラムは空になりますが、デフォルトの設定では belongs_to 関連の要素が全て揃っていないとデータがテーブルに保存されません。
参考:Rails5からbelongs_to関連はデフォルトでrequired: trueになる
各コントローラを書き換えて、お気に入り・フォロー時に通知を保存するようにします。
class LikesController < ApplicationController
before_action :logged_in_user
after_action :create_notifications, only: [:create]
...
private
def create_notifications
return if @micropost.user_id == current_user.id
Notification.create(user_id: @micropost.user_id,
notified_by_id: current_user.id,
micropost_id: @micropost.id,
notified_type: "お気に入り",
read: false,
like_id: @like.id)
end
end
ユーザーが自分の投稿にお気に入りをした時はリジェクトするようにしています。フォロー時も同じ要領で書きました。
通知の未読/既読の書き換えについてはこのような感じで実装しました。
- 通知ページへのリンクを post で送る
- create アクションで未読を既読に書き換え
- 通知ページにリダイレクト
...
def create
Notification.where("user_id = #{current_user.id}").update_all "read = 'true'"
redirect_to "/notification"
end
def notification
@notifications = Notification.where("user_id = '#{current_user.id}'")
.paginate(page: params[:page], per_page: 20)
end
...
未読/既読書き換えの際には、 update_all
のメソッドが大変便利でした。
Active record の基礎的な要素を学ぶにはこのページが参考になりますのでおすすめです。
Ruby on Rails ガイド : Active Record の基礎
最後に、新着通知が来たときにヘッダーに数字が表示されるようにします。
...
def notification_count(user)
count = Notification.where("user_id = ? AND read = ?", user.id, false).count
if count == 0
return
else
return "(#{count})"
end
end
...
新たにメソッドを定義して実装しました。これにて完成です。
5. ミュート
ミュート機能を実装します。ミュートした人の投稿はページに表示されなくなり、その人からの通知も表示されません。
まずは新たにモデルを作ります。
class CreateRelationshipMutes < ActiveRecord::Migration[5.1]
def change
create_table :relationship_mutes do |t|
t.integer :follower_id
t.integer :followed_id
t.timestamps
end
add_index :relationship_mutes, :follower_id
add_index :relationship_mutes, :followed_id
add_index :relationship_mutes, [:follower_id, :followed_id], unique: true
end
end
モデルのリレーションやコントローラの内容はほとんど Railsチュートリアル14章 と同じなので省略します。
ただ1つだけ落とし穴があり、relationship_mutes
という名前でモデルを生成するとクラス名が RelationshipMute
になるということには注意せねばなりません。
ミュート中のユーザーの投稿を非表示にさせる部分を実装していきます。
mute_ids = "SELECT followed_id FROM relationship_mutes WHERE follower_id = #{current_user.id}"
まずはテーブルからミュート中のユーザーIDを収集するコードを書きます。
データベース操作の構文についてはこのページを参考に書きました。
DBOnline SQLite入門 取得するデータの条件を設定(WHERE句)
そしてwhere句を用いて @micropost_items
からミュート中のユーザーの投稿を抜いていきます。
@micropost_items = Micropost.where("category = '#{params[:id]}'").where("NOT user_id IN (#{mute_ids})")
.paginate(page: params[:page], per_page: 10)
最終的にindexアクションはこのような感じになりました。
...
def index
unless logged_in?
if params[:q]
@micropost_items = Micropost.search_by_keyword(params[:q])
.paginate(page: params[:page], per_page: 10)
elsif params[:id]
@micropost_items = Micropost.where("category = '#{params[:id]}'")
.paginate(page: params[:page], per_page: 10)
else
@micropost_items = Micropost.all.paginate(page: params[:page], per_page: 10)
end
else
mute_ids = "SELECT followed_id FROM relationship_mutes WHERE follower_id = #{current_user.id}"
if params[:q]
@micropost_items = Micropost.search_by_keyword(params[:q]).where("NOT user_id IN (#{mute_ids})")
.paginate(page: params[:page], per_page: 10)
elsif params[:id]
@micropost_items = Micropost.where("category = '#{params[:id]}'").where("NOT user_id IN (#{mute_ids})")
.paginate(page: params[:page], per_page: 10)
else
@micropost_items = Micropost.where("NOT user_id IN (#{mute_ids})")
.paginate(page: params[:page], per_page: 10)
end
end
end
...
logged_in?
で分岐させているのは、未ログインのユーザーがトップページを閲覧した時を考慮してのことです。これについてはもっと上手い回避方法があった気がします。
通知のミュートについても同じように実装しました。
課題点
これから追加していきたい機能
- 複数ワード検索
- 同じユーザーからの通知をひとまとめにして表示する機能
- 作品の紹介コメントで改行できるようにする
最後に
実際に手を動かしてモノを作るのはやはり楽しいものですが、しばらくは専門書でも読みつつ知識の再整理をしようと思っています。
おすすめ作品共有SNS「susumeru」、よかったら使ってみてください。
https://susumeru.herokuapp.com/