🦓

[Rails]検索候補

2023/08/23に公開

はじめに

elasticsearchsearchkickを使って検索のサジェスト機能を追加していきます。

ElasticsearchはJSONベースの全文検索エンジンです。
Searchkickは、RailsにElasticsearchベースの検索機能を実装するためのgemです。
Searchkick を使用すると、データベース内のデータをインデックス化してElasticsearchで検索することができます。

https://github.com/elastic/elasticsearch-ruby
https://github.com/ankane/searchkick

環境

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にアクセスして接続を確認します。
https://hub.docker.com/_/elasticsearch/

gemをインストールする

Gemfile
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

ダミーデータを作成する

db/seeds.rb
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を有効にします。

app/models/post.rb
class Post < ApplicationRecord
    searchkick text_middle: %i[title body]
end

text_middleは、Elasticsearchのクエリオプションの一部で、検索対象のテキストフィールド内の部分一致を行うためのオプションです。

Search用URLを作成する

config/routes.rb
post '/search', to: 'search#search', as: :search
post '/search_suggestions', to: 'search#suggestions', as: 'search_suggestions'

Searchコントローラーを作成する

検索結果を@resultsに代入し、turbo_streamを使って検索結果をリロードせずに更新できるようにします。

app/controllers/search_controller.rb
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パーシャルを作成します。

app/views/posts/posts.html.erb
<% posts.each do |post| %>
    <%= render post %>
<% end %>

searchフォームを作成する

検索フォームの下にサジェストを配置します。
キーが離された際にsearchコントローラのsuggestionsアクションを呼び出します。

app/views/search/_form.html.erb
<%= 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内に検索結果が更新されるようにします。

app/controllers/search_controller.rb
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パーシャルを作成する

app/views/_suggestion.html.erb
<% if @results.present? %>
  <div>
    <% @results.each do |post| %>
      <div>
        <%= link_to post.title, post %>
      </div>
    <% end %>
  </div>
<% end %>

Stimulusコントローラー

app/javascript/controllers/search_controller.js
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;
  }
}

メモ

  1. データのインデックスのタイミング・量・頻度
  2. 更新されないデータのキャッシュ化

こちらに関して、もう少し調査が必要です。

終わりに

ElasticsearchとStimulusを使った検索補完機能を実装してみました。
都度リクエストを送りデータをフェッチすることになりますが、検索候補のライブラリを使ってフロントで候補を表示させる方法もあります。
そちらも試してみようと思います。

参考したリポジトリ:
https://github.com/Deanout/search_autocomplete_rails

Discussion