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のアノテーションで書いているようなものです。
で、この@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が起動できなくなります。
あと(ハマったので)ログ出力も設定しておきましょう。
ログのカテゴリで、「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