0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-02-08

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_validationsgem を利用する。

  1. gem を追加し、bundle install
gem 'active_storage_validations', '~> 0.8.8' # ActiveStorageのバリデーション用のライブラリ
  1. モデルファイルにバリデーションを追加
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
  1. アップロード用の編集フォームを修正

フォーム編集時に画像アップロードがバリデーションエラーとなると関連付けのみ作成されて関連先の画像がないという状態となるので、@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

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

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

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. ビューへページネーション用のリンクを追加

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 とは別プロセスで動作し、インデックスやドキュメントを独立して内部で保持する。
Searchkickgem で 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]
elasticsearch起動時のエラーの解決方法

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

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

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

  1. OpenJDK をインストール
brew install openjdk@17
  1. シンボリックリンクを作成
sudo ln -sfn /usr/local/opt/openjdk@17/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk-17.jdk
  1. シェル設定ファイルに JAVA_HOME を追加
echo 'export JAVA_HOME=$(/usr/libexec/java_home)' >> ~/.zshrc
source ~/.zshrc
  1. java のインストール確認
java -version

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

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
bin/rails sでエラーが発生した場合

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

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

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

落穂ひろい

エラーハンドリング

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

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

rescue_from を利用する場合

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

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修正することで設定する

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
0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?