Open8

searchkickを読む

inomotoinomoto

仕事で使ってるけど割とふいんき()って感じでよくわかっていないことが多いので読む
https://github.com/ankane/searchkick

基本的にREADMEが非常に充実しているので、ぶっちゃけコード読まなくても結構なんとかなる。
が、挙動をある程度知ってないとElasticsearchインフラをごにょごにょするときに困る。

なお読むのはv4.3.1

inomotoinomoto

まずはルートのlib/searchkick.rbから。

Searchkick.xxx諸々のattr_accessorやclient, envなどが定義されている。clientがオプション付きのElasticsearch gemのソレであることがわかる。

  def self.client
    @client ||= begin
      require "typhoeus/adapters/faraday" if defined?(Typhoeus) && Gem::Version.new(Faraday::VERSION) < Gem::Version.new("0.14.0")

      Elasticsearch::Client.new({
        url: ENV["ELASTICSEARCH_URL"],
        transport_options: {request: {timeout: timeout}, headers: {content_type: "application/json"}},
        retry_on_failure: 2
      }.deep_merge(client_options)) do |f|
        f.use Searchkick::Middleware
        f.request signer_middleware_key, signer_middleware_aws_params if aws_credentials
      end
    end
  end

ちょっと下にいくと検索本体であろうSearchkick.searchが定義されているが、これはまた後ほど。

一番下までいくと

# TODO find better ActiveModel hook
require "active_model/callbacks"
ActiveModel::Callbacks.include(Searchkick::Model)

ActiveSupport.on_load(:active_record) do
  extend Searchkick::Model
end

詳しくは不勉強にて知らないが、雰囲気的にはActiveRecordなクラスでSearchkick::Model内のコードが使えるようにしているのであろう。
on_loadについてはドキュメントがあった: https://railsguides.jp/engines.html#active-supportのon-loadフック

inomotoinomoto

lib/searchkick/model.rbを見ると、Searchkick::Modeldef searchkickが定義されている。定義でいうとこれだけ。
要するに

class Product < ApplicationRecord
  searchkick
end

searchkick部分があるだけで、それ以外はこのメソッドが定義しているようだ。

メソッド内部は大きく分けて、オプションsanitize&読み込み・index名決定・class_evalになっている。

オプションのsanitizeは

      unknown_keywords = options.keys - [:_all, :_type, :batch_size, ...]
      raise ArgumentError, "unknown keywords: #{unknown_keywords.join(", ")}" if unknown_keywords.any?

となっていて、keysからvalidなkeywordsを引いて残りがあればunknownとしてる。なるほどなー。

index名決定は、index_nameindex_prefixなど指定できるが、デフォルトであれば

[Searchkick.index_prefix, model_name.plural, Searchkick.env, Searchkick.index_suffix].compact.join("_")

で、まぁSearchkick.index_prefixSearchkick.index_suffixなども無指定であれば

"#{model_name.plural}_#{Searchkick.env}"

と等価になる。要するにproducts_developmentとかそんな感じ。
なおSearchkick.envの定義はlib/searchkick.rbにあった。名前の通りのもの。

inomotoinomoto

class_evalの中には、search_indexreindex、またcallbackなどが定義されている。

モデルにはSearchkick::Indexのインスタンスがくっついており、おそらくESのindexへのアクセスなどをしているのだろう。
またsearchkick_reindexにてsearchkick_index.reindexという呼び出しが見え、indexするなどの操作もしていそう。

それぞれのメソッドやalias定義で

alias_method :search_index, :searchkick_index unless method_defined?(:search_index)

def reindex(method_name = nil, **options)
  RecordIndexer.new(self).reindex(method_name, **options)
end unless method_defined?(:reindex)

という感じにunless method_defined?しており、ユーザによる自前定義を上書きしないようにしている。

てか、defの末尾にもunlessつけて分岐できるのか。いや、冷静に考えれば特に不思議は無いし、なんならなるほど感ある。

inomotoinomoto

次はlib/searchkick/index.rbにあるSearchkick::Indexを見てみる。

パッと見で

    def create(body = {})
      client.indices.create index: name, body: body
    end

    def delete
      if alias_exists?
        # can't call delete directly on aliases in ES 6
        indices = client.indices.get_alias(name: name).keys
        client.indices.delete index: indices
      else
        client.indices.delete index: name
      end
    end

    def exists?
      client.indices.exists index: name
    end

    def refresh
      client.indices.refresh index: name
    end

    def alias_exists?
      client.indices.exists_alias name: name
    end

    def mapping
      client.indices.get_mapping index: name
    end

    def settings
      client.indices.get_settings index: name
    end

と、いかにもES本体の操作のwrapperですよって感じのコードが並んでいる。
ちなみにclientSearchkick.clientなので要するにElasticsearch::Clientのインスタンス。

inomotoinomoto

もうちょっと見るとdef promoteがありindexのaliasを使った切り替えとかやるのかなーとか、def reindexdef reindex_scopeなどreindexの実体っぽいもの、def create_indexにタイムスタンプ付きindexを作っている部分があったり、色々実装の詳細に踏み込んでいけそうな感じ。

とはいえファイルごとに網羅的に見ていくと上下レイヤがわからず微妙なので、ユースケースごとに縦に掘っていくのがよさそうか。

inomotoinomoto

SomeModel.reindexと呼び出した場合を考える。lib/searchkick/model.rbのclass_evalの中にある定義を見ると

          def searchkick_reindex(method_name = nil, **options)
            # TODO relation = Searchkick.relation?(self)
            relation = (respond_to?(:current_scope) && respond_to?(:default_scoped) && current_scope && current_scope.to_sql != default_scoped.to_sql) ||
              (respond_to?(:queryable) && queryable != unscoped.with_default_scope)

            searchkick_index.reindex(searchkick_klass, method_name, scoped: relation, **options)
          end
          alias_method :reindex, :searchkick_reindex unless method_defined?(:reindex)

relationが長いが、respond_to?は後段のための前提チェックみたいな感じなので、理解のために除去していみると

relation = (current_scope && current_scope.to_sql != default_scoped.to_sql) ||
           (queryable != unscoped.with_default_scope)

となる。読めそう。

current_scopeはActiveRecord::Relationでwhereとかくっつけたときの現在の条件全体的な感じであろう。きっと。
そしてdefault_scopedとsql比較をして不一致を確認しているので、前半は「モデルのdefault_scope以外に何かクエリが付与されているか」って感じか。
※後半のqueryableはよくわからなかった。ググるとmongoidの話とかが出てくるのでそっち系かも。ここでは無視。

relationという変数名ではあるがsearchkick_index.reindexscopedというoption名で渡されていることや、上にあるTODOコメントの雰囲気からも、この意図で合ってそう。

冒頭に戻ってSomeModel.reindexの呼び出しであれば

searchkick_index.reindex(SomeModel, nil, scoped: false)

で呼び出されると言えそう。

inomotoinomoto

lib/searchkick/index.rbに戻ってreindexの定義を見る。

    def reindex(relation, method_name, scoped:, full: false, scope: nil, **options)
      refresh = options.fetch(:refresh, !scoped)
      options.delete(:refresh)

      if method_name
        # TODO throw ArgumentError
        Searchkick.warn("unsupported keywords: #{options.keys.map(&:inspect).join(", ")}") if options.any?

        # update
        import_scope(relation, method_name: method_name, scope: scope)
        self.refresh if refresh
        true
      elsif scoped && !full
        # TODO throw ArgumentError
        Searchkick.warn("unsupported keywords: #{options.keys.map(&:inspect).join(", ")}") if options.any?

        # reindex association
        import_scope(relation, scope: scope)
        self.refresh if refresh
        true
      else
        # full reindex
        reindex_scope(relation, scope: scope, **options)
      end
    end

オプションによって3方向に分岐するようだが、今回の前提ではmethod_namenilscopedfullfalseである。
とすると実行されるコードは

        # full reindex
        reindex_scope(relation, scope: scope, **options)

であり、もうちょっと具体値を入れるとreindex_scope(SomeModel, scope: nil)となる。
# full reindexらしい。まぁそうだな。