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
(画像ファイルそのものに相当)はそのままとしておける
.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"
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 を利用する。
- gem を追加し、
bundle install
gem 'active_storage_validations', '~> 0.8.8' # ActiveStorageのバリデーション用のライブラリ
- モデルファイルにバリデーションを追加
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
- アップロード用の編集フォームを修正
フォーム編集時に画像アップロードがバリデーションエラーとなると関連付けのみ作成されて関連先の画像がないという状態となるので、@event.image.blob&.persisted?
のような条件を設定することで画像が実際にアップロードされたかを判定する
.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. コントローラに専用メソッドを追加
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. ビューへページネーション用のリンクを追加
%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
用の設定を追加
ja:
views:
pagination:
first: "« 最初"
last: "最後 »"
previous: "‹ 前"
next: "次 ›"
truncate: "…"
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]
elasticsearch起動時のエラーの解決方法
私の環境だとeasticsearch
起動時に以下のようなポップアップメッセージが出力され起動がうまくいきませんでした。
jdk.appは壊れているため開けません。 ゴミ箱に入れる必要があります。
以下の手順で Java をインストールしてから、Elasticsearch を起動することで解決しました。
- OpenJDK をインストール
brew install openjdk@17
- シンボリックリンクを作成
sudo ln -sfn /usr/local/opt/openjdk@17/libexec/openjdk.jdk /Library/Java/JavaVirtualMachines/openjdk-17.jdk
- シェル設定ファイルに JAVA_HOME を追加
echo 'export JAVA_HOME=$(/usr/libexec/java_home)' >> ~/.zshrc
source ~/.zshrc
- java のインストール確認
java -version
正常に表示されればelasticsearch
が起動されます。
2. Searchkick を bundle install
gem 'searchkick', '4.5.0' # 私の環境ではこのバージョンで安定しました。
3. 検索対象のモデルへ searchkick の設定を追加
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. 検索フォームをビューへ追加
%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 環境でリンクをクリックした場合と同じ挙動に変換する。
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()
})
}
})
require("get_from_turbolinks")
5. フォーム用モデルオブジェクトを作成
# 検索フォームのモデルクラス
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. コントローラでフォームオブジェクトを利用する
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 種類のエラーが発生しましたので解決方法をまとめておきます。
-
ActiveSupport::LoggerThreadSafeLevel::Logger (NameError)
こちらは以下の記事の内容で解決できました。
【Rails】uninitialized constant ActiveSupport::LoggerThreadSafeLevel::Logger (NameError)の対処法
-
uninitialized constant Elasticsearch::Transport (NameError)
こちらは searchkick の依存関係で Elasticsearch クライアントが適切にロードできないのが原因のようです。以下の gem を
bundle install
したところ解消されました。
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 組み込みの例外発生時に独自の挙動を実行したい場合は以下のように設定する
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/error500.html.haml
config/routes.rb
に定義されていない URL がリクエストされた場合、エラーはRack Middleware
で発生するのでコントローラレベルで定義しているrescue_from
ではキャッチできない。そのため、ルーティング設定の最後に全ての URL をキャッチする設定を追加し、どのルーティングにも当てはまらない場合に 404 エラー用のアクションへルーティングさせる
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
修正することで設定する
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
などのページではなく開発用のエラーページが表示される。
開発環境で本番用のエラーページを表示させるには以下の設定を変更する。
Rails.application.configure do
略
config.consider_all_requests_local = false # falseにすることで開発用エラーページではなく、本番用エラーページを表示する
end