kickflow Tech Blog

株式会社kickflowの開発チームによるブログ

elsaticsearch-railsを使うのをやめた話

こんにちは、CTOの小林です。kickflowでは検索基盤としてElasticsearchを利用しています。kickflowのバックエンドにはRailsを採用しており、以前はElasticsearchの統合にelasticsearch-railsという公式gemを使用していたのですが、これを使用しなくなったというお話をします。

elasticsearch-railsとは

github.com

elasticsearch-railsは、Elasticsearchの開発元であるElastic社が公開している公式のRuby on Rails用のgemです。 gemをインストール後、以下のようにActiveRecordのクラスの中でincludeすることでElasticsearchのインデックス作成や検索のメソッドをActiveRecordのクラスに追加することができます。

# リポジトリのREADMEより
require 'elasticsearch/model'

class Article < ActiveRecord::Base
  include Elasticsearch::Model
  include Elasticsearch::Model::Callbacks
end

# Index creation right at import time is not encouraged.
# Typically, you would call create_index! asynchronously (e.g. in a cron job)
# However, we are adding it here so that this usage example can run correctly.
Article.__elasticsearch__.create_index!
Article.import

@articles = Article.search('foobar').records

elasticsearch railsなどのキーワードで検索すると、こちらを使用してElasticsearchによる検索を実装する記事が沢山ヒットするので、RailsでElasticsearchを使用している人は一度は見たことがあるかと思います。

Elasticが公開しているgemには、他にはelasticsearch-rubyがあります。elasticsearch-rubyは純粋なElasticsearch APIのRubyクライアントを提供するのに対し、elasticsearch-railsはelasticsearch-rubyの上の構築された、Railsの仕組みにがっつり乗っかったライブラリになっています。

github.com

なぜelasticsearch-railsを剥がすことにしたのか

さて、一見便利そうなelasticsearch-railsなのですが、下記の事情からkickflowでは使用するのをやめることにしました。

長い間メンテナンスされていない

elasticsearch-rubyは継続的にメンテナンスされているのに対し、elasticsearch-railsはメンテナンスがされていないようです。Elasticsearchは2022年2月にバージョン8系が公開されましたが、この記事執筆時点の2023年4月においてもelasticsearch-railsは未だバージョン7系までしかサポートしておりません。elasticsearch-rubyは最新版の8.7まで追従できているのですが...

また、elasticsearch-railsがメンテされていないことで内部で利用されているelasticsearch-rubyも最新版を使うことができず、例えばFaradayを2系にアップデートできないなど、elasticsearch以外のライブラリのアップデートのブロッカーになっておりました。

(ちなみにバージョン8系のサポートのIssueはこちらですが、現在もOpenになっています) github.com

モデルの設計的にも、Elasticsearchへのインデックスや検索を別クラスに分離したい

elasticsearch-railsはActiveRecordのクラスにメソッドを生やすため、モデルのクラスに大量のメソッドが自動的に追加されます。これはモデルの肥大化につながるため、できればElasticsearch関連の処理はクラスやモジュールを分離して処理を移譲したいと思っていました。

インデックス処理・検索処理を実装

elasticsearch-railsは先述の通りメンテナンスされなくなっていましたが、elasticsearch-rubyは頻繁にメンテナンスされていることから、プロジェクトのGemfileにはelasticsearch-rubyを直接依存関係に追加し、これを使って各モデルのインデックスや検索処理を実装することにしました。

実装は簡単です。例えばkickflowのワークフロー(Workflowクラス)の検索機能では、以下のようなWorkflowIndexerクラスを追加します。まずはインデックスの作成からドキュメントの登録までです。

class WorkflowIndexer
  # 一部の主要なメソッドのみ抜粋

  # インデックスを作成する
  def create_index
    settings = {
      analysis: {
        normalizer: {
          my_normalizer: {
            type: "custom",
            char_filter: ["icu_normalizer"],
            filter: ["lowercase", "katakana"],
          },
        },
        filter: {
          katakana: {
            type: "icu_transform",
            id: "Hiragana-Katakana",
          },
        },
      },
    }
    mappings = {
      _routing: {
        required: true,
      },
      properties: {
        name: {
          type: "keyword",
          normalizer: "my_normalizer",
        },
        description: {
          type: "keyword",
          normalizer: "my_normalizer",
        },
        ...
      },
    }
    client.indices.create index: INDEX_NAME,
                          body: {
                            settings:,
                            mappings:,
                          }
  end

  # ドキュメントの登録
  def index_document(workflow, refresh: false)
    arguments = {
      index: INDEX_NAME,
      id: workflow.id,
      routing: workflow.tenant_id,
      body: indexed_json(workflow),
      refresh:,
    }
    res = client.index arguments
    if res["_shards"]["failed"] > 0
      raise "Failed to index document"
    end
  end

  # Elasticsearchに登録するJSON
  # 検索対象となるカラムと、ソート対象となるカラムを含むJSONを返す
  def indexed_json(workflow)
    json = {
      name: workflow.current_version.name,
      description: workflow.current_version.description,
      ...
    }
    json.as_json
  end
end

次に、検索処理をWorkflowIndexerに追加します。ElasticsearchのレスポンスからレコードのIDを取り出し、IDを指定してWorkflowレコードを検索します。最後に sort_by を使ってElasticsearchのレスポンスの順序に一致するように配列を並び替えています。

class WorkflowIndexer
  # 一部の主要なメソッドのみ抜粋

  # 指定したテナントの中で検索する
  # @return [Array] 第一要素がレコードの配列、第二要素が合計件数
  def search_documents(tenant_id, body, from, size)
    response_body = client.search({
                                    index: ALIAS_NAME,
                                    routing: tenant_id,
                                    from:,
                                    size:,
                                    body:,
                                  })
    hits = response_body["hits"]["hits"]
    ids = hits.pluck("_id")
    total = response_body["hits"]["total"]["value"]

    records = Workflow.where(tenant_id:, id: ids)
                      .to_a
                      .sort_by { |record| hits.index { |hit| hit["_id"].to_s == record.id.to_s } }
    [records, total]
  end
end

あとは必要なタイミングで上記のクラスのindex_documentsearch_documentsを呼び出すだけになります。

おわりに

kickflowでは今回紹介したワークフロー以外にも、様々なデータ構造の検索をElasticsearchを使用して実装しております。こうした検索分野に興味のあるエンジニアも絶賛募集中ですので、ご興味のある方は以下のリンクからご応募ください。

herp.careers