[Rails]検索候補
はじめに
elasticsearch
とsearchkick
を使って検索のサジェスト機能を追加していきます。
ElasticsearchはJSONベースの全文検索エンジンです。
Searchkickは、RailsにElasticsearchベースの検索機能を実装するためのgemです。
Searchkick を使用すると、データベース内のデータをインデックス化してElasticsearchで検索することができます。
環境
Rails 7.0.7
ruby 3.2.1
Elasticsearchを起動する
docker run -d --name elasticsearch --net somenetwork -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" elasticsearch:tag
ポート9200にアクセスして接続を確認します。
gemをインストールする
gem 'searchkick'
gem 'elasticsearch'
gem 'faker'
bundle install
scaffoldを作成する
bin/rails g scaffold post title body:text
searchコントローラーを作成する
bin/rails g stimulus search
create app/javascript/controllers/search_controller.js
rails stimulus:manifest:update
ダミーデータを作成する
150.times do |_i|
Post.create(title: Faker::Book.title,
body: Faker::Lorem.paragraph(sentence_count:2))
end
Post.reindex
reindex
メソッドは、Searchkick を使用して Elasticsearch インデックスを作成または更新するために使われるメソッドです。
bin/rails db:seed
Modelファイル
searchkick
を有効にします。
class Post < ApplicationRecord
searchkick text_middle: %i[title body]
end
text_middle
は、Elasticsearchのクエリオプションの一部で、検索対象のテキストフィールド内の部分一致を行うためのオプションです。
Search用URLを作成する
post '/search', to: 'search#search', as: :search
post '/search_suggestions', to: 'search#suggestions', as: 'search_suggestions'
Searchコントローラーを作成する
検索結果を@results
に代入し、turbo_stream
を使って検索結果をリロードせずに更新できるようにします。
class SearchController < ApplicationController
def search
@results = search_for_post
respond_to do |format|
format.turbo_stream do
render turbo_stream:
turbo_stream.update("posts", partial: "posts/posts", locals: {posts: @results} )
end
end
end
private
def search_for_post
if params[:query].blank?
Post.all
else
Post.search(params[:query], fields: %i[title body], operator: "or", match: :text_middle)
end
end
end
posts/posts
パーシャルを作成します。
<% posts.each do |post| %>
<%= render post %>
<% end %>
searchフォームを作成する
検索フォームの下にサジェストを配置します。
キーが離された際にsearch
コントローラのsuggestions
アクションを呼び出します。
<%= form_with( url: search_path, method: :post,
data: {
controller: "search",
action: "keyup->search#suggestions",
suggestions_url: search_suggestions_path
}
)
do |form| %>
<div class="input-group relative">
<%= form.text_field :q,
data: {
search_target: "input",
action: "blur->search#hideSuggestions"
},
class: 'form-control', placeholder: t('defaults.search_word') %>
<%= form.submit t('defaults.search_word') %>
// サジェスト
<div class="suggestions"
data-search-target="suggestions"
data-action="mousedown->search#childClicked">
<%= render 'search/suggestions', locals: { results: results } %>
</div>
</div>
<% end %>
Postを検索できるようになりました。
サジェスト機能を作っていきます。
suggestアクションを作成する
search
アクションと同じように、サジェストのdiv内に検索結果が更新されるようにします。
class SearchController < ApplicationController
...
def suggestions
@results = search_for_post
respond_to do |format|
format.turbo_stream do
render turbo_stream:
turbo_stream.update("suggestions", partial: "search/suggestions", locals: {results: @results} )
end
end
end
end
suggestパーシャルを作成する
<% if @results.present? %>
<div>
<% @results.each do |post| %>
<div>
<%= link_to post.title, post %>
</div>
<% end %>
</div>
<% end %>
Stimulusコントローラー
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["input", "suggestions"];
connect() {
console.log("Connected?");
// ドキュメント全体にクリックイベントリスナーを追加
document.addEventListener("click", (event) => {
// クリックがフォーム内でない場合、候補を非表示にする
if (!this.element.contains(event.target)) {
this.hideSuggestions();
}
});
}
suggestions() {
const query = this.inputTarget.value;
const url = this.element.dataset.suggestionsUrl;
clearTimeout(this.timeout);
this.timeout = setTimeout(() => {
this.requestSuggestions(query, url);
}, 250);
}
requestSuggestions(query, url) {
if (query.length === 0) {
this.hideSuggestions();
return;
}
this.showSuggestions();
// 候補のリクエストを送信
fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": document.querySelector("meta[name='csrf-token']")
.content,
},
body: JSON.stringify({ query: query }),
}).then((response) => {
response.text().then((html) => {
// 候補をHTMLとして追加
document.body.insertAdjacentHTML("beforeend", html);
});
});
}
childClicked(event) {
// 子要素がクリックされたかを判定
this.childWasClicked = this.element.contains(event.target);
}
showSuggestions() {
// 候補を表示する
this.suggestionsTarget.classList.remove("hidden");
}
hideSuggestions() {
if (!this.childWasClicked) {
// 子要素がクリックされていない場合、候補を非表示にする
this.suggestionsTarget.classList.add("hidden");
}
this.childWasClicked = false;
}
}
メモ
- データのインデックスのタイミング・量・頻度
- 更新されないデータのキャッシュ化
こちらに関して、もう少し調査が必要です。
終わりに
ElasticsearchとStimulusを使った検索補完機能を実装してみました。
都度リクエストを送りデータをフェッチすることになりますが、検索候補のライブラリを使ってフロントで候補を表示させる方法もあります。
そちらも試してみようと思います。
参考したリポジトリ:
Discussion