CLOVER🍀

That was when it all began.

Infinispanの検索のバックエンドにElasticsearchを使う

Infinispan 9から実験的な扱いとして入っていた、検索のバックエンドにElasticsearchを使うElasticsearch IndexManagerですが
(正確にはHibernate Searchが利用)、Infinispan 9.1.1.Finalで依存関係に入っているHibernate Searchが5.8.0.Finalに
なったので、これを機に試してみることにしました。

Release Notes - JBoss Issue Tracker

Hibernate Search 5.8.0.Final is out! - In Relation To

Hibernate Search 5.8から、Elasticsearchの2〜5.6までと統合することができるようになっています。また、5系のElasticsearchと
統合する際にはREST Clientを使用するようにもなっています(2系の時はJestを使っていました)。

この5.8を待っていたという感じです。

Infinispan × Hibernate Search × Elasticsearch

で、これがどういうことかというと、要するにInfinispanの検索のバックエンドをElasticsearchにすることができます。

Infinispanのインデックス有効時の検索は、Hibernate Searchを使った全文検索を行います。インデックスを使わない場合は、
リフレクションを使ったフルスキャンです。

このHibernate Searchを使った検索を行う際に、Hibernate SearchがバックエンドにElasticsearchが使用できるようになったことを
利用した統合です。

ちなみに、Hibernate SearchからElasticsearchを使う機能は、5.6から少しずつ実装されていました。

Hibernate Search × Elasticsearch - CLOVER

Hibernate Search 5.6.0.Alpha2 introduces Elasticsearch integration - In Relation To

ただ、Infinispan越しに使おうとするとちょっとAPIが足りない感じになっていて、そこの統合はできていませんでした。
そのあたりも変わったみたいですので。

お題

今回は、次の内容をお題に試してみようと思います。

  • Infinispanの検索機能(インデキシング付き)を使う
  • インデックスのバックエンドは、Elasticsearchとする
  • データのテーマは書籍
  • AnalyzerにはKuromojiを使用する

単純な検索、ソート、Aggregation、再インデックスあたりを試してみる感じで。

準備

まずは、依存関係から。

libraryDependencies ++= Seq(
  "org.infinispan" % "infinispan-query" % "9.1.1.Final" % Compile,
  "org.hibernate" % "hibernate-search-elasticsearch" % "5.8.0.Final" % Compile,
  "net.jcip" % "jcip-annotations" % "1.0" % Provided,
  "org.slf4j" % "slf4j-api" % "1.7.25" % Test,
  "ch.qos.logback" % "logback-classic" % "1.2.3" % Test,
  "org.scalatest" %% "scalatest" % "3.0.4" % Test
)

「infinispan-query」と「hibernate-search-elasticsearch」が、Infinispan越しにElasticsearchを使うためにまずは必要なモジュールです。

ScalaTestはテスト用、SLF4JおよびLogbackがいるのは、デバッグログ出力用となります。「jcip-annotations」が要るのは、Scalaの都合です。

Elasticsearchは、ElasticのDockerイメージを使いましょう。

$ docker run -d --name elasticsearch \
 -p 9200:9200 \
 -p 9300:9300 \
 -e ES_JAVA_OPTS="-Xms1g -Xmx1g" \
 -e http.host=0.0.0.0 \
 -e transport.host=127.0.0.1 \
 -e xpack.security.enabled=false \
 docker.elastic.co/elasticsearch/elasticsearch:5.6.2

Kuromojiプラグインをインストールして、再起動します。

## Kuromojiプラグインインストール
$ docker exec -it elasticsearch bash -c 'bin/elasticsearch-plugin install analysis-kuromoji'

## 再起動
$ docker restart elasticsearch

Japanese (kuromoji) Analysis Plugin

が、下準備的なものはまだまだ続きます。

テストコードの雛形とInfinispanの設定

最初に、テストコードの雛形を用意します。
src/test/scala/org/littlewings/infinispan/query/ElasticsearchQuerySpec.scala

package org.littlewings.infinispan.query

import org.infinispan.Cache
import org.infinispan.manager.DefaultCacheManager
import org.infinispan.query.Search
import org.scalatest.{FunSuite, Matchers}

class ElasticsearchQuerySpec extends FunSuite with Matchers {
  // ここに、テストを書く!

  protected def withCache[K, V](cacheName: String, numInstances: Int = 1)(fun: Cache[K, V] => Unit): Unit = {
    val managers = (1 to numInstances).map(_ => new DefaultCacheManager("infinispan.xml"))
    managers.foreach(_.getCache[K, V](cacheName))

    try {
      val cache = managers(0).getCache[K, V](cacheName)
      fun(cache)
      cache.stop()
    } finally {
      managers.foreach(_.stop())
    }
  }
}

テスト中にクラスタを構成できる、ヘルパーメソッド付き。

Infinispanの設定ファイルは、ベースはこのようにして用意。Cacheの定義については、また後で。
src/test/resources/infinispan.xml

<?xml version="1.0" encoding="UTF-8"?>
<infinispan
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="urn:infinispan:config:9.1 http://www.infinispan.org/schemas/infinispan-config-9.1.xsd"
        xmlns="urn:infinispan:config:9.1">
    <jgroups>
        <stack-file name="udp" path="default-configs/default-jgroups-udp.xml"/>
    </jgroups>
    <cache-container>
        <jmx duplicate-domains="true"/>
        <transport cluster="test-cluster" stack="udp"/>

        <!-- Cacheの定義はあとで -->
    </cache-container>
</infinispan>

Entity

Cacheに保存&インデキシングに利用する、Entityクラスを作成します。

えらく長くなりましたが、こんな感じで。
src/main/scala/org/littlewings/infinispan/query/IndexedBook.scala

package org.littlewings.infinispan.query

import org.hibernate.search.annotations._
import org.hibernate.search.elasticsearch.analyzer.{ElasticsearchTokenFilterFactory, ElasticsearchTokenizerFactory}

import scala.beans.BeanProperty

object IndexedBook {
  def apply(isbn: String, title: String, price: Int, category: String): IndexedBook = {
    val book = new IndexedBook
    book.isbn = isbn
    book.title = title
    book.price = price
    book.category = category
    book
  }
}

@Indexed(index = "book")
@AnalyzerDef(
  name = "kuromoji_analyzer",
  tokenizer = new TokenizerDef(
    factory = classOf[ElasticsearchTokenizerFactory],
    params = Array(new Parameter(name = "type", value = "kuromoji_tokenizer"))
  ),
  filters = Array(
    new TokenFilterDef(
      name = "kuromoji_baseform",
      factory = classOf[ElasticsearchTokenFilterFactory],
      params = Array(new Parameter(name = "type", value = "kuromoji_baseform"))
    ),
    new TokenFilterDef(
      name = "kuromoji_part_of_speech",
      factory = classOf[ElasticsearchTokenFilterFactory],
      params = Array(new Parameter(name = "type", value = "kuromoji_part_of_speech"))
    ),
    new TokenFilterDef(
      name = "cjk_width",
      factory = classOf[ElasticsearchTokenFilterFactory],
      params = Array(new Parameter(name = "type", value = "cjk_width"))
    ),
    new TokenFilterDef(
      name = "stop",
      factory = classOf[ElasticsearchTokenFilterFactory],
      params = Array(new Parameter(name = "type", value = "stop"))
    ),
    new TokenFilterDef(
      name = "ja_stop",
      factory = classOf[ElasticsearchTokenFilterFactory],
      params = Array(new Parameter(name = "type", value = "ja_stop"))
    ),
    new TokenFilterDef(
      name = "kuromoji_stemmer",
      factory = classOf[ElasticsearchTokenFilterFactory],
      params = Array(new Parameter(name = "type", value = "kuromoji_stemmer"))
    ),
    new TokenFilterDef(
      name = "lowercase",
      factory = classOf[ElasticsearchTokenFilterFactory],
      params = Array(new Parameter(name = "type", value = "lowercase"))
    )
  )
)
@Analyzer(definition = "kuromoji_analyzer")
@SerialVersionUID(1L)
class IndexedBook extends Serializable {
  @Field(analyze = Analyze.NO)
  @BeanProperty
  var isbn: String = _

  @Field
  @BeanProperty
  var title: String = _

  @Field
  @BeanProperty
  var price: Int = _

  @Field(analyze = Analyze.NO)
  @BeanProperty
  var category: String = _
}

これで、インデックスに対するAnalyzerの設定や、保存するドキュメントのAnalyzeの有無などを一気に設定します。

まず、@Indexedアノテーションでインデックス対象であることを指定しますが、この時にindex属性を指定することで、Elasticsearchのインデックス名と
することができます。

@Indexed(index = "book")

デフォルトでは、インデックス名はEntityクラスのFQCN(の小文字)となってしまってえらく長くなるので、indexで明示的に名前を指定するのが
無難でしょう。なお、typeについてはEntityクラスのFQCNとなってしまいます。

次に、@AnalyzerDefアノテーションでAnalyzerの定義(tokenizerとfilterの設定)を行います。

@AnalyzerDef(
  name = "kuromoji_analyzer",
  tokenizer = new TokenizerDef(
    factory = classOf[ElasticsearchTokenizerFactory],
    params = Array(new Parameter(name = "type", value = "kuromoji_tokenizer"))
  ),
  filters = Array(
    new TokenFilterDef(
      name = "kuromoji_baseform",
      factory = classOf[ElasticsearchTokenFilterFactory],
      params = Array(new Parameter(name = "type", value = "kuromoji_baseform"))
    ),
    new TokenFilterDef(
      name = "kuromoji_part_of_speech",
      factory = classOf[ElasticsearchTokenFilterFactory],
      params = Array(new Parameter(name = "type", value = "kuromoji_part_of_speech"))
    ),
    new TokenFilterDef(
      name = "cjk_width",
      factory = classOf[ElasticsearchTokenFilterFactory],
      params = Array(new Parameter(name = "type", value = "cjk_width"))
    ),
    new TokenFilterDef(
      name = "stop",
      factory = classOf[ElasticsearchTokenFilterFactory],
      params = Array(new Parameter(name = "type", value = "stop"))
    ),
    new TokenFilterDef(
      name = "ja_stop",
      factory = classOf[ElasticsearchTokenFilterFactory],
      params = Array(new Parameter(name = "type", value = "ja_stop"))
    ),
    new TokenFilterDef(
      name = "kuromoji_stemmer",
      factory = classOf[ElasticsearchTokenFilterFactory],
      params = Array(new Parameter(name = "type", value = "kuromoji_stemmer"))
    ),
    new TokenFilterDef(
      name = "lowercase",
      factory = classOf[ElasticsearchTokenFilterFactory],
      params = Array(new Parameter(name = "type", value = "lowercase"))
    )
  )
)

通常なら、Mappingの設定としてJSONでElasticsearchに放り込むものを、Javaアノテーションで書いているようなものです。

analyzer

kuromoji analyzer

で、この@AnalyzerDefのnameで指定した値を使って、このEntityのデフォルトのAnalyzerとして設定します。

@Analyzer(definition = "kuromoji_analyzer")

ここまでで、Entityの作成はおしまいです。

InfinispanのCacheの設定をする

このEntityを使用して、Elasticsearchに保存するためのInfinispanのCacheの設定を行います。

InfinispanのCacheの設定として、今回は次のように設定しました。Cacheの種類は、Distributed Cacheです。

        <distributed-cache name="indexedBookCache">
            <indexing index="LOCAL">
                <indexed-entities>
                    <indexed-entity>org.littlewings.infinispan.query.IndexedBook</indexed-entity>
                </indexed-entities>
                <property name="hibernate.search.default.indexmanager">elasticsearch</property>
                <property name="hibernate.search.default.elasticsearch.host">http://localhost:9200</property>

                <!-- for development -->
                <property name="hibernate.search.default.elasticsearch.index_schema_management_strategy">
                    drop-and-create
                </property>
                <property name="hibernate.search.default.elasticsearch.refresh_after_write">true</property>
                <property name="hibernate.search.default.elasticsearch.required_index_status">yellow</property>
            </indexing>
        </distributed-cache>

ここで、ドキュメントと照らし合わせつつ見ていってみます。

Elasticsearch IndexManager (experimental)

Elasticsearchを使う場合、indexのmodeは「LOCAL」とします。

            <indexing index="LOCAL">

IndexManagerは「elasticsearch」とし、Elasticsearchへの接続先(デフォルトは「http://127.0.0.1:9200」)を設定します。

                <property name="hibernate.search.default.indexmanager">elasticsearch</property>
                <property name="hibernate.search.default.elasticsearch.host">http://localhost:9200</property>

続いて、もう少し細かい設定を。

Hibernate Search configuration

テスト用として、インデックスは毎回drop/createすることにします。

                <!-- for development -->
                <property name="hibernate.search.default.elasticsearch.index_schema_management_strategy">
                    drop-and-create
                </property>

また、インデックスに登録したらすぐに検索可能になって欲しいので「hibernate.search.default.elasticsearch.refresh_after_write」をtrueとし(デフォルトfalse)、
Elasticsearchは今回ひとつのNodeしか用意しないので「hibernate.search.default.elasticsearch.required_index_status」を「yellow」としておきます。

                <property name="hibernate.search.default.elasticsearch.refresh_after_write">true</property>
                <property name="hibernate.search.default.elasticsearch.required_index_status">yellow</property>

Nodeがひとつしかない場合に、「hibernate.search.default.elasticsearch.required_index_status」をデフォルトの「green」としておくと、
Hibernate Searchが起動できなくなります。

あと(ハマったので)ログ出力も設定しておきましょう。

Logging executed requests

ログのカテゴリで、「org.hibernate.search.fulltext_query」を「DEBUG」にするとElasticsearchへのクエリが、「org.hibernate.search.elasticsearch.request」を
「DEBUG」または「TRACE」にするとElasticsearchとの通信をログ出力することができます。

というわけで、Logbackの設定ファイルを用意します。
src/test/resources/logback.xml

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <logger name="org.hibernate.search.fulltext_query" level="DEBUG" />
    <logger name="org.hibernate.search.elasticsearch.request" level="TRACE" />

    <root level="info">
        <appender-ref ref="STDOUT"/>
    </root>
</configuration>

つまり、このためだけにLogbackを使いました…。

だいたいここまでで、下準備的なところはおしまいです。

使ってみる

だいぶ準備が長くなりましたが、あとはInfinispanを使ってクエリを投げてみればOKです。

Cacheに登録するデータは、先ほど用意したテストの雛形コードにこのように用意。

  val indexedBooks: Array[IndexedBook] = Array(
    IndexedBook("978-4798142470", "Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発", 4320, "Spring"),
    IndexedBook("978-4774182179", "[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ", 4104, "Spring"),
    IndexedBook("978-4774161631", "[改訂新版] Apache Solr入門 ~オープンソース全文検索エンジン", 3888, "全文検索"),
    IndexedBook("978-4048662024", "高速スケーラブル検索エンジン ElasticSearch Server", 6915, "全文検索"),
    IndexedBook("978-4774183169", "パーフェクト Java EE", 3456, "Java EE"),
    IndexedBook("978-4798140926", "Java EE 7徹底入門 標準Javaフレームワークによる高信頼性Webシステムの構築", 4104, "Java EE")
  )
単純なクエリ

Ickle Queryを使って、ふつうに全文検索してみます。

  test("simple full-text query") {
    withCache[String, IndexedBook]("indexedBookCache", 3) { cache =>
      indexedBooks.foreach(b => cache.put(b.isbn, b))

      val queryFactory = Search.getQueryFactory(cache)
      val query =
        queryFactory.create(
          """|from org.littlewings.infinispan.query.IndexedBook b
             |where b.price < 5000
             |and b.title: '全文検索'""".stripMargin)

      val resultBooks = query.list[IndexedBook]()
      resultBooks should have size (1)
      resultBooks.get(0).getIsbn should be("978-4774161631")
      resultBooks.get(0).getTitle should be("[改訂新版] Apache Solr入門 ~オープンソース全文検索エンジン")
      resultBooks.get(0).getPrice should be(3888)
      resultBooks.get(0).getCategory should be("全文検索")
    }
  }

これで、特に問題なく動作します。素晴らしい…。

パラメーターのバインドとソート

パラメーターのバインドや、ソートについても大丈夫です。

  test("parameter-bind and sort") {
    withCache[String, IndexedBook]("indexedBookCache", 3) { cache =>
      indexedBooks.foreach(b => cache.put(b.isbn, b))

      val queryFactory = Search.getQueryFactory(cache)
      val query =
        queryFactory.create(
          """|from org.littlewings.infinispan.query.IndexedBook b
             |where b.price > :price
             |order by b.price desc, b.isbn desc'""".stripMargin)
      query.setParameter("price", 4000)

      val resultBooks = query.list[IndexedBook]()
      resultBooks should have size (4)
      resultBooks.get(0).getIsbn should be("978-4048662024")
      resultBooks.get(0).getTitle should be("高速スケーラブル検索エンジン ElasticSearch Server")
      resultBooks.get(0).getPrice should be(6915)
      resultBooks.get(0).getCategory should be("全文検索")

      resultBooks.get(1).getIsbn should be("978-4798142470")
      resultBooks.get(1).getTitle should be("Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発")
      resultBooks.get(1).getPrice should be(4320)
      resultBooks.get(1).getCategory should be("Spring")

      resultBooks.get(2).getIsbn should be("978-4798140926")
      resultBooks.get(2).getTitle should be("Java EE 7徹底入門 標準Javaフレームワークによる高信頼性Webシステムの構築")
      resultBooks.get(2).getPrice should be(4104)
      resultBooks.get(2).getCategory should be("Java EE")

      resultBooks.get(3).getIsbn should be("978-4774182179")
      resultBooks.get(3).getTitle should be("[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ")
      resultBooks.get(3).getPrice should be(4104)
      resultBooks.get(3).getCategory should be("Spring")
    }
  }
Aggregation

Group Byなどの集合演算も、問題なく。

  test("aggregation") {
    withCache[String, IndexedBook]("indexedBookCache", 3) { cache =>
      indexedBooks.foreach(b => cache.put(b.isbn, b))

      val queryFactory = Search.getQueryFactory(cache)
      val query =
        queryFactory.create(
          """|select b.category, sum(b.price)
             |from org.littlewings.infinispan.query.IndexedBook b
             |where b.title: (+'入門' and -'検索')
             |group by b.category
             |order by sum(b.price) desc""".stripMargin)

      val results = query.list[Array[AnyRef]]()
      results should have size (2)
      results.get(0)(0) should be("Spring")
      results.get(0)(1) should be(8424)
      results.get(1)(0) should be("Java EE")
      results.get(1)(1) should be(4104)
    }
  }
再インデキシング

Mass Indexerを使った、再インデキシング。

  test("re-indexing") {
    withCache[String, IndexedBook]("indexedBookCache", 3) { cache =>
      indexedBooks.foreach(b => cache.put(b.isbn, b))

      val queryFactory = Search.getQueryFactory(cache)
      val query1 =
        queryFactory.create(
          """|from org.littlewings.infinispan.query.IndexedBook b
             |where b.price < 5000
             |and b.title: '全文検索'""".stripMargin)

      val resultBooks1 = query1.list[IndexedBook]()
      resultBooks1 should have size (1)
      resultBooks1.get(0).getIsbn should be("978-4774161631")

      val massIndexer = Search.getSearchManager(cache).getMassIndexer
      massIndexer.start()

      val query2 =
        queryFactory.create(
          """|from org.littlewings.infinispan.query.IndexedBook b
             |where b.price < 5000
             |and b.title: '全文検索'""".stripMargin)

      val resultBooks2 = query2.list[IndexedBook]()
      resultBooks2 should have size (1)
      resultBooks2.get(0).getIsbn should be("978-4774161631")
    }
  }
Hibernate SearchのNative API

オマケで、Hibernate SearchのNativeなAPIを使って、クエリを構築した場合。

  test("Hibernate Search, native query") {
    withCache[String, IndexedBook]("indexedBookCache", 3) { cache =>
      indexedBooks.foreach(b => cache.put(b.isbn, b))

      val searchManager = Search.getSearchManager(cache)
      val queryBuilder = searchManager.buildQueryBuilderForClass(classOf[IndexedBook]).get
      val query =
        queryBuilder
          .bool
          .must(queryBuilder.keyword.onField("title").matching("全件検索").createQuery)
          .must(queryBuilder.range.onField("price").below(5000).createQuery)
          .createQuery
      val cacheQuery = searchManager.getQuery[IndexedBook](query, classOf[IndexedBook])

      val resultBooks = cacheQuery.list()
      resultBooks should have size (1)
      resultBooks.get(0).getIsbn should be("978-4774161631")
      resultBooks.get(0).getTitle should be("[改訂新版] Apache Solr入門 ~オープンソース全文検索エンジン")
      resultBooks.get(0).getPrice should be(3888)
      resultBooks.get(0).getCategory should be("全文検索")
    }
  }

良さそうですね。

ログ確認

最初、うまく動かせなくてけっこうハマりました。

この時に、Logbackの設定をしたのを活用して、Hibernate SearchとElasticsearchのやり取りをログ確認していました。

    <logger name="org.hibernate.search.fulltext_query" level="DEBUG" />
    <logger name="org.hibernate.search.elasticsearch.request" level="TRACE" />

例えば、マッピングの定義や

18 TRACE o.h.search.elasticsearch.request - HSEARCH400093: Executed Elasticsearch HTTP PUT request to path '/book/' with query parameters {} in 721ms. Response had status 200 'OK'. Request body: <{"analysis":{"analyzer":{"kuromoji_analyzer":{"tokenizer":"kuromoji_tokenizer","filter":["kuromoji_baseform","kuromoji_part_of_speech","cjk_width","stop","ja_stop","kuromoji_stemmer","lowercase"]}},"filter":{"cjk_width":{"type":"cjk_width"},"ja_stop":{"type":"ja_stop"},"kuromoji_baseform":{"type":"kuromoji_baseform"},"kuromoji_part_of_speech":{"type":"kuromoji_part_of_speech"},"kuromoji_stemmer":{"type":"kuromoji_stemmer"},"lowercase":{"type":"lowercase"},"stop":{"type":"stop"}}}}>. Response body: <{"acknowledged":true,"shards_acknowledged":true,"index":"book"}>
22:51:42.630 TRACE o.h.search.elasticsearch.request - HSEARCH400093: Executed Elasticsearch HTTP GET request to path '/_cluster/health/book/' with query parameters {wait_for_status=yellow, timeout=10000ms} in 5ms. Response had status 200 'OK'. Request body: <>. Response body: <{"cluster_name":"docker-cluster","status":"yellow","timed_out":false,"number_of_nodes":1,"number_of_data_nodes":1,"active_primary_shards":5,"active_shards":5,"relocating_shards":0,"initializing_shards":0,"unassigned_shards":5,"delayed_unassigned_shards":0,"number_of_pending_tasks":0,"number_of_in_flight_fetch":0,"task_max_waiting_in_queue_millis":0,"active_shards_percent_as_number":50.0}>
22:51:42.672 TRACE o.h.search.elasticsearch.request - HSEARCH400093: Executed Elasticsearch HTTP PUT request to path '/book/org.littlewings.infinispan.query.IndexedBook/_mapping/' with query parameters {} in 27ms. Response had status 200 'OK'. Request body: <{"properties":{"category":{"type":"keyword","boost":1.0,"index":true,"norms":true,"store":false},"isbn":{"type":"keyword","boost":1.0,"index":true,"norms":true,"store":false},"price":{"type":"integer","boost":1.0,"index":true,"store":false},"providedId":{"type":"keyword","boost":1.0,"index":true,"norms":false,"store":true},"title":{"type":"text","boost":1.0,"index":true,"norms":true,"store":false,"analyzer":"kuromoji_analyzer"}},"dynamic":"strict"}>. Response body: <{"acknowledged":true}>

データの登録&リフレッシュ、

22:51:46.585 TRACE o.h.search.elasticsearch.request - HSEARCH400093: Executed Elasticsearch HTTP PUT request to path '/book/org.littlewings.infinispan.query.IndexedBook/S%3A978-4798142470/' with query parameters {} in 79ms. Response had status 201 'Created'. Request body: <{"providedId":"S:978-4798142470","isbn":"978-4798142470","title":"Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発","price":4320,"category":"Spring"}>. Response body: <{"_index":"book","_type":"org.littlewings.infinispan.query.IndexedBook","_id":"S:978-4798142470","_version":1,"result":"created","_shards":{"total":2,"successful":1,"failed":0},"created":true}>
22:51:46.662 TRACE o.h.search.elasticsearch.request - HSEARCH400093: Executed Elasticsearch HTTP POST request to path '/book/_refresh/' with query parameters {} in 62ms. Response had status 200 'OK'. Request body: <>. Response body: <{"_shards":{"total":10,"successful":5,"failed":0}}>
22:51:46.814 TRACE o.h.search.elasticsearch.request - HSEARCH400093: Executed Elasticsearch HTTP PUT request to path '/book/org.littlewings.infinispan.query.IndexedBook/S%3A978-4774182179/' with query parameters {} in 136ms. Response had status 201 'Created'. Request body: <{"providedId":"S:978-4774182179","isbn":"978-4774182179","title":"[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ","price":4104,"category":"Spring"}>. Response body: <{"_index":"book","_type":"org.littlewings.infinispan.query.IndexedBook","_id":"S:978-4774182179","_version":1,"result":"created","_shards":{"total":2,"successful":1,"failed":0},"created":true}>
22:51:46.853 TRACE o.h.search.elasticsearch.request - HSEARCH400093: Executed Elasticsearch HTTP POST request to path '/book/_refresh/' with query parameters {} in 38ms. Response had status 200 'OK'. Request body: <>. Response body: <{"_shards":{"total":10,"successful":5,"failed":0}}>
22:51:46.896 TRACE o.h.search.elasticsearch.request - HSEARCH400093: Executed Elasticsearch HTTP PUT request to path '/book/org.littlewings.infinispan.query.IndexedBook/S%3A978-4774161631/' with query parameters {} in 20ms. Response had status 201 'Created'. Request body: <{"providedId":"S:978-4774161631","isbn":"978-4774161631","title":"[改訂新版] Apache Solr入門 ~オープンソース全文検索エンジン","price":3888,"category":"全文検索"}>. Response body: <{"_index":"book","_type":"org.littlewings.infinispan.query.IndexedBook","_id":"S:978-4774161631","_version":1,"result":"created","_shards":{"total":2,"successful":1,"failed":0},"created":true}>
22:51:46.921 TRACE o.h.search.elasticsearch.request - HSEARCH400093: Executed Elasticsearch HTTP POST request to path '/book/_refresh/' with query parameters {} in 24ms. Response had status 200 'OK'. Request body: <>. Response body: <{"_shards":{"total":10,"successful":5,"failed":0}}>
22:51:46.945 TRACE o.h.search.elasticsearch.request - HSEARCH400093: Executed Elasticsearch HTTP PUT request to path '/book/org.littlewings.infinispan.query.IndexedBook/S%3A978-4048662024/' with query parameters {} in 8ms. Response had status 201 'Created'. Request body: <{"providedId":"S:978-4048662024","isbn":"978-4048662024","title":"高速スケーラブル検索エンジン ElasticSearch Server","price":6915,"category":"全文検索"}>. Response body: <{"_index":"book","_type":"org.littlewings.infinispan.query.IndexedBook","_id":"S:978-4048662024","_version":1,"result":"created","_shards":{"total":2,"successful":1,"failed":0},"created":true}>
22:51:46.982 TRACE o.h.search.elasticsearch.request - HSEARCH400093: Executed Elasticsearch HTTP POST request to path '/book/_refresh/' with query parameters {} in 34ms. Response had status 200 'OK'. Request body: <>. Response body: <{"_shards":{"total":10,"successful":5,"failed":0}}>
22:51:47.214 TRACE o.h.search.elasticsearch.request - HSEARCH400093: Executed Elasticsearch HTTP PUT request to path '/book/org.littlewings.infinispan.query.IndexedBook/S%3A978-4774183169/' with query parameters {} in 210ms. Response had status 201 'Created'. Request body: <{"providedId":"S:978-4774183169","isbn":"978-4774183169","title":"パーフェクト Java EE","price":3456,"category":"Java EE"}>. Response body: <{"_index":"book","_type":"org.littlewings.infinispan.query.IndexedBook","_id":"S:978-4774183169","_version":1,"result":"created","_shards":{"total":2,"successful":1,"failed":0},"created":true}>
22:51:47.229 TRACE o.h.search.elasticsearch.request - HSEARCH400093: Executed Elasticsearch HTTP POST request to path '/book/_refresh/' with query parameters {} in 14ms. Response had status 200 'OK'. Request body: <>. Response body: <{"_shards":{"total":10,"successful":5,"failed":0}}>
22:51:47.258 TRACE o.h.search.elasticsearch.request - HSEARCH400093: Executed Elasticsearch HTTP PUT request to path '/book/org.littlewings.infinispan.query.IndexedBook/S%3A978-4798140926/' with query parameters {} in 12ms. Response had status 201 'Created'. Request body: <{"providedId":"S:978-4798140926","isbn":"978-4798140926","title":"Java EE 7徹底入門 標準Javaフレームワークによる高信頼性Webシステムの構築","price":4104,"category":"Java EE"}>. Response body: <{"_index":"book","_type":"org.littlewings.infinispan.query.IndexedBook","_id":"S:978-4798140926","_version":1,"result":"created","_shards":{"total":2,"successful":1,"failed":0},"created":true}>
22:51:47.284 TRACE o.h.search.elasticsearch.request - HSEARCH400093: Executed Elasticsearch HTTP POST request to path '/book/_refresh/' with query parameters {} in 25ms. Response had status 200 'OK'. Request body: <>. Response body: <{"_shards":{"total":10,"successful":5,"failed":0}}>

クエリの様子などを確認することができます。

22:51:47.535 DEBUG org.hibernate.search.fulltext_query - HSEARCH400053: Executing Elasticsearch query on '/book/_search/' with parameters '{from=0, size=10000}': <{"query":{"bool":{"must":{"bool":{"must":[{"range":{"price":{"lt":5000}}},{"match":{"title":{"query":"全文検索"}}}]}},"filter":{"type":{"value":"org.littlewings.infinispan.query.IndexedBook"}}}},"_source":["providedId"]}>
22:51:47.554 TRACE o.h.search.elasticsearch.request - HSEARCH400093: Executed Elasticsearch HTTP POST request to path '/book/_search/' with query parameters {from=0, size=10000} in 16ms. Response had status 200 'OK'. Request body: <{"query":{"bool":{"must":{"bool":{"must":[{"range":{"price":{"lt":5000}}},{"match":{"title":{"query":"全文検索"}}}]}},"filter":{"type":{"value":"org.littlewings.infinispan.query.IndexedBook"}}}},"_source":["providedId"]}>. Response body: <{"took":12,"timed_out":false,"_shards":{"total":5,"successful":5,"skipped":0,"failed":0},"hits":{"total":1,"max_score":1.5697701,"hits":[{"_index":"book","_type":"org.littlewings.infinispan.query.IndexedBook","_id":"S:978-4774161631","_score":1.5697701,"_source":{"providedId":"S:978-4774161631"}}]}}>

もうちょっと中身を

で、ここからはもう少し中身を追ってみましょう。

まず、InfinispanでHibernate Search越しにElasticsearchと連携できるようになったところから。

Hibernate Searchのクエリは、Apache LuceneのQueryをHSQueryという形式にラップして保持します。ここで、対応するIndexManagerを取得する時に、
IndexManagerがDirectoryBasedIndexManagerのサブクラスである必要がありました。
https://github.com/hibernate/hibernate-search/blob/5.8.0.Final/engine/src/main/java/org/hibernate/search/query/engine/impl/LuceneHSQuery.java#L297

ところが、ElasticsearchIndexManagerはIndexManagerの実装ではありますが、DirectoryBasedIndexManagerを継承してはいません。
https://github.com/hibernate/hibernate-search/blob/5.8.0.Final/elasticsearch/src/main/java/org/hibernate/search/elasticsearch/impl/ElasticsearchIndexManager.java#L64

また、期待されているメソッドがサポート外だったり
https://github.com/hibernate/hibernate-search/blob/5.8.0.Final/elasticsearch/src/main/java/org/hibernate/search/elasticsearch/impl/ElasticsearchIndexManager.java#L400

Elasticsearch用のHSQueryの実装がApache LuceneのQueryへの変換もサポートしていなかったりと、どうにも動かせない感じになっていました。
https://github.com/hibernate/hibernate-search/blob/5.8.0.Final/elasticsearch/src/main/java/org/hibernate/search/elasticsearch/query/impl/ElasticsearchHSQueryImpl.java#L134

これらが、以前のInfinispan Query API越しにHibernate SearchとElasticsearchをどうにもうまくつなぎ合わせることができなかった理由でした。

で、今はどういう挙動をしているかというと、間に仲介するQueryが入ってQueryを変換する層が入り込みました。
https://github.com/infinispan/infinispan/blob/9.1.1.Final/query/src/main/java/org/infinispan/query/dsl/embedded/impl/DelegatingQuery.java

その後で、HSQueryを構築するのをSearchIntegratorを介して行う時に
https://github.com/infinispan/infinispan/blob/9.1.1.Final/query/src/main/java/org/infinispan/query/impl/CacheQueryImpl.java#L56

Apache LuceneのQueryを変換するTranslatorを探してきて、
https://github.com/hibernate/hibernate-search/blob/5.8.0.Final/engine/src/main/java/org/hibernate/search/engine/impl/ImmutableSearchFactory.java#L305
https://github.com/hibernate/hibernate-search/blob/5.8.0.Final/elasticsearch/src/main/java/org/hibernate/search/elasticsearch/impl/ElasticsearchLuceneQueryTranslator.java

QueryDescriptorを使ってHSQueryを構築するようになっています。
https://github.com/hibernate/hibernate-search/blob/5.8.0.Final/elasticsearch/src/main/java/org/hibernate/search/elasticsearch/impl/ElasticsearchJsonQueryDescriptor.java#L39

この変換は元々そういう実装だったりするのですが、IndexManagerとのつなぎ込みが足かせになっていて、うまく連携できていませんでした。
DelegatingQueryが入るなどして、変換処理が追加されたことなどによって、つながった感じになったみたいです。

外観のAPIはそう変わらないのですが、前に動かなかった機能が動かせると嬉しいですね〜。

ところで、ソートやAggregationは実はInfinispan側の自前実装だったりします。
https://github.com/infinispan/infinispan/blob/9.1.1.Final/query/src/main/java/org/infinispan/query/dsl/embedded/impl/BaseEmbeddedQuery.java#L62

ということは…ないとは思いますが、あんまり大きなヒット件数のデータを持ってきたりすると厳しい結果になりそうですね…。

また、Mass Indexerを使った時の再インデキシングというのは、単にEntityをバルクで放り込んでいるだけだったりします。
https://github.com/hibernate/hibernate-search/blob/5.8.0.Final/elasticsearch/src/main/java/org/hibernate/search/elasticsearch/processor/impl/DefaultElasticsearchWorkBulker.java#L20

なにか再構築しているとか、そういうわけではなさそうです。

まとめ

InfinispanのQuery(Hibernate Search)のバックエンドに、Elasticsearchを使用して検索を行ってみました。

Infinispan → Hibernate Search → Elasticsearchとちょっと多段になっていてとっつきにくいところはありますが、理屈がわかればまあなんとかなる…のかな?
あとは、Hibernate Search 5.8でどうElasticsearchと連携しているのかはちゃんと確認した方がよいかもですねぇ。

今回作成したソースコードは、こちらに置いています。
https://github.com/kazuhira-r/infinispan-getting-started/tree/master/embedded-query-hibernate-search-elasticsearch

参考)
Elasticsearch の Docker イメージを試してみる | Tagbangers Blog

Hibernate Search + Elasticsearch を試してみる (Docker 版) | Tagbangers Blog