Railsのcontrollerとmodelのみを使って記事閲覧履歴を作成する

# はじめに

ポートフォリオ作成の過程で、個人的に試行錯誤したところを備忘録的にメモしています。
自分自身もコーディング歴がそこまで長くないので、初心者目線となりますがご容赦ください。
また、おかしい点がありましたら、ぜひご指摘いただければと思います。

Railsでページの閲覧履歴を記録する手っ取り早い手段としては、cookieを使った記録方法があるようなのですが
今回はcontrollerとmodelのみを使用して作成してみました。

# 環境・gem情報
- Ruby 2.5.3(ローカル環境)/2.3.8(本番環境)
- Rails 5.2.2
- devise 4.6.1

# 目標物
複数の記事があるサイトで、利用者登録を済ませたログイン済ユーザーが
過去に閲覧した記事の履歴をリスト化し、最後に閲覧した記事から数えて10件を表示する。

# もうちょっと具体的に設計へ落とし込む
上の文言だけだとやや抽象的なので、もう少し設計に落とし込んだ上で
それぞれを満たすためには何を作る必要があるのか、簡単にまとめます。
(ルーティングの設定は省略しますが、下記のアクションそれぞれに対してルーティングが必要です。念の為。)

### 機能設計
- **複数の記事があり、それらを閲覧する。**
    - 記事テーブル・モデルの準備
    - 記事の閲覧ビュー・アクションの準備
- **記事の閲覧履歴を記録する。**
    - 閲覧履歴テーブル・モデルの準備
    - 閲覧履歴のレコードを作成するアクションの準備
- **利用者登録を済ませたログイン済みユーザーが、自分の閲覧履歴を見れる。**
    - ユーザーテーブル・モデルの準備
    - ログイン中のユーザー情報に紐づいた閲覧履歴一覧

これらの機能から、必要なコントローラーとモデルを洗い出していきます。

### model & controller(アクション含む)
- models
    - Article(記事)
    - User(ユーザー) ※deviseで作成
    - BrowsingHistory(閲覧履歴)
- controller
    - articles(記事)
        - showアクション（ビュー表示用）

model間のリレーションは下記のような感じです。

<img width="607" alt="スクリーンショット 2019-03-03 0.02.18.png" src="https://qiita-image-store.s3.amazonaws.com/0/304628/7afafe57-d045-aedf-2c2c-8982055635b3.png">

UserとArticleの中間テーブルとして、BrowsingHistoryが存在している形です。
この中間テーブルたるBrowsingHistoryに、ログイン中ユーザーのIDと、閲覧した記事のIDを保存していきます。

# 実装
### model & controllerの作成
deviseのインストールについては割愛しますが、インストールが終わった状態で、下記のコマンドを実行してください。

`$ rails g model Article`
`$ rails g model BrowsingHistory`
`$ rails g devise User`
`$ rails g db:migrate`

モデルが作成されたら、続いてコントローラーを作成します。

`$ rails g controller articles`

この後、ルーティング作成をし、showアクションでviewが表示されるようにします。

```Ruby:config/routes.rb
Rails.application.routes.draw do
  # 前略
  get 'articles/:id' => 'article#show', as: 'article'
  # 後略
end
```

### コーディング
出来るところからコーディングしていきます。まずはmodelのアソシエーションから。

先ほどER図でも示した通り、BrowsingHistoryがUserとArticleの中間テーブルになるようにしたいので
それぞれ下記の通りhas_manyとbelongs_toを追加します。

```Ruby:app/models/user.rb
class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable

  has_many :browsing_histories, dependent: :destroy # ここを追加
end
```

```Ruby:app/models/article.rb
class Article < ApplicationRecord
  has_many :browsing_histories, dependent: :destroy # ここを追加
end
```

```Ruby:app/models/browsing_history.rb
class BrowsingHistory < ApplicationRecord
  belongs_to :user    # ここを追加
  belongs_to :article # ここを追加
end
```

続いて、controllerでshowアクションを設定します。
とりあえず記事が閲覧できればいいので、URLに入力されたIDの記事をインスタンス変数で受け取るようにします。

```Ruby:app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def show
    @article = Article.find(params[:id])
  end
end
```

これで、記事が表示されるようになりました。

# 閲覧履歴の保存
いよいよ閲覧履歴の保存の機能実装となりますが、事前に一つ解決しておく課題があります。
### いつ保存する？
閲覧履歴を保存するタイミング、つまり「ユーザーがその記事を見たと判断するタイミング」を決める必要があります。
例えば、showアクションの一番初めに持ってくると、どうなるでしょうか。

```Ruby:app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def show
    # ここに閲覧履歴を保存するコードを書く？
    @article = Article.find(params[:id])
  end
end
```

この場合、インスタンス変数が宣言される前なので、閲覧履歴に必要な記事のIDを引っ張ってくるのが面倒になりますし
存在しない記事IDが入力された場合に、その存在しないIDがそのまま保存されるリスクもありそうです。

逆に、インスタンス変数を定義した後に閲覧履歴を保存するアクションを書けば、取得済の記事IDを
@article.idという形で使えるので、ぐっと楽になりそうです。
というわけで

```Ruby:app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def show
    @article = Article.find(params[:id])
    # ここから閲覧履歴を保存するコードを書く
  end
end
```

閲覧履歴を保存するタイミングは、**インスタンス変数の宣言後**とします。

### 保存するアクションの作成

```Ruby:app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def show
    @article = Article.find(params[:id])
    # ここから閲覧履歴を保存するコードを書く
    new_history = @article.browsing_histories.new
    new_history.user_id = current_user.id
    new_history.save
  end
end
```

手順としては、
`new_history = @article.browsing_histories.new`
この一文の意味は

1. 記事情報(`@article`)に紐づいた
2. 閲覧履歴レコード(`browsing_histories`)の
3. 空レコードを新規作成(`new`)

という意味になります。

次は、この変数に、ログイン中のユーザーのIDを入れていきます。

`new_history.user_id = current_user.id`
current_userは、deviseのヘルパーメソッドで、ログイン中のユーザーの情報を格納しています。

これで記事ID、ユーザーIDが出揃ったので、saveで保存します。

`new_history.save`

これで閲覧履歴の作成機能は完成です。

# 問題の抽出
機能としてはこれで完成なのですが、このままだといくつか問題があります。

- 同じユーザーが同じページを何回も続けて見た場合、見た回数だけ閲覧履歴に追加される
- 上限がないので、閲覧履歴が際限なく追加される

上記のやり方だと、showアクションを実行するたびに閲覧履歴が保存されるので、
例えばAという記事を表示した後にBという記事を表示、その後にAを再び表示した場合
閲覧履歴にはA→B→Aの順番でレコードが残ることになります。

ユーザーのログを取るという意図があるのであれば、これでも問題は無いと思うのですが、今回はそのあたりの意図は無いものとして、あくまでユーザーが直近の閲覧履歴を見返せるようにする、という目的にフォーカスし、ページの重複をなくすように実装します。

また、二つ目の問題、閲覧履歴が際限なく追加される点についても、上記と同様の理由で、１ユーザーあたりの閲覧履歴レコード数に上限を設けたいと思います。

### 同一ユーザーの閲覧履歴に同一ページがある場合の処理
先ほどのアクションに、下記のようにコードを追加します。

```Ruby:app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def show
    @article = Article.find(params[:id])
    # ここから閲覧履歴を保存するコードを書く
    new_history = @article.browsing_histories.new
    new_history.user_id = current_user.id

    # ここから追加
    if current_user.browsing_histories.exists?(article_id: "#{params[:id]}")
      old_history = current_user.browsing_histories.find_by(article_id: "#{params[:id]}")
      old_history.destroy
    end
    # ここまで

    new_history.save
  end
end
```

まず、`current_user.browsing_histories.exists?(article_id: "#{params[:id]}")`で

1. ログイン中のユーザー(`current_user`)の
2. 閲覧履歴(`browsing_histories`)の中で
3. 記事ID(`article_id`)がURLに入力されているID(params[:id])と同じものが
4. すでに存在しているか？(`exists?`)

ということを調べています。

結果的に、ユーザーIDも記事IDも同じになっている閲覧履歴は存在しているか？ということを調べています。
もし存在した場合は、if文内の処理に移ります。

`old_history = current_user.browsing_histories.find_by(story_id: "#{params[:id]}")`
ここでは、

1. ログイン中のユーザー(`current_user`)の
2. 閲覧履歴(`browsing_histories`)の中で
3. 記事ID(`article_id`)がURLに入力されているID(`params[:id]`)と同じものを
4. すでに存在する古い履歴(`old_history`)として定義する

という処理になっています。

こうして定義した古い履歴を、削除します。

`old_history.destroy`

その後、新しい履歴を追加することで、同じ記事の履歴が重複せずに済む、ということになります。

### 同一ユーザーの閲覧履歴の件数が上限を超えた場合の処理
最後に、同一ユーザーの閲覧履歴の上限に達した場合、古い履歴を順番に消していくようにします。
先ほどのアクションに、下記のようにコードを追加します。

```Ruby:app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def show
    @article = Article.find(params[:id])
    # ここから閲覧履歴を保存するコードを書く
    new_history = @article.browsing_histories.new
    new_history.user_id = current_user.id

    # 同一記事の重複チェック・重複時は古い履歴を削除
    if current_user.browsing_histories.exists?(article_id: "#{params[:id]}")
      old_history = current_user.browsing_histories.find_by(article_id: "#{params[:id]}")
      old_history.destroy
    end

    new_history.save

    # ここから追加
    histories_stock_limit = 10
    histories = current_user.browsing_histories.all
    if histories.count > histories_stock_limit
      histories[0].destroy
    end
    # ここまで
  end
end
```

まず、`histories_stock_limit`で、上限とする記事数を決めます。
ここでは、1ユーザーが見ることができる閲覧履歴は10件までとします。

`histories_stock_limit = 10`

続いて、ログイン中のユーザーの閲覧履歴を、新しい順に並び替えたものを変数`histories`として宣言します。

1. ログイン中のユーザー(`current_user`)の
2. 閲覧履歴(`browsing_histories`)を
3. 全て取得したもの(`all`)を
4. 変数に入れて宣言(`histories`)

という処理の流れになります。

`if histories.count > histories_stock_limit`
この時点でログイン中ユーザーの閲覧履歴を全て取得しているので
上記のようにレコード数(`histories.count`)が上限(`histories_stock_limit`)を上回っていないか確認します。
上回っていた場合、一番古いデータを削除する必要があります。

ここで、`histories`は、閲覧履歴の全データがレコードごとに配列で格納されていますが、
`all`で全レコードを取得した場合、一番古いデータは配列の一番最初に格納されています。
したがって、
`histories[0]`を`destroy`することで、一番古いレコードが削除されます。

# おわりに

いかがだったでしょうか。
そもそも閲覧履歴をデータベースで管理する事例がどれだけ存在するのか、私自身把握できてないのですが
このアプローチが、何かの一助になればと思います。