パーフェクトRuby on Rails 8章 メモ・雑感(Active Storageの拡張、kaminariによるページネーション、Elasticsearchによる全文検索、エラーハンドリング)

# 8 Rails アプリケーション拡張

## 雑感

この章は、外部gemを利用したRailsアプリケーションの機能の拡張に関する内容でした。Elasticsearchl周りは実際に業務でも使用しており、かなり実務に近い内容になってきているので基礎から学んで解像度を上げることができて良かったと感じています。また、Elasticsearchの設定周りはかなりエラーも多かったので、私の環境におけるその辺りの解決方法もまとめてあります。

## 8.1 ファイルアップロード機能を作る

### 画像データを削除せず、関連付けのみを削除する

Active Strage の機能は以下の 2 つのモデルで作られる

- `Active::Storage::Attachment`
  - 画像を紐付けたいモデルと`Active::Storage::Blob`の中間テーブルに相当
  - 画像を紐付けたいモデルと画像を多対多で結びつける
- `Active::Storage::Blob`
  - アップロードファイルのメタ情報を管理する

モデルの属性に`dependent: false`を設定することでモデルデータ削除時に`Active::Storage::Attachment`(関連付け)のみを削除し、`Active::Storage::Blob`(画像ファイルそのものに相当)はそのままとしておける

```app/views/events/edit.html.haml
  .form-group
      = f.label :image
      -# 画像が保存済みであるか確認
      - if @event.image.attached? && @event.image.blob&.persisted?
        -# image_tagメソッドに画像用のプロパティを渡して画像を表示
        = image_tag(@event.image.variant(resize_to_fit: [200, 200]), class: "img-thumbnail d-block mb-3")
      = f.file_field :image, class: "form-control-file"
    - if @event.image.attached? && @event.image.blob&.persisted?
      -# 画像削除用のチェックボックス
      .checkbox
        %label
          = f.check_box :remove_image
          画像を削除する
    = f.submit class: "btn btn-primary"
```

```app/models/event.rb
class Event < ApplicationRecord
  # has_one_attachedにdependent: :falseを指定することで、イベントが削除時にActive::Storage::Attachment(中間モデル)のみ削除
  # Active::Storage::Blob(画像ファイル)は削除されないので再びアタッチすることで復元できる
  has_one_attached :image, dependent: :false

  attr_accessor :remove_image

  before_save :remove_image_if_user_accept

  private

  def remove_image_if_user_accept
    # ActiveRecord::Type::Boolean.new.castは、文字列を真偽値に変換する
    self.image = nil if ActiveRecord::Type::Boolean.new.cast(remove_image)
  end
end
```

### Active Storage へバリデーションを追加する

Active Storage にはバリデーションヘルパーが存在しないため、
画像以外のファイルアップロードを禁止するためには、`active_storage_validations`gem を利用する。

1. gem を追加し、`bundle install`

```Gemfile
gem 'active_storage_validations', '~> 0.8.8' # ActiveStorageのバリデーション用のライブラリ
```

2. モデルファイルにバリデーションを追加

```diff_ruby:app/models/event.rb
class Event < ApplicationRecord
  has_one_attached :image, dependent: :false
  has_many :tickets, dependent: :destroy
  belongs_to :owner, class_name: 'User'

+  validates :image,
+    content_type: [:png, :jpg, :jpeg], # ファイルの種類
+    size: { less_than_or_equal_to: 10.megabytes }, # ファイルサイズ
+    dimension: { width: { max: 2000 }, height: { max: 2000 } } # 大きさ
略
end
```

3. アップロード用の編集フォームを修正

フォーム編集時に画像アップロードがバリデーションエラーとなると関連付けのみ作成されて関連先の画像がないという状態となるので、`@event.image.blob&.persisted?`のような条件を設定することで画像が実際にアップロードされたかを判定する

```app/views/events/edit.html.haml
  .form-group
      = f.label :image
      -# 画像が保存済みであるか確認
      - if @event.image.attached? && @event.image.blob&.persisted?
        -# image_tagメソッドに画像用のプロパティを渡して画像を表示
        = image_tag(@event.image.variant(resize_to_fit: [200, 200]), class: "img-thumbnail d-block mb-3")
      = f.file_field :image, class: "form-control-file"
    - if @event.image.attached? && @event.image.blob&.persisted?
      -# 画像削除用のチェックボックス
      .checkbox
        %label
          = f.check_box :remove_image
          画像を削除する
    = f.submit class: "btn btn-primary"
```

### ダイレクトアップロード時にバリデーションしたい時の注意点

`active_storage_validations`によるバリデーションは通常のアップロードを想定しており、ダイレクトアップロード時には不完全なバリデーションしかできない。例えば`dummy.png`のような画像ファイル拡張子に擬態したテキストファイルでもアップロードできてしまう。

## gem で機能を拡張する

### Kaminari でページネーション機能を作る

`Kaminari`はページネーション機能を簡単に追加することができる gem

#### 1. gem を追加し`bundle install`

```Gemfile
gem 'kaminari', '~> 1.2.0' # ページネーション用のライブラリ
```

#### 2. コントローラに専用メソッドを追加

```diff_ruby:app/controllers/welcome_controller.rb
class WelcomeController < ApplicationController
  skip_before_action :authenticate

  def index
+    # kaminariを追加することでpage, perメソッドが使えるようになる
+    # pageメソッドでページ番号を指定し、perメソッドで1ページあたりの表示件数を指定
+    # perを指定しない場合はデフォルトの25件が表示される
+    @events = Event.page(params[:page]).per(10).
      where("start_at >  ?", Time.zone.now).order(:start_at)
  end
end
```

#### 3. ビューへページネーション用のリンクを追加

```diff_ruby:app/views/welcome/index.html.haml
%h1 イベント一覧

%ul.list-group
  - @events.each do |event|
    = link_to(event, class: "list-group-item list-group-item-action") do
      %h5.list-group-item-heading= event.name
      %p.mb-1= "#{l(event.start_at, format: :long)} - #{l(event.end_at, format: :long)}"

+ -# ページネーションの表示
+ = paginate @events
```

#### 4. Bootstrap 用の Kaminari のビューを生成

以下コマンドで`app/views/kaminari/`配下に Bootstrap4 用のビューテンプレートが生成される

```
% bin/rails g kaminari:views bootstrap4
```

#### 5. `i18n`用の設定を追加

```config/locales/kaminari_ja.yml
ja:
  views:
    pagination:
      first: "&laquo; 最初"
      last: "最後 &raquo;"
      previous: "&lsaquo; 前"
      next: "次 &rsaquo;"
      truncate: "&hellip;"
```

### Searchkick でイベント検索機能を作る

複雑な仕様や検索対象のレコードが多い場合は、文章を検索する時にインデックスを利用できないなどの問題があり、`ActiveRecord`単体で検索機能を実現するのは難しい。
このような場合、Elasticsearch などの全文検索エンジンを利用する。Elasticsearch は Rails や DB とは別プロセスで動作し、インデックスやドキュメントを独立して内部で保持する。
`Searchkick`gem で DB と Elasticsearch を自動で連携させることができる。

### 1. Elasticsearch のインストール

```
% brew tap elastic/tap
% brew install elastic/tap/elasticsearch-full
```

自然な日本語で検索するためには日本語の検索エンジンも一緒にインストールする

```
% elasticsearch-plugin install analysis-kuromoji
```

elasticsearch を起動する

```
% elasticsearch
略
[2025-02-01T13:17:43,130][INFO ][o.e.i.g.DatabaseNodeService] [MacBook-Pro-2.local] successfully reloaded changed geoip database file [/var/folders/sy/swd_k_6s121b8y7bw_d0mwqh0000gn/T/elasticsearch-4249022260319380573/geoip-databases/aN9p4CIKQR2RN_dzzcoUoQ/GeoLite2-City.mmdb]
```

<details><summary>elasticsearch起動時のエラーの解決方法</summary>

私の環境だと`easticsearch`起動時に以下のようなポップアップメッセージが出力され起動がうまくいきませんでした。

```
jdk.appは壊れているため開けません。 ゴミ箱に入れる必要があります。
```

以下の手順で Java をインストールしてから、Elasticsearch を起動することで解決しました。

1. OpenJDK をインストール

```
brew install openjdk@17
```

2. シンボリックリンクを作成

```
sudo ln -sfn /usr/local/opt/openjdk@17/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk-17.jdk
```

3. シェル設定ファイルに JAVA_HOME を追加

```
echo 'export JAVA_HOME=$(/usr/libexec/java_home)' >> ~/.zshrc
source ~/.zshrc
```

4. java のインストール確認

```
java -version
```

正常に表示されれば`elasticsearch`が起動されます。

</details>

### 2. Searchkick を bundle install

```Gemfile.rb
gem 'searchkick', '4.5.0' # 私の環境ではこのバージョンで安定しました。
```

### 3. 検索対象のモデルへ searchkick の設定を追加

```app/models/event.rb
class Event < ApplicationRecord
  # searchkickメソッドで専用のメソッドをモデルへ追加
  # language: "japanese"を指定することで検索に日本語検索エンジンkuromojiを使用
  searchkick language: "japanese"

  # このメソッドの戻り値がインデックスに追加される
  # 検索フォームで入力するキーワードにマッチさせる情報とイベント開始時間を登録
  def search_data
    {
      name: name,
      place: place,
      content: content,
      owner_name: owner&.name,
      start_at: start_at
    }
  end
end
```

メソッドを追加後に、モデルの文章情報のインデックスを`elasticsearch`へ登録

```
% bin/rails r Event.reindex
```

### 4. 検索フォームをビューへ追加

```app/views/welcome/index.html.haml
%h1 イベント一覧

-# 検索条件が単純なのでGETメソッドを指定
-# 複雑な条件の場合はGETだとURL長さ上限を超えるのでPOSTとする
-# @event_search_formは検索フォーム用のモデルオブジェクト(この後、作成する)
-# form_withにmodelオプションを設定して上記を渡すことでActiveRecordオブジェクトをフォームへ渡した時と同様に設計できる
= form_with(model: @event_search_form, url: root_path, method: :get) do |f|
  .form-group
    = f.label :keyword, "キーワード"
    = f.text_field :keyword, class: "form-control"
  .form-group
    = f.label :start_at, "以降に開催されるイベント"
    = f.datetime_field :start_at, class: "form-control"
  .form-group
    = f.submit "検索", class: "btn btn-primary"
```

`form_with`はデフォルトで ajax リクエストを送るが、Turbolinks は form による ajax での GET リクエストに対応していない。以下のような JavaScript コードを追加して form による GET リクエストを Turbolinks 環境でリンクをクリックした場合と同じ挙動に変換する。

```app/javascript/get_from_turbolinks.js
import Turbolinks from "turbolinks"

document.addEventListener("turbolinks:load", function(event) {
  const forms = document.querySelectorAll("form[method=get][data-remote=true]")
  for (const form of forms) {
    form.addEventListener("ajax:beforeSend", function(event) {
      const options = event.detail[1]

      Turbolinks.visit(options.url)
      event.preventDefault()
    })
  }
})
```

```app/javascript/packs/application.js
require("get_from_turbolinks")
```

### 5. フォーム用モデルオブジェクトを作成

```app/forms/event_search_form.rb
# 検索フォームのモデルクラス
class EventSearchForm
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :keyword, :string
  attribute :page, :integer

  # searchkickで検索するためのメソッド
  # デフォルトでkaminariのページングに対応しているのでpageとper_pageメソッドが使える
  def search
    Event.search(
      keyword_for_search,
      where: { start_at: { gt: start_at } },
      page: page,
      per_page: 10
    )
  end

  def start_at
    @start_at || Time.current
  end

  # in_time_zoneメソッドで、タイムゾーンを考慮した時刻に変換
  def start_at=(new_start_at)
    @start_at = new_start_at.in_time_zone
  end

  private

  # 何も入力がない場合は全検索で全ての検索結果を表示する
  def keyword_for_search
    keyword.presence || "*"
  end
end

```

### 6. コントローラでフォームオブジェクトを利用する

```app/controllers/welcome_controller.rb
class WelcomeController < ApplicationController
  skip_before_action :authenticate

  def index
    @event_search_form = EventSearchForm.new(event_search_form_params)
    @events = @event_search_form.search
  end

  private

  # デフォルト値に{}を設定
  # mergeで許可するパラメータにpageを追加してページング情報をパラメータとして利用できるようにする
  def event_search_form_params
    params.fetch(:event_search_form, {}).permit(:keyword, :start_at).merge(page: params[:page])
  end
end
```

<details><summary>bin/rails sでエラーが発生した場合</summary>

私の環境だと、上記までの実装を行なって動作確認したところ以下 2 種類のエラーが発生しましたので解決方法をまとめておきます。

- `ActiveSupport::LoggerThreadSafeLevel::Logger (NameError)`

  こちらは以下の記事の内容で解決できました。

  [【Rails】uninitialized constant ActiveSupport::LoggerThreadSafeLevel::Logger (NameError)の対処法](https://qiita.com/Taira0222/items/89fe772eb8d752da4db7)

- `uninitialized constant Elasticsearch::Transport (NameError)`

  こちらは searchkick の依存関係で Elasticsearch クライアントが適切にロードできないのが原因のようです。以下の gem を`bundle install`したところ解消されました。

```Gemfile
gem 'elasticsearch', '~> 7.17.4'
gem 'elasticsearch-transport', '~> 7.17.4'
gem 'elasticsearch-api', '~> 7.17.4'
```

なお、解決後は上記 gem を削除しても正常に稼働しています。
</details>


## 落穂ひろい

### エラーハンドリング

Rails には以下の 2 つのエラーハンドリング方法が存在する

- コントローラで`rescue_fromn`メソッドを使う
- エラーハンドリング用の`Rack Middleware`を利用する

#### rescue_from を利用する場合

`ActiveRecord::RecordNotFound`, `ActionController::RoutingError`などの rails 組み込みの例外発生時に独自の挙動を実行したい場合は以下のように設定する

```diff_ruby:app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
略
+  # recue_fromは下から上に評価される
+  # rescue_from Exceptionは全てのエラーを捕捉するので一番上に記述する
+  rescue_from Exception, with: :error500
+  rescue_from ActiveRecord::RecordNotFound, ActionController::RoutingError, with: :error404

  private
略
+  # エラー発生時にHTML以外でのレスポンスを要求された場合にテンプレートが見つからないエラーが発生する
+  # formats: [:html]を指定することで、HTML以外のレスポンスを要求された場合にはHTML形式でエラーページを返す
+  def error404(e)
+    render "error404", status: 404, formats: [:html]
+  end
+
+  def error500(e)
+    logger.error [e, *e.backtrace].join("\n")
+    render "error500", status: 500, formats: [:html]
+  end
end
```

エラーテンプレートを用意する。haml の場合はタグを必要とせず下記のように文言のみで HTML を記述できる。

```app/views/application/error404.html.haml
ご指定になったページは存在しません
```

```app/views/application/error500.html.haml
app/views/application/error500.html.haml
```

`config/routes.rb`に定義されていない URL がリクエストされた場合、エラーは`Rack Middleware`で発生するのでコントローラレベルで定義している`rescue_from`ではキャッチできない。そのため、ルーティング設定の最後に全ての URL をキャッチする設定を追加し、どのルーティングにも当てはまらない場合に 404 エラー用のアクションへルーティングさせる

```config/routes.rb
Rails.application.routes.draw do
略
  # どのルーティングにも当てはまらないリクエストの場合はerror404アクションを実行する
  match "*path" => "application#error404", via: :all
end
```

#### Rack Middleware を利用する場合

Rails からデフォルトで提供される`ActionDispatch::ShowExeptions`という`Rack Middleware`が例外をキャッチする。ここでキャッチされた例外は`Rails.application.config.exeptions_app`として設定された Rack アプリケーションで処理され、デフォルトでは`ActionDispatch::PublicExeptions`インスタンスが設定される。

`ActionDispatch::PublicExeptions`は例外クラスによって表示する HTML を切り替える。

表示例:

| 例外クラス                             | HTML            |
| -------------------------------------- | --------------- |
| ActionController::RoutingError         | public/404.html |
| ActionController::BadRequest           | public/400.html |
| 対応する HTML ファイルが存在しない場合 | public/500.html |

アプリケーション内で定義した独自の例外が発生した場合に`public/500.html`以外を表示したい場合は`config.action_dispatch.rescue_responses`修正することで設定する

```diff_ruby:config/application.rb
module AwesomeEvents
  class Application < Rails::Application
  略
+    # 独自の例外が発生した場合にpublic/500.html以外を表示したい時は以下を追加
+    # 以下の設定ではYourNewException例外クラスがpublic/404.htmlへルーティングされる
+    config.action_dispatch.rescue_responses.merge!(
+      "YourNewException" => :not_found
+    )
  end
end
```

なお、開発環境では例外を Rack でキャッチしても`public/500.html`などのページではなく開発用のエラーページが表示される。
開発環境で本番用のエラーページを表示させるには以下の設定を変更する。

```config/environments/development.rb
Rails.application.configure do
略
  config.consider_all_requests_local = false # falseにすることで開発用エラーページではなく、本番用エラーページを表示する
end
```