##実装したいこと
-
掲示板にブックマーク機能を追加したい。
-
ユーザーがブックマークした掲示板を一覧できるページを実装したい。
これだけの機能なのに、めちゃくちゃ難しい。いろんなことを調べるいい機会になりました。
かなり多くのものを調べたので順序立てて説明をしていきます。
###実装の大まかな流れ
- 中間テーブルとなるBookmarkモデルの実装
- UserモデルとBoardモデルとのアソシエーションを実装
- Userモデルにお気に入り登録のギミックを定義
- BookmarkControllerの実装
- Routingの設定
- Viewの実装
##Bookmarkモデルの仕組み(多対多)
で、いざお気に入り機能をつけよう!となってもどうやって実装するの・・・?となります。なので、まずどうUserモデルとBoardモデルに紐づけていくのか考えて見ましょう。
###UserとBookmarkとBoardの関係
ユーザーと掲示板とブックマークの関係を見ていきましょう。
ユーザーはたくさんの掲示板をブックマークすることができます。反対に、掲示板はたくさんのユーザーにフォローされることができます。
つまり、UserもBoardもBookmarkをたくさん持っているということになります。このような関係を多対多の関係と言われています。
###中間モデル
多対多のモデルを実装するにはお互いのforeign_keyを知っている必要があります。そのために、お互いのidを格納するテーブル、中間テーブルを実装する必要があります。
##Bookmarkモデルの作成
中間テーブルとなるBookmarkモデルを作成していきます。
ターミナル
rails g model Bookmark user:references board:references
migrateファイル
class CreateBookmarks < ActiveRecord::Migration[6.0]
def change
create_table :bookmarks do |t|
t.references :user, null: false, foreign_key: true
t.references :board, null: false, foreign_key: true
t.timestamps
end
add_index :bookmarks, [:user_id, :board_id], unique: :true
end
end
ユーザーが同じ掲示板をお気に入り登録しないようにunique: :true
をつける必要があります。
add_index :bookmarks, [:user_id, :board_id], unique: :true
でuser_idとboard_idの組み合わせがuniqueであることを設定します。
これでrails db:migrate
を行います。
bookmark.rb
class Bookmark < ApplicationRecord
belongs_to :user
belongs_to :board
validates :user_id, uniqueness: { scope: :board_id}
end
migrationにもunique: :true
を付けたので、モデルにもバリデーションを記載します。
#####uniquenessとscopeについて
validates :user_id, uniqueness: { scope: :board_id} end
上記は各掲示板idと同じユーザーidがお気に入り関係にならないように一意性制約を付けています。
rails cで確認して見ます。
irb(main):001:0> user = User.first
irb(main):002:0> board = Board.first
# userが掲示板をお気に入り登録する。
irb(main):003:0> user.bookmark(board)
(0.1ms) begin transaction
Bookmark Exists? (0.9ms)
Bookmark Create (3.2ms)
(0.7ms) commit transaction
=> #<ActiveRecord::Associations::CollectionProxy [#<Board id: 1, title: "hogehoge", body: "3333333", user_id: 1, created_at: "2020-11-19 10:27:20", updated_at: "2020-11-19 10:27:20", board_image: "8965-1-20151228165149_b5680ea1512891.jpg">]>
# もう一度同じユーザーで掲示板のお気にいり登録を試みる。
irb(main):004:0> user.bookmark(board)
# すでにお気に入り登録されているので、バリデーションに引っ掛かりrollbackされる。
(0.1ms) begin transaction
Bookmark Exists? (0.2ms)
(0.1ms) rollback transaction
Traceback (most recent call last):
2: from (irb):4
1: from app/models/user.rb:27:in `bookmark'
ActiveRecord::RecordInvalid (バリデーションに失敗しました: Userはすでに存在します)
# 違うユーザーを指定
irb(main):005:0> user_2 = User.second
# 違うユーザーで掲示板をお気に入り登録を試みると成功する。
irb(main):006:0> user_2.bookmark(board)
(0.1ms) begin transaction
Bookmark Exists? (0.2ms)
Bookmark Create (0.8ms)
(1.4ms) commit transaction
=> #<ActiveRecord::Associations::CollectionProxy [#<Board id: 1, title: "hogehoge", body: "3333333", user_id: 1, created_at: "2020-11-19 10:27:20", updated_at: "2020-11-19 10:27:20", board_image: "8965-1-20151228165149_b5680ea1512891.jpg">]>
bookmarkメソッドはUserモデルに定義しています。また後で紹介しますがconsoleで出てくるので載せておきます。
user.rb
# お気に入りにしている掲示板を取得する
has_many :bookmarks_boards, through: :bookmarks, source: :board
# お気に入り追加
# <<で引数で渡した掲示板の情報がbookmark_boardsに入っている
def bookmark(board)
bookmarks_boards << board
end
#####参考記事
[https://qiita.com/j-sunaga/items/d7f0e944baad6e56206c:title]
[https://railsguides.jp/active_record_validations.html#uniqueness:title]
[https://qiita.com/kazukimatsumoto/items/14bdff681ec5ddac26d1#%E3%81%8A%E6%B0%97%E3%81%AB%E5%85%A5%E3%82%8A%E6%A9%9F%E8%83%BD%E3%82%92er%E5%9B%B3%E3%82%92%E4%BD%BF%E3%81%A3%E3%81%A6%E8%A8%AD%E8%A8%88%E3%81%97%E3%82%88%E3%81%86:title]
##UserモデルとBoardモデルのアソシエーションの設定
Bookmarkモデルの実装が終わったので、他のモデルにもアソシエーションなどの設定を行っていきます。
###Boardモデル
board.rb
class Board < ApplicationRecord
belongs_to :user
has_many :comments, dependent: :destroy
has_many :bookmarks, dependent: :destroy # 追記
mount_uploader :board_image, BoardImageUploader
validates :title, length: { maximum: 255 }, presence: true
validates :body, length: { maximum: 65535 }, presence: true
end
掲示板はたくさんのBookmarkを持つことができるのでhas_many
を使います。
###Userモデル
user.rb
has_many :bookmarks, dependent: :destroy
# お気に入りにしている掲示板を取得する
has_many :bookmarks_boards, through: :bookmarks, source: :board
UserもたくさんのBookmarkを持つことができるのでhas_many
を使っていきます。
ちょっと待って、has_many :bookmarks_boards
って何?through
とかsource
も使っているけどよくわからない・・・。
というわけでこれからhas_many through
について調べたことをまとめていきます。
#####has_many throuth
まずhas_many through
はUserモデルでどういう働きをしているのかというと、ユーザーがお気に入りしている掲示板を取得することができるようになります。
Twitterでいいね一覧が表示できる機能がありますよね。それと同じようにお気に入り登録した掲示板を一覧で表示できるページを作るために必要となってきます。
has_many through
を使わずにユーザーのお気に入りした掲示板を取得したいとなると、このようなコードになります。
# user.bookmarksでuserがお気に入り登録した掲示板のidが入っているレコードの集合を取得することができる。
irb(main):014:0> user.bookmarks
=> #<ActiveRecord::Associations::CollectionProxy [#<Bookmark id: 7, user_id: 1, board_id: 1, created_at: "2020-11-20 00:50:02", updated_at: "2020-11-20 00:50:02">, #<Bookmark id: 10, user_id: 1, board_id: 3, created_at: "2020-11-20 01:57:05", updated_at: "2020-11-20 01:57:05">, #<Bookmark id: nil, user_id: 1, board_id: 1, created_at: nil, updated_at: nil>]>
# userが最初にお気に入り登録した掲示板のレコードを取得
irb(main):005:0> user.bookmarks.first
Bookmark Load (0.5ms)
=> #<Bookmark id: 7, user_id: 1, board_id: 1, created_at: "2020-11-20 00:50:02", updated_at: "2020-11-20 00:50:02">
# 上記で取得したレコードにboardメソッドを実行するとお気に入り登録した掲示板の内容が取得できる!
irb(main):006:0> user.bookmarks.first.board
Bookmark Load (0.1ms)
Board Load (0.2ms)
=> #<Board id: 1, title: "hogehoge", body: "3333333", user_id: 1, created_at: "2020-11-19 10:27:20", updated_at: "2020-11-19 10:27:20", board_image: "8965-1-20151228165149_b5680ea1512891.jpg">
# つまり、user.bookmarksのひとつひとつのレコードにboardメソッドを実行すればユーザーがお気に入りにした掲示板の内容の集合を取得することができる!
# なのでmapメソッドを使ってユーザーがお気に入り登録した掲示板のレコードにboardメソッドを実行し、それを配列に組み込んでいく。
irb(main):007:0> user.bookmarks.map{|bookmark| bookmark.board}
Bookmark Load (0.4ms)
Board Load (0.2ms)
Board Load (0.1ms)
=> [#<Board id: 1, title: "hogehoge", body: "3333333", user_id: 1, created_at: "2020-11-19 10:27:20", updated_at: "2020-11-19 10:27:20", board_image: "8965-1-20151228165149_b5680ea1512891.jpg">, #<Board id: 3, title: "aaaaaaaaaa", body: "aaaaaaaaaaa", user_id: 2, created_at: "2020-11-20 01:56:08", updated_at: "2020-11-20 01:56:08", board_image: nil>]
# 上記の式を(&:)を使って書き換えます。
irb(main):008:0> user.bookmarks.map(&:board)
=> [#<Board id: 1, title: "hogehoge", body: "3333333", user_id: 1, created_at: "2020-11-19 10:27:20", updated_at: "2020-11-19 10:27:20", board_image: "8965-1-20151228165149_b5680ea1512891.jpg">, #<Board id: 3, title: "aaaaaaaaaa", body: "aaaaaaaaaaa", user_id: 2, created_at: "2020-11-20 01:56:08", updated_at: "2020-11-20 01:56:08", board_image: nil>]
つまり、user.bookmarks.map(&:board)
を使えばユーザーのお気に入り登録している掲示板の情報の集合を取得できるというわけです!
このコードをcontrollerなどに書いて実装するのも一つの方法だと思いますが、あまり直接的ではないのと、このコードをビューに落とし込むのも大変です。
そこでhas_many through
の登場です。
これを使えばuser.bookmarks.map(&:board)
をモデル内に簡単に実装できちゃいます。
has_many :bookmarks_boards, through: :bookmarks, source: :board
:bookmarks_boards
と定義することでメソッド化して使うことができます。
user.bookmarks.map(&:board)
このコードを見ながら解説していくと
Userのインスタンスにbookmarksメソッド(through:
で定義)を実行し、それで得られたBookmarksのインスタンスデータのひとつひとつの要素に対してboardメソッド(source:
で定義)を実行する
ということです。
なので、多対多のモデルを作った時に必ずと言っていいほど活躍するというわけです!
#####参考記事
実はRailsチュートリアルの第14章の動画を見るとすごくわかりやすいです。
[https://railstutorial.jp/:title]
Railsガイドの文献
[https://railsguides.jp/association_basics.html#has-many-through%E9%96%A2%E9%80%A3%E4%BB%98%E3%81%91:title]
#Bookmarks_Controllerの実装
よし、モデルのアソシエーションも終わったしcontroller作ろう!
ちょっと待ってください。controllerの可読性を上げるためにまずはモデルにお気に入り登録のギミックを定義していきましょう。すると驚くほどにcontrollerの実装が完結になりますよ!
###controllerを作る前にモデルにBookmarkのギミックを定義する。
Userモデルにお気に入り登録のギミックとなるメソッドを定義していきましょう。
user.rb
# お気に入り追加
# <<で引数で渡した掲示板の情報がbookmark_boardsに入っている
def bookmark(board)
bookmarks_boards << board
end
# お気に入りを外す
def unbookmark(board)
bookmarks_boards.delete(board)
end
# お気に入り登録しているか判定するメソッド
def bookmark?(board)
bookmarks_boards.include?(board)
end
早速先ほど定義したbookmarks_boards
が使われていますね。
一つずつメソッドを見ていきます。
####bookmarkメソッド
# お気に入り追加
# <<で引数で渡した掲示板の情報がbookmark_boardsに入っている
def bookmark(board)
bookmarks_boards << board
end
掲示板の情報のレコードが引数boardに格納されbookmarks_boards
に<<
で追加されています。
<<
は指定されたオブジェクトの末尾に破壊的に追加できるメソッドです。
強制的に追加されて保存もされているのでsaveメソッドなどは必要ありません。
<<
メソッドについて詳しくはこちら
[https://docs.ruby-lang.org/ja/latest/method/Array/i/=3c=3c.html:title]
####unbookmarkメソッド
# お気に入りを外す
def unbookmark(board)
bookmarks_boards.delete(board)
end
bookmarks_boards
からboardの引数に入っている掲示板idが入ったレコードを探し出して削除(delete)するメソッド。
####bookmark?メソッド
# お気に入り登録しているか判定するメソッド
def bookmark?(board)
bookmarks_boards.include?(board)
end
bookmarks_boards
にboardの引数に入っている掲示板idが含まれているレコードがあるかどうか判定するメソッド。
def bookmark?(board)
Bookmark.where(user_id: id, board_id: board.id).exist?
end
このように書くこともできますが、include?
の方が直感的でわかりやすいです。
###bookmarks_controllerの実装
ここまできたら、bookmarks_controllerを作っていきましょう。
viewも必要ないのでcontrollerファイルだけ作って記載します。
bookmarks_controller.rb
class BookmarksController < ApplicationController
def create
board = Board.find(params[:board_id])
current_user.bookmark(board)
redirect_back fallback_location: root_path, success: 'ブックマークしました'
end
def destroy
board = current_user.bookmarks.find_by(params[:id]).board
current_user.unbookmark(board)
redirect_back fallback_location: root_path, success: 'ブックマークを外しました'
end
end
お気に入り登録のギミックをモデルに定義したことによって、かなり直感的なcontrollerになりました!
redirect_back fallback_location : root_path
redirect_back
はユーザーが直前にリクエストを送ったページに戻すことができます。
fallback_location
は直前にリクエストを送ったページがない場合のデフォルトのリダイレクト先を指定しています。
##Routingの設定
controllerも書けたので、次はRoutingの設定を行っていきます。
routes.rb
Rails.application.routes.draw do
root 'static_pages#top'
resources :users
get 'login', to: 'user_sessions#new'
post 'login', to: 'user_sessions#create'
delete 'logout', to: 'user_sessions#destroy'
resources :boards, shallow: true do
resources :comments, only: %i[create destroy]
resource :bookmarks, only: [:create, :destroy]
collection do
get :bookmarks
end
end
end
BoardとBookmarkは親子の関係なのでbookmarks_controllerのRoutingはboards_controllerにネストするように記載しています。
####collectionルーティング
railsの基本的なアクションはresourcesで定義した時に作られる7つのアクションですが、更に別のアクションを追加したい時があります。
その時に使えるのがcollectionルーティングです。boards_controllerに新しくbookmarksというアクションを追加することができます。
ちなみに、controllerのメンバーに対してアクションを追加する場合(idが伴う場合)はmemberルーティングを使います。
collection以外を抜いたルーティングがこちら。
resources :boards, shallow: true do
collection do
get :bookmarks
end
end
bookmarks_boards GET /boards/bookmarks(.:format) boards#bookmarks
上記でこのようなルーティングが出来上がります。
このルーティングとアクションはお気に入りされた掲示板一覧を表示するページとして使っていきます。
####Boards_controllerにbookmarksアクションを追記
ルーティングが書けましたので、Boards_controllerにbookmarksアクションを追加していきます。
boards_controller.rb
def bookmarks
@bookmark_boards = current_user.bookmarks_boards.includes(:user).order(created_at: :desc)
end
無駄にSQL文を発行させない様にincludes(:user)
を記載して関連するuserの情報も取得しています。(n + 1問題の解消)
irb(main):001:0> user = User.first
irb(main):002:0> user.bookmarks_boards
Board Load (2.2ms) SELECT "boards".* FROM "boards" INNER JOIN "bookmarks" ON "boards"."id" = "bookmarks"."board_id" WHERE "bookmarks"."user_id" = ? LIMIT ? [["user_id", 1], ["LIMIT", 11]]
=> #<ActiveRecord::Associations::CollectionProxy [#<Board id: 1, title: "hogehoge", body: "3333333", user_id: 1, created_at: "2020-11-19 10:27:20", updated_at: "2020-11-19 10:27:20", board_image: "8965-1-20151228165149_b5680ea1512891.jpg">, #<Board id: 3, title: "aaaaaaaaaa", body: "aaaaaaaaaaa", user_id: 2, created_at: "2020-11-20 01:56:08", updated_at: "2020-11-20 01:56:08", board_image: nil>]>
irb(main):003:0> user.bookmarks_boards.includes(:user)
Board Load (0.3ms) SELECT "boards".* FROM "boards" INNER JOIN "bookmarks" ON "boards"."id" = "bookmarks"."board_id" WHERE "bookmarks"."user_id" = ? LIMIT ? [["user_id", 1], ["LIMIT", 11]]
User Load (0.5ms) SELECT "users".* FROM "users" WHERE "users"."id" IN (?, ?) [["id", 1], ["id", 2]]
=> #<ActiveRecord::AssociationRelation [#<Board id: 1, title: "hogehoge", body: "3333333", user_id: 1, created_at: "2020-11-19 10:27:20", updated_at: "2020-11-19 10:27:20", board_image: "8965-1-20151228165149_b5680ea1512891.jpg">, #<Board id: 3, title: "aaaaaaaaaa", body: "aaaaaaaaaaa", user_id: 2, created_at: "2020-11-20 01:56:08", updated_at: "2020-11-20 01:56:08", board_image: nil>]>
##Viewの実装
ここまで仕組みを実装したら、あとはViewを作るだけです!
お気に入りボタンとお気に入りした掲示板一覧ページを作成していきます。
###お気に入りボタンの作成
まずはパーシャルでブックマークするボタンとブックマーク解除ボタンを切り替えるページを作ります。
bookmarks/_bookmark_area.html.erb
<% if current_user.bookmark?(board) %>
<%= render 'bookmarks/unbookmark', board: board %>
<% else %>
<%= render 'bookmarks/bookmark', board: board %>
<% end %>
user.rbで定義されたbookmark?
メソッドがここで使われます。
掲示板がブックマークされていたら掲示板解除ボタン、掲示板がブックマークされていなかったらブックマーク登録ボタンに切り替わる仕組みです。
次に、お気に入り登録ボタンを実装していきます。
bookmarks/_bookmark.html.erb
<%= link_to board_bookmarks_path(board_id: board.id), id: "js-bookmark-button-for-board-#{board.id}", method: :post do %>
<%= icon 'far', 'star' %>
<% end%>
次に、お気に入り解除ボタンを実装していきます。
bookamrks/_unbookmarks.html.erb
<%= link_to board_bookmarks_path(current_user.bookmarks.find_by(board_id: board.id)), id: "js-bookmark-button-for-board-#{board.id}", method: :delete do %>
<%= icon 'fas', 'star' %>
<% end %>
current_user
に紐づいているbookmarkインスタンスの中から掲示板idが含まれているものを探し、取得しています。
これで、お気に入りボタンが完成しました。これを掲示板のパーシャルに組み込んでいきます。
boards/_board.html.erb
<% if current_user.own?(board) %>
<div class='mr10 float-right'>
<%= render 'crud_menus', board: board %>
<% else %>
<%= render 'bookmarks/bookmark_area', board: board %>
</div>
<% end %>
掲示板がログインしているユーザーのものだったらcrud_menusボタンが表示され、ユーザーのものではなかった場合はお気に入りボタンに切り替わる様になっています。
###お気に入り掲示板一覧機能ページの作成
最後に、お気に入りした掲示板一覧ページを作成していきます。
掲示板一覧ページとレイアウトはあまり変わらないため、ほとんど掲示板一覧ページから引っ張ってきています。
boards/bookmarks.html.erb
<% content_for(:title, t('.title')) %>
<div class="container pt-3">
<div class="row">
<div class="col-lg-10 offset-lg-1">
<!-- 検索フォーム -->
<form>
<div class="input-group mb-3"><input class="form-control" placeholder="検索ワード" type="search"/>
<div class="input-group-append"><input type="submit" value="検索" class="btn btn-primary"/></div>
</div>
</form>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="row">
<% if @bookmark_boards.present? %>
<%= render partial: "board", collection: @bookmark_boards %>
<% else %>
<p>ブックマーク中の掲示板がありません</p>
<% end%>
</div>
</div>
</div>
</div>
これで完成です!お疲れ様でした!
##おまけ
n + 1問題を解消するためにコードをリファクタリングしていきます。
boards_controller.rb
def index
# userのみキャッシュしている。
@boards = Board.all.includes(:user).order(created_at: :desc)
#bookmarkも取得できる様になる。
@boards = Board.all.includes([:user, :bookmarks]).order(created_at: :desc)
end
user.rb
# userを起点にしてSQLを走らせてしまっているためレコードを取得する時に毎回SQLが走ってしまう。
def bookmark?(board)
bookmarks_boards.include?(board)
end
# boardを起点にしてSQLが走り、検索をかける。無駄なSQLが走らない。
def bookmark?(board)
bookmarks_boards.pluck(:user_id).include?(id)
end
_unbookmark.html.erb
<%= link_to bookmark_path(board.bookmarks.find { |b| b.user_id == current_user.id }),
id: "js-bookmark-button-for-board-#{board.id}",
class:"float-right",
method: :delete,
remote: true do %>
<%= icon 'fas', 'star' %>
<% end %>
なるべくfindを使う様にして無駄にSQLを走らせない様にする。