[Ruby on Rails] ログインしていないユーザーが「1度だけ」いいねを押せる機能の実装

# はじめに

今回ポートフォリオ制作で、
日本各地の名所を投稿できるサイトを制作しました。

実際に製作したサイトと、コード(GitHub)は下記のURLからご覧ください。
・サイトURL : https://japansiteinfo.com (今後予告なく公開停止する場合があります。ご了承ください。)
・GitHubのURL : https://github.com/yuta-pharmacy2359/dwc_JapanSiteInfo_app

その中で、特に「ログインしていないユーザーが「1度だけ」いいねを押せる機能」で苦戦しましたので、
今回ご紹介させていただきたいと思います。

# 本題

### 1.背景

投稿サイトのみならず、Webアプリケーションではもはや欠かせない機能となっている「いいね機能」。
「ログインユーザーがいいねを押す機能」であればネットで沢山の検索結果が出てくると思います。

通常、投稿ID: 1 の投稿にいいねを押した場合、
その情報はDBに以下のように登録されます。


| いいねID | 投稿ID | (いいねを押した)ユーザーID |
|:-:|:-:|:-:|
| 1  |  1 |  2 |
|  2 |  1 | 3  |
|3   | 1 | 5 |
| 4  | 1 | **nil** |

ログインユーザーの場合、いいねID: 1〜3 のように、
「投稿IDとユーザーID」を照合させることで、いいねが押されているかどうかの判定ができます。
(その判定によって「いいねを押すボタン」と「いいねを取り消すボタン」を切り替えるという仕組みです。)

しかし、ログインしていないユーザー(以後「ゲスト」)の場合は、ユーザーIDを持たないため、
いいねID: 4 のようにユーザーIDが「nil」となり、
そのままではそのブラウザでいいねが押されたかどうかの判定ができません。
(そのため、1つの投稿に対して無限回いいねが押せてしまいます。)

ゲストがいいねを押した情報を保存する方法はいくつかあるかと思いますが、
今回は**「cookie」にいいねを押した投稿IDの情報を持たせ、ブラウザ上に保存する**方法を紹介します。


### 2.cookieについて
ステートレスなプロトコルであるHTTPにおいて、状態を保持し管理する仕組みの一つが`cookie`です。

Webサーバーへ接続してきたWebブラウザに対して、
コンテンツとともにWebブラウザに保存してもらいたい情報(今回の場合はいいねを押した情報)がcookieとして送られます。
cookieを受け取ったWebブラウザはそれを保存しておき、次にWebサーバーに接続する際にcookieを送信することで、
サーバーが接続してきたブラウザを認識し、情報を反映させることができます。

以下、上記の説明を図にしたものです。パワポで作成したものですが、
是非参照いただければと思います。
<img width="680" alt="スクリーンショット 2021-06-03 17 05 21" src="https://user-images.githubusercontent.com/75191225/120611254-f59c6680-c48e-11eb-90e0-323c79a9c94f.png">
使用画像サイト: フレームぽけっと(https://www.illust-pocket.com/illust/625)
エコのモト(https://economoto.org/illust/2929/)
イラストAC(https://www.ac-illust.com/main/search_result.php?word=%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%82%B5%E3%83%BC%E3%83%90)

参考文献: 「この一冊で全部わかる　 Web技術の基本(SB Creative出版)」

### 3.開発環境・前提条件など

◎開発環境
・Ruby: 2.6.3
・Rails: 5.2.3

◎前提条件
・「Userモデル」
・「Spotモデル(投稿のモデルです)」
・「Favoriteモデル(ログインユーザーの部分)」
以上3つのモデルについて実装済みである想定で話を進めたいと思います。
(ゲストとの比較の関係上、ログインユーザー向けのコードも記載していますが、
詳細な説明は省略させていただいております。)

### 4.実装

#### favoriteモデル
```ruby:app/models/favorite.rb 
class Favorite < ApplicationRecord
  belongs_to :user, optional: true
  belongs_to :spot
end
```
#### 解説

1.の項で「ログインしていないユーザーがいいねを押すとuser_id: nilのレコードが挿入される」
と述べました。さらにこの後favoritesコントローラーにuser_idがnilのレコードを挿入する文を記述しますが、
その場合は上記のようにuserモデルの`belongs_to`の記述のところで
`optional: true`(外部キーとして挿入される値としてnilも許容する)を加える必要があります。

#### favoritesコントローラー
```ruby:app/controllers/favorites_controller.rb 
class FavoritesController < ApplicationController
  def create
    @spot = Spot.find(params[:spot_id])
    if current_user.nil?
　　　　# ログインしていない場合(ゲスト)
      if cookies[:favorite_spot_id].nil?
        #cookieがブラウザに保存されていない場合
        cookies.permanent[:favorite_spot_id] = @spot.id.to_s
      else
        #cookieがブラウザに保存されている場合
        cookies.permanent[:favorite_spot_id] = cookies.permanent[:favorite_spot_id] + "," + @spot.id.to_s 
      end
      Favorite.create(user_id: nil, spot_id: @spot.id)    # 「user_idがnil」のレコードを追加
      @favorites_count = @spot.favorites.count    #いいね数の表示
      @cookies = cookies[:favorite_spot_id]     #インスタンス変数化
    else
      # ログインしている場合(詳しい説明は省略)
      @favorite = current_user.favorites.new(spot_id: @spot.id)
      @favorite.save
      @favorites_count = @spot.favorites.count
    end
    # (省略)
  end

  def destroy
    @spot = Spot.find(params[:spot_id])
    if current_user.nil?
      # (今回、ゲストのいいね取消し機能は実装しませんでした。)
    else
      # ログインしている場合(説明は省略)
      @favorite = current_user.favorites.find_by(spot_id: @spot.id)
      @favorite.destroy
    end
    # (省略)
  end
end

```

#### 解説
◎createアクション
ログインユーザーかゲストかで分岐させた後、cookieが該当のブラウザに保存されているかどうかで分岐しています。

◯cookieが保存されていない(当該ブラウザでどの投稿にもいいねが押されていない)場合
永続化(permanent、とはいっても実際は20年間ですが)cookieに対象のspot_idを**文字列化して**代入します。
 (数値のまま代入してしまうと上手くいきません。)

例えば、spot_id: 5 の投稿にいいねが押された場合は、

```
cookies.permanent[:favorite_spot_id] = "5"
```
となります。

◯cookieが保存されている(当該ブラウザで既にいずれかの投稿にいいねが押されている)場合
既に保存されているcookieに対し、新たなspot_idを**カンマをつけて付け足します**。

例えば、先程のspot_id: 5 の投稿に続きspot_id: 7 および10の投稿にいいねが押された場合は、

```
cookies.permanent[:favorite_spot_id] = "5,7,10"
```
上記の通り、いいねを押されたspot_idがあたかも配列のごとく並ぶのがわかると思います。
そして、このあと解説するviewファイルのところでこの**「カンマをつけて配列のごとく並べる」**ことが重要となります。

これでブラウザにいいねを押した情報がcookieとして保存されますが、
これだけでは当然DBにその情報が保存されないため、以下のコードで同時に「user_idがnil」のレコードが追加されるようにします。

```
Favorite.create(user_id: nil, spot_id: @spot.id)
```


◎destroyアクション

こちらもログインユーザー同様、ゲストも「再度ボタンを押せばいいねが取り消される」という仕様にしたかったのですが、
そのためには、
**「保存したcookieの中に当該のspot_idがある場合はそのidをカンマとともに削除し、
DBにおいても当該のspot_idかつuser_idがnilであるレコードを1つ削除する」**
という内容を実装する必要があり、非常に難しくなることが予想されたため、
誠に勝手ながら今回は一旦保留とさせていただきました。
(もちろん、機会があればチャレンジしてみたいと思いますが)

なお、上記の機能を実装するしないにかかわらず、
ゲストユーザーの場合はelse以下の文が成立しない(「current_user? なにそれ美味しいの?」状態)ので、
ログインしているかどうかの分岐は必要となります。


#### viewファイル

※各viewファイル(spotやuserの詳細、一覧画面など)における部分テンプレートとしているため、インスタンス変数の`@`は省略しています。
(今回の場合は@spot, @cookieです。)

```html:app/views/favorites/_list_favorite.html.erb
<% if current_user.present? %> 
  # ログインユーザー
  <% if spot.favorited_by?(current_user) %>
    # 当該のspot_idの投稿にいいねを押している場合
    <%= link_to spot_favorites_path(spot), method: :delete, remote: true do %>
      <span class="fas fa-heart unlike" id="unlike-<%= spot.id %>"></span>
    <% end %>
  <% else %>
    # 当該のspot_idの投稿にいいねを押していない場合
    <%= link_to spot_favorites_path(spot), method: :post, remote: true do %>
      <span class="far fa-heart like" id="like-<%= spot.id %>"></span>
    <% end %>
  <% end %>
<% else %>
  # ゲスト
  <% if cookies.nil? %>
    # cookieが保存されていない(対象のブラウザでどの投稿にもいいねが押されていない)場合
    <%= link_to spot_favorites_path(spot), method: :post, remote: true do %>
      <span class="far fa-heart like" id="like-<%= spot.id %>"></span>
    <% end %>
  <% else %>
    # cookieが保存されている(対象のブラウザでいずれかの投稿にいいねが押されている)場合
    <% arr = cookies.split(",").map(&:to_i) %>
    <% if arr.include?(spot.id) %>
      # 保存されたcookieの中に当該のspot_idが含まれている(いいねが押されている)場合
      <span class="fas fa-heart unlike" id="unlike-<%= spot.id %>"></span>
    <% else %>
      # 保存されたcookieの中に当該のspot_idが含まれていない(いいねが押されていない)場合
      <%= link_to spot_favorites_path(spot), method: :post, remote: true do %>
        <span class="far fa-heart like" id="like-<%= spot.id %>"></span>
      <% end %>
    <% end %>
  <% end %>
<% end %>
```

#### 解説

まずログインユーザーとゲストで条件分岐するところはコントローラーと一緒です。

そして、ゲストの中でさらに「cookieがブラウザに保存されているか」で分岐します。

この後の、`if arr.include?(spot.id)`の行で
「保存されたcookieの中に当該のspot_idが含まれているか(つまり、いいねが押されているか)」
によりさらに分岐することになりますが、cookieが保存されていない場合、
この「保存されたcookie」という前提条件が成立せずエラーとなってしまいます。
そのため、まず「cookieがブラウザに保存されているか」で分岐させることが必要となります。

まず、「cookieがブラウザに保存されていない」場合ですが、
これは「どの投稿にもいいねが押されていない状態」と同じですので、
「いいねを押す」ボタン(灰色)を表示します。

次に、「cookieがブラウザに保存されている」場合ですが、上記の通り、
「保存されたcookieの中に当該のspot_idが含まれているか」でさらに分岐させます。


```
<% arr = cookies.split(",").map(&:to_i) %>
```
続いて、上記の行に関して一つずつ説明します。

まず`split`は「引数で指定した区切り文字(今回の場合は",")で文字列を分割し、配列として返す」メソッドです。
そのため、先程のfavoriteコントローラーの項で挙げた例で説明すると、

```
@cookies = cookies.permanent[:favorite_spot_id]

@cookies = "5,7,10"
         ↓
@cookies.split(",") = ["5", "7", "10"]
```
となるわけです。

続いて`map`は「配列の要素の数だけ繰り返し処理を行う」メソッドです。
そして、`map(&:to_i)`に関してですが、詳しい説明に関しては以下の参考文献をご覧いただきたく思います。
https://qiita.com/snyt45/items/7beb719ab0c4a25aa585

とりあえず今回は「下の行のif文で当該spot_id(数値)が保存したcookie中に含まれているかどうかを調べるために、
文字列の配列を数値の配列に変換した」理解していただければOKです。

```
@cookies = cookies.permanent[:favorite_spot_id]

@cookies.split(",") = ["5", "7", "10"]
                    ↓
@cookies.split(",").map(&:to_i) = [5, 7, 10]
```
こうして次の`if arr.include?(spot.id)`の文で、
得られたspot_idの配列の中に当該spot_idが含まれているかどうかを
判断し、表示するいいねボタンの種類を分けているというわけです。

# 最後に

いかがだったでしょうか。

初めての投稿のため、一部読みづらい部分もあったかと思いますが、
参考にしていただけますと幸いです。


また、ゲストのいいね削除機能を含め、その他の機能についても気になるところを見つけ次第記事にしたいと思います。