【Rails】閲覧履歴の降順表示＋定時削除を実装する

ポートフォリオ作成時に閲覧履歴機能を実装したのですが、色々詰まったところがあったので整理しておきたいと思います。

この記事では、以下をまとめていきます：
1. 閲覧履歴機能の実装（モデルに記述）
2. 閲覧履歴が降順で表示されるように調整（コントローラーとビューに記述）
3. 1ヶ月以上前の閲覧履歴が定時で削除されるようにする（バッチ処理）

## 前提
#### 機能としては・・・
- 複数のユーザー（会員登録・ログイン済）がレシピを投稿でき、自分や他者が投稿したレシピを閲覧できる
- ユーザーがログイン済の場合、閲覧履歴が記録され、マイページから自分の閲覧履歴を確認できる
- 閲覧履歴は降順で表示される（閲覧順：新→古）
- 1ヶ月経った閲覧履歴は定時削除される


### モデル & コントローラー
以上の機能を実装するために、以下の構成で進めていきます。
なお、PFでレシピ投稿サイトを作成したのでPostRecipeということにしていますが、ArticleやBookなど別の投稿内容に置き換えていただければと思います。

- MODEL
    - User（会員）
    - History（閲覧履歴）
    - PostRecipe（レシピ投稿）
- CONTROLLER
    - post_recipes

バッチ処理には、gemのwheneverを使用し```schedule.rb```に記述していきます。
Userモデルの作成及びユーザー認証にはdeviseを使用しています。
上記のモデル・コントローラーは作成済として進めていきます。


# 1. 閲覧履歴機能の実装
では、まず肝心の閲覧履歴機能から作っていきます。
なお実装にあたっては「[Railsのcontrollerとmodelのみを使って記事閲覧履歴を作成する](https://qiita.com/solare_tech/items/3cad7ada6c14a00ee15c)」の記事から、多分に勉強させていただきました。

こちらの記事ではコントローラーに記述されていたのですが、コントローラーがややファットになるのが気になったので、私はモデルの方に記述しています。コードの内容としてはほぼ同じです。ぜひ一度上記の記事を確認してください。

## 1-1. アソシエーション記述
まずは、3つのモデルのアソシエーションから記述していきます。

```ruby:app/models/post_recipe.rb
  belongs_to :user
  has_many :histories, dependent: :destroy
```

```ruby:app/models/history.rb
  belongs_to :user
  belongs_to :post_recipe
```

```ruby:app/models/user.rb
  has_many :post_recipes, dependent: :destroy
  has_many :histories, dependent: :destroy
```

## 1-2. browsing_historyメソッド記述
次に、PostRecipeモデルに閲覧履歴を保存する処理を書いていきます。
今回メソッド名は、閲覧履歴を意味する```browsing_history```にします。

※コードの内容の解説は👆の記事に事細かに説明されているので、ここでは割愛します。

```ruby:app/models/post_recipe.rb
  belongs_to :user
  has_many :histories, dependent: :destroy

  def browsing_history(user)
    new_history = histories.new
    new_history.user_id = user.id
    # 同じ投稿をcurrent_userが閲覧している場合、古い履歴を削除
    if user.histories.exists?(post_recipe_id: id)
      visited_history = user.histories.find_by(post_recipe_id: id)
      visited_history.destroy
    end
    new_history.save
```

## 1-3. post_recipesコントローラーで呼び出し
では```browsing_history```メソッドを、コントローラーで呼び出します。
閲覧履歴が追加されるのは、ユーザーがレシピを閲覧した時なので、post_recipesコントローラーのshowアクションに記述します。

```ruby:app/controllers/post_recipes_controller.rb
  def show
    @post_recipe = PostRecipe.find(params[:id])
    if user_signed_in?
      @post_recipe.browsing_history(current_user)
    end
  end
```

deviseのヘルパーメソッド```user_signed_in?```を使って、ユーザーがログインしている場合は閲覧履歴を保存する処理を実行します。

# 2. 閲覧履歴を降順で表示
閲覧履歴を表示するのはログインユーザーのマイページなので、usersコントローラーのshowアクションとビューに記述を加えます。

## 2-1. usersコントローラーのshowアクションに記述


このコントローラーの記述が詰まったところで…
最初はこのように書いていました👇

なお、```where(is_draft: false)```は下書きでないレシピを表示するために加えています。

```ruby:app/controllers/users_controller.rb
#うまくいかなかった例
  def show
    @browsed_posts = @user.browsed_posts.where(is_draft: false)
  end
```

```ruby:app/models/user.rb
#うまくいかなかった例
  has_many :histories, dependent: :destroy
  has_many :browsed_posts, through: :histories, source: :post_recipe
```

ところがこれだと、当たり前ですが閲覧履歴が降順で表示されません。
orderメソッドで```created_at: "DESC"```を```@browsed_posts```の末尾に追記しても、レシピの投稿日時で降順に並び変わるだけで、閲覧履歴そのものが降順にはならない…。

閲覧履歴が古→新の順で表示されるのはだいぶ違和感があります。

さて、どうしよう…
といろいろ試行錯誤して行き着いたのがこれでした👇

```ruby:app/controllers/users_controller.rb
  def show
    @user = User.find(params[:id])
    @browsed_posts = PostRecipe.joins(:histories).where(is_draft: false,'histories.user_id': @user.id).order('histories.created_at': "DESC")
  end
```

joinsメソッドで、historiesテーブルをpost_recipesテーブルとを内部統合します。
これによって、閲覧履歴そのものの```created_at```が取得でき、これを降順に並び替えることができます。

joinsメソッドによるテーブル統合については「[【Rails】 joinsメソッドのテーブル結合からネストまでの解説書](https://pikawaka.com/rails/joins)」がわかりやすかったです。

joinsメソッドの引数には、テーブル名でなく、アソシエーションで定義した関連名が入ります。今回は、冒頭でモデルに記述した通り、userモデルとhistoryモデルは１対多(user has_many histories)なので、引数には```histories```が入ります。

（2-1のうまくいかなかった例で、```models/user.rb```に```has_many :browsed_posts, through　...```（以下略）を定義してますが、これは結果的に不要だったので、モデルの記述はSTEP1のままです）


## 2-2. ビューの記述

ここまで来れば、ビューで閲覧履歴のレコードをeachで取り出してあげればよさそうですね。

```html:app/views/users/show.html.erb
 <table>
   <% @browsed_posts.each do |browsed_post| %>
     <tr>
       <td><%= link_to (attachment_image_tag browsed_post, :recipe_image, :fill, 100, 100, format: 'jpg', size:'60x60', class:'rounded-circle'), post_recipe_path(browsed_post.id) %></td>
       <td><%= link_to browsed_post.title, post_recipe_path(browsed_post) %></td>
       <td><%= link_to browsed_post.user.name, user_path(browsed_post.user) %></td>
     </tr>
   <% end %>
 </table>
```

#3 バッチ処理で一定期間以上前の閲覧履歴を自動削除
最後に、バッチ処理を実装していきます。
閲覧履歴はどんどん増えて蓄積されてしまうので、今回は1ヶ月以上前の閲覧履歴は削除されるようにしておきたいと思います。

## 3-1. whenever導入
ではまず、gemのwheneverをインストールします。
後ほどcronを使用するのですが、その設定を簡単に書けるようにしてくれるgemですね。

```ruby:Gemfile
  gem 'whenever', require: false
```

```:terminal
  $ bundle install
```

## 3-2. schedule.rbを生成し、バッチ処理を記述
まず、```schedule.rb```を生成します。

```:terminal
  $ wheneverize .
```

次に、```schedule.rb```を開いて、処理を記述します。

```ruby:config/schedule.rb
# Use this file to easily define all of your cron jobs.
#
# It's helpful, but not entirely necessary to understand cron before proceeding.
# http://en.wikipedia.org/wiki/Cron

# Example:

env :PATH, ENV['PATH']
set :output, 'log/cron.log'
set :environment, :development

every 1.days, at: '0:00 am' do
  runner 'History.where("created_at < ?", 30.days.ago.beginning_of_day).delete_all'
end
```
👆毎日0時に、30日以上前にcreateされたhistoriesテーブルのレコードを全て削除します

ちなみに、```config/application.rb```に```config.time_zone = 'Tokyo'```と記述することで時間を日本時間にしています。

## 3-3. cronにバッチ処理を反映する
さて、wheneverでは、schedule.rbに記述した内容（バッチ処理）をcrontabに反映させることで実行される仕組みになっているので、crontabに反映させてあげましょう。

#### 開発環境の場合

```Ruby:terminal
$ bundle exec whenever --update-crontab
  [write] crontab file updated  #このように表示されていれば成功
```

次に、crontabに反映されているか確認しておきます。
先ほど```schedule.rb```に記述した内容が反映されていれば、OKです。

```Ruby:terminal
$ crontab -l
```

処理が正常に動いていれば、以下のようにlogファイルが生成されているはずです。
開発環境で正常に動くか確認してみましょう。

```Ruby:log/cron.log
Running via Spring preloader in process xxxx
Running via Spring preloader in process yyyy
```

確認できたら、開発環境の方では処理を停止しておきましょう👇

```Ruby:terminal
$ bundle exec whenever --clear-crontab
```


#### 本番環境の場合

本番環境で実行する場合は、まず```schedule.rb```のenvironmentをproductionに変更します👇

```ruby:config/schedule.rb
env :PATH, ENV['PATH']
set :output, 'log/cron.log'
set :environment, :production #ここを変更

every 1.days, at: '0:00 am' do
  runner 'History.where("created_at < ?", 30.days.ago.beginning_of_day).delete_all'
end
```

さらに、Rails5以上の場合は、下記を追記します👇
この追記が必要な背景は「[Rails5のproduction環境でlib/配下のクラス読込みがNameErrorになるのはautoloadが無効化されたからだった](https://qiita.com/kyoooko/items/27aa8e6c9809a387b66f)」に詳しく書かれているので、確認してみてください。

```ruby:config/application.rb
config.paths.add 'lib', eager_load: true
```

上記のファイルを変更した上で、EC2上で①のコマンドを実行します。
開発環境とはコマンドが違うので注意。

```Ruby:本番環境
# ①定時処理の内容更新
RAILS_ENV=production bundle exec whenever --update-crontab

# ②定時処理の停止
RAILS_ENV=production bundle exec whenever --clear-crontab

# ③cronのログ確認
RAILS_ENV=production crontab  -l
```

以上です！
お疲れさまでした。


## 参考資料
- [【Rails】wheneverを本番環境＋Capistranoで使う](https://qiita.com/kyoooko/items/27aa8e6c9809a387b66f)
- [【Rails】 joinsメソッドのテーブル結合からネストまでの解説書](https://pikawaka.com/rails/joins)
- [【Rails】バッチ処理(whenever)で、1日1回メールを自動配信してみる
](https://qiita.com/mi-1109/items/9278a425fb2e7f8c1c0c)
- [Railsのcontrollerとmodelのみを使って記事閲覧履歴を作成する](https://qiita.com/solare_tech/items/3cad7ada6c14a00ee15c)