CLOVER🍀

That was when it all began.

Infinispan Queryモジュールを使う時の、設定を確認する

だいぶ前に、InfinispanのQueryモジュールを使ってなんとなくの検索を行ったことがありますが(Query DSLではありません)、その時は「とりあえず動かしてみました」的な感じで流していたので、ちょっとマジメに設定してみたいと思います。

参照するのは、このあたりですね。

Querying Infinispan
http://infinispan.org/docs/6.0.x/user_guide/user_guide.html#_querying_infinispan

Infinispan as a storage for Lucene indexes
http://infinispan.org/docs/6.0.x/user_guide/user_guide.html#_infinispan_as_a_storage_for_lucene_indexes

Infinispan Directory configuration
http://docs.jboss.org/hibernate/search/4.4/reference/en-US/html_single/#infinispan-directories

1番最後のは、Hibernate Searchのドキュメントですよ。

やりたい設定は2つ。

  • Analyzerの設定
  • Lucene DirectoryをHibernate Search越しに使う時の、InfinispanのCacheの設定

これを目標に、頑張ってみます。

準備

依存関係などの定義。
build.sbt

name := "infinispan-query-config-analyzerdef"

version := "0.0.1-SNAPSHOT"

scalaVersion := "2.10.3"

organization := "org.littlewings"

scalacOptions ++= Seq("-deprecation")

//javaOptions in Test += "-javaagent:/usr/local/byteman/current/lib/byteman.jar=script:rule.btm"

fork in Test := true

libraryDependencies ++= Seq(
  "org.infinispan" % "infinispan-query" % "6.0.1.Final"  excludeAll(
    ExclusionRule(organization = "org.jgroups", name = "jgroups"),
    ExclusionRule(organization = "org.jboss.marshalling", name = "jboss-marshalling-river"),
    ExclusionRule(organization = "org.jboss.marshalling", name = "jboss-marshalling"),
    ExclusionRule(organization = "org.jboss.logging", name = "jboss-logging"),
    ExclusionRule(organization = "org.jboss.spec.javax.transaction", name = "jboss-transaction-api_1.1_spec")
  ),
  "org.jgroups" % "jgroups" % "3.4.1.Final",
  "org.jboss.spec.javax.transaction" % "jboss-transaction-api_1.1_spec" % "1.0.1.Final",
  "org.jboss.marshalling" % "jboss-marshalling-river" % "1.3.18.GA",
  "org.jboss.marshalling" % "jboss-marshalling" % "1.3.18.GA",
  "org.jboss.logging" % "jboss-logging" % "3.1.2.GA",
  "net.jcip" % "jcip-annotations" % "1.0",
  "org.scalatest" %% "scalatest" % "2.0" % "test"
)

Infinispan 6.0.1.Finalがリリースされましたね!でも、再びsbtで依存関係の解決が不可能に…いったい、どうなっているんでしょう?

Bytemanの設定が入っているのは、苦労の跡なのですが、そのまま残しておきます。

JGroupの設定は端折って、次にいきます。

Analyzerを設定する

Hibernate Searchで、Entityのフィールドなどに対してAnalyzerを設定する方法は、いくつかあるようです。

Analyzer
http://docs.jboss.org/hibernate/search/4.4/reference/en-US/html_single/#d0e447

Default analyzer and analyzer by class
http://docs.jboss.org/hibernate/search/4.4/reference/en-US/html_single/#analyzer

@AnalyzerDefアノテーションを使用する、@Analyzerアノテーションを使用する、@Fieldアノテーションのanalyzer属性に@Analyzerアノテーションを使用する、など。

Scalaを使った場合、アノテーションの引数にアノテーションが指定できなさそうなので、@Analyzerアノテーションを使う方法一択になりました。

Kuromojiを使うとすると、こんな感じです。ターゲットは書籍。
src/main/scala/org/littlewings/infinispan/query/entity/Book.scala

package org.littlewings.infinispan.query.entity

import java.util.{Date, Objects}

import org.hibernate.search.annotations.{Analyze, Analyzer, Field, DateBridge, Indexed}
import org.hibernate.search.annotations.{Resolution, Store}

import org.apache.lucene.analysis.ja.JapaneseAnalyzer

object Book {
  def apply(isbn: String,
            title: String,
            summary: String,
            price: Int,
            publisherDate: Date): Book = {
    val book = new Book
    book.isbn = isbn
    book.title = title
    book.summary = summary
    book.price = price
    book.publisherDate = publisherDate
    book
  }
}

@Indexed
@SerialVersionUID(1L)
class Book extends Serializable {
  @Field(analyze = Analyze.NO)
  var isbn: String = _

  @Field
  @Analyzer(impl = classOf[JapaneseAnalyzer])
  var title: String = _

  @Field
  @Analyzer(impl = classOf[JapaneseAnalyzer])
  var summary: String = _

  @Field(analyze = Analyze.NO)
  var price: Int = _

  @Field(analyze = Analyze.NO)
  @DateBridge(resolution = Resolution.DAY)
  var publisherDate: Date = _

  override def equals(other: Any): Boolean = other match {
    case ob: Book => isbn == ob.isbn && title == ob.title && summary == ob.summary &&
      price == ob.price && publisherDate == ob.publisherDate
    case _ => false
  }

  override def hashCode: Int =
    Objects.hash(isbn, title, summary, Integer.valueOf(price), publisherDate)

  override def toString: String =
    s"""Book[isbn = $isbn,
       |     title = $title,
       |     summary = $summary,
       |     price = $price,
       |     publisherDate = $publisherDate""".stripMargin
}

クラス全体に@Analyzerアノテーションを付与して、デフォルトのAnalyzerとして宣言することもできるみたいです。今回は、書籍のタイトルと概要を形態素解析します。

ちなみに、Hibernate Searchが使っているLuceneバージョンは、3.6.2となりますのでご注意ください。Lucene 4系への対応は、Hibernate Search 5で実現する…予定っぽいですが。

設定。
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:6.0 http://www.infinispan.org/schemas/infinispan-config-6.0.xsd"
    xmlns="urn:infinispan:config:6.0">

  <global>
    <transport clusterName="query-cluster">
      <properties>
        <property name="configurationFile" value="jgroups.xml" />
      </properties>
    </transport>

    <globalJmxStatistics
        enabled="true"
        jmxDomain="org.infinispan"
        cacheManagerName="DefaultCacheManager"
        allowDuplicateDomains="true"
        />

    <shutdown hookBehavior="REGISTER"/>
  </global>

  <namedCache name="bookCache">
    <clustering mode="dist" />

    <indexing enabled="true" indexLocalOnly="true">
      <properties>
        <property name="default.directory_provider" value="infinispan" />
        <property name="lucene_version" value="LUCENE_CURRENT" />
      </properties>
    </indexing>
  </namedCache>

</infinispan>

とりあえず、なぜかクラスタリングを有効にしたインデキシング可能なCacheを、「bookCache」という名前で用意します。

あとは、これに対してEntityと設定ファイルを使うテストコードを書きましょう。
src/test/scala/org/littlewings/infinispan/query/InfinispanQuerySpec.scala

package org.littlewings.infinispan.query

import scala.collection.JavaConverters._

import java.text.SimpleDateFormat

import org.infinispan.Cache
import org.infinispan.manager.DefaultCacheManager
import org.infinispan.query.Search

import org.scalatest.FunSpec
import org.scalatest.Matchers._

import org.littlewings.infinispan.query.entity.Book

class InfinispanQuerySpec extends FunSpec {
  val toDate = (dateString: String) => new SimpleDateFormat("yyyy/MM/dd").parse(dateString)

  val luceneBook: Book =
    Book("978-4774127804",
         "Apache Lucene 入門 〜Java・オープンソース・全文検索システムの構築",
         "Luceneは全文検索システムを構築するためのJavaのライブラリです。",
         3360,
         toDate("2006/05/17"))

  val solrBook: Book =
    Book("978-4774161631",
         "[改訂新版] Apache Solr入門 オープンソース全文検索エンジン",
         "最新版Apaceh Solr Ver.4.5.1に対応するため大幅な書き直しと原稿の追加を行い、現在の開発環境に合わせて完全にアップデートしました。Apache Solrは多様なプログラミング言語に対応した全文検索エンジンです。",
         3780,
         toDate("2013/11/29"))

  val collectiveIntelligenceInActionBook: Book =
    Book("978-4797352009",
         "集合知イン・アクション",
         "レコメンデーションエンジンをつくるには?ブログやSNSのテキスト分析、ユーザー嗜好の予測モデル、レコメンデーションエンジン……Web 2.0の鍵「集合知」をJavaで実装しよう!",
         3990,
         toDate("2009/03/27"))

  val books: Array[Book] = Array(luceneBook, solrBook, collectiveIntelligenceInActionBook)

  describe("infinispan query spec") {
    // ここにテストを書く!!
  }

  def withCache(fun: Cache[String, Book] => Unit): Unit = {
    val manager = new DefaultCacheManager("infinispan.xml")

    try {
      val cache = manager.getCache[String, Book]("bookCache")
      try {
        fun(cache)
      } finally {
        cache.stop()
      }
    } finally {
      manager.stop()
    }
  }
}

withCacheメソッドで、Cacheを取得して引数の関数に渡して実行して終了、そんな感じの使い方をしてテストを書きます。

ちなみに、Luceneのインデックス保存先をInfinispanにした場合、ちゃんとCache#stopすべきだというのが今回の教訓となりました。まあ、普段からちゃんとやろうよという話ですが…。

今回は、せっかくなのでHibernate SearchのDSLを使ってQueryを組み立ててみます。

    it("keyword query") {
      withCache { cache =>
        books.foreach(book => cache.put(book.isbn, book))

        val searchManager = Search.getSearchManager(cache)
        val queryBuilder = searchManager.buildQueryBuilderForClass(classOf[Book]).get

        val luceneQuery =
          queryBuilder
            .keyword
            .onField("title")
            .andField("summary")
            .matching("オープンソース 全文検索システムの構築")
            .createQuery

        luceneQuery.toString should be ("(title:オープン title:ソース title:全文 title:検索 title:システム title:構築) (summary:オープン summary:ソース summary:全文 summary:検索 summary:システム summary:構築)")

        val query = searchManager.getQuery(luceneQuery, classOf[Book])

        val result = query.list

        result should have size 2
        result.get(0) should be (luceneBook)
        result.get(1) should be (solrBook)
      }
    }

    it("phrase query") {
      withCache { cache =>
        books.foreach(book => cache.put(book.isbn, book))

        val searchManager = Search.getSearchManager(cache)
        val queryBuilder = searchManager.buildQueryBuilderForClass(classOf[Book]).get

        val luceneQuery =
          queryBuilder
            .phrase
            .onField("title")
            .andField("summary")
            .sentence("オープンソース 全文検索システムの構築")
            .createQuery

        luceneQuery.toString should be ("title:\"オープン ソース 全文 検索 システム ? 構築\" summary:\"オープン ソース 全文 検索 システム ? 構築\"")

        val query = searchManager.getQuery(luceneQuery, classOf[Book])

        val result = query.list

        result should have size 1
        result.get(0) should be (luceneBook)
      }
    }

    it("range query") {
      withCache { cache =>
        books.foreach(book => cache.put(book.isbn, book))

        val searchManager = Search.getSearchManager(cache)
        val queryBuilder = searchManager.buildQueryBuilderForClass(classOf[Book]).get

        val luceneQuery =
          queryBuilder
            .range
            .onField("price")
            .from(3500)
            .to(4000)
            .createQuery

        luceneQuery.toString should be ("price:[3500 TO 4000]")

        val query = searchManager.getQuery(luceneQuery, classOf[Book])

        val result = query.list

        result should have size 2
        result.get(0) should be (solrBook)
        result.get(1) should be (collectiveIntelligenceInActionBook)
      }
    }

    it("bool query") {
      withCache { cache =>
        books.foreach(book => cache.put(book.isbn, book))

        val searchManager = Search.getSearchManager(cache)
        val queryBuilder = searchManager.buildQueryBuilderForClass(classOf[Book]).get

        val luceneQuery =
          queryBuilder
            .bool
              .should {
                queryBuilder
                  .keyword
                  .onField("title")
                  .matching("全文検索")
                  .createQuery
              }.should {
                queryBuilder
                  .keyword
                  .onField("summary")
                  .matching("java")
                  .createQuery
              }.createQuery

        luceneQuery.toString should be ("(title:全文 title:検索) summary:java")

        val query = searchManager.getQuery(luceneQuery, classOf[Book])

        val result = query.list

        result should have size 3
        result.get(0) should be (luceneBook)
        result.get(1) should be (solrBook)
        result.get(2) should be (collectiveIntelligenceInActionBook)
      }
    }

LuceneのQueryをtoStringして確認しているというのがちょっと難点ですが、一応形態素解析できているのが確認できます(Query DSLの時は、そもそもアナライズしない設定にするんでしたね)。

Hibernate SearchのDSLで、Entityに定義したAnalyzerをQueryで使えているというのは良いと思います。

まあ、仮にLuceneのQueryParserを使いたいといった場合でも、PerFieldAnalyzerWrapperを使ってEntityの@Analyzerアノテーションと、QueryParserで使うAnalyzerをうまく合わせるようにすればいいかなと思いますが。

Cacheの設定をする

続いて、もうひとつの目標であるHibernate Searchの使う、Cache(InfinispanのLuceneのDirectoryの実装)を設定してみましょう。

先ほどInfinispanの設定を書きましたが、これの裏にはこういう設定が隠れているみたいです。

  <namedCache name="bookCache">
    <clustering mode="dist" />

    <indexing enabled="true" indexLocalOnly="true">
      <properties>
        <property name="default.directory_provider" value="infinispan" />
        <property name="lucene_version" value="LUCENE_CURRENT" />

        <!-- デフォルトでは、各Cacheの名前とチャンクサイズはこのような形
        <property name="default.locking_cachename" value="LuceneIndexesLocking" />
        <property name="default.data_cachename" value="LuceneIndexesData" />
        <property name="default.metadata_cachename" value="LuceneIndexesMetadata" />
        <property name="default.chunk_size" value="16384" />
        -->
      </properties>
    </indexing>
  </namedCache>

コメントアウトしている部分が、本来InfinispanのLuceneのDirectoryとして必要な、3つのCacheの名前のデフォルト値を指しています。

  • metadataCache
  • chunksCache
  • distLocksCache

それぞれのCacheがどういう役割なのかは、ドキュメントを参照してください。あと、前にここでも少し書きました。

Infinispan as a storage for Lucene
http://d.hatena.ne.jp/Kazuhira/20130519/1368965554

また、インデックスの名前ですが、Entity(というかCacheの値)のクラス名が使われているっぽいです。今回の場合は、

org.littlewings.infinispan.query.entity.Book

がインデックスの名前です。

で、これらの項目に付いている「default」というのは

        <property name="default.directory_provider" value="infinispan" />
        <property name="default.locking_cachename" value="LuceneIndexesLocking" />
        <property name="default.data_cachename" value="LuceneIndexesData" />
        <property name="default.metadata_cachename" value="LuceneIndexesMetadata" />
        <property name="default.chunk_size" value="16384" />

文字通りデフォルト的な指定なのですが(これも有効な値です)、Hibernate Searchのドキュメント上だと

hibernate.search.[default|].directory_provider = infinispan

と書かれているため、今回のEntityの例だと

        <property name="org.littlewings.infinispan.query.entity.Book.directory_provider" value="infinispan" />

という指定が、インデックス名を意識した正確な指定だということです。
*「hibernate.search」の部分はなくてもいいみたい…

この「インデックス名」もしくは「default」が必要というのは、directory_providerについてはドキュメントに書いてあったのですぐにわかりましたが、他の項目にも必要だというのが最初わからずにBytemanで挙動を見ていてやっと理解しました…。

では、デフォルトのCacheの名前から、ちょっと変えてみましょう。

  <namedCache name="bookCache">
    <clustering mode="dist" />

    <indexing enabled="true" indexLocalOnly="true">
      <properties>
        <property name="default.directory_provider" value="infinispan" />
        <property name="lucene_version" value="LUCENE_CURRENT" />

        <property name="default.locking_cachename" value="My-LuceneIndexesLocking" />
        <property name="default.data_cachename" value="My-LuceneIndexesData" />
        <property name="default.metadata_cachename" value="My-LuceneIndexesMetadata" />

        <!-- デフォルトでは、各Cacheの名前とチャンクサイズはこのような形
        <property name="default.locking_cachename" value="LuceneIndexesLocking" />
        <property name="default.data_cachename" value="LuceneIndexesData" />
        <property name="default.metadata_cachename" value="LuceneIndexesMetadata" />
        <property name="default.chunk_size" value="16384" />
        -->
      </properties>
    </indexing>
  </namedCache>

具体的なCacheの定義は、このようにしてみます。

  <!-- Lucene用のCacheの設定 -->
  <namedCache name="My-LuceneIndexesLocking">
    <clustering mode="repl">
      <stateTransfer
          fetchInMemoryState="true" />
      <sync
          replTimeout="25000" />
    </clustering>

    <locking
        lockAcquisitionTimeout="20000"
        writeSkewCheck="false"
        concurrencyLevel="500"
        useLockStriping="false" />

    <invocationBatching
        enabled="false" />

    <eviction
        maxEntries="-1"
        strategy="NONE" />

    <expiration
        maxIdle="-1" />
  </namedCache>

  <namedCache name="My-LuceneIndexesData">
    <clustering mode="dist">
      <stateTransfer
          fetchInMemoryState="true" />
      <sync
          replTimeout="25000" />
    </clustering>

    <locking
        lockAcquisitionTimeout="20000"
        writeSkewCheck="false"
        concurrencyLevel="500"
        useLockStriping="false" />

    <invocationBatching
        enabled="false" />

    <eviction
        maxEntries="-1"
        strategy="NONE" />

    <expiration
        maxIdle="-1" />
  </namedCache>

  <namedCache name="My-LuceneIndexesMetadata">
    <clustering mode="repl">
      <stateTransfer
          fetchInMemoryState="true" />
      <sync
          replTimeout="25000" />
    </clustering>

    <locking
        lockAcquisitionTimeout="20000"
        writeSkewCheck="false"
        concurrencyLevel="500"
        useLockStriping="false" />

    <invocationBatching
        enabled="false" />

    <eviction
        maxEntries="-1"
        strategy="NONE" />

    <expiration
        maxIdle="-1" />
  </namedCache>

実はこれ、Hibernate Searchにデフォルトで含まれているInfinispanの設定を、ちょっとだけ変えたものです。
https://github.com/hibernate/hibernate-search/blob/master/infinispan/src/main/resources/default-hibernatesearch-infinispan.xml

名前が変わったのと、インデックスデータを保存するCacheをReplicationではなくDistributionにしたという感じですね。

それでは、この設定を使ったテストを書いてみます。

    it("clustered spec") {
      withCache { _ =>
        withCache { cache =>
          books.foreach(book => cache.put(book.isbn, book))
        }

        withCache { cache =>
          val searchManager = Search.getSearchManager(cache)
          val queryBuilder = searchManager.buildQueryBuilderForClass(classOf[Book]).get

          val luceneQuery =
            queryBuilder
              .keyword
              .onField("title")
              .andField("summary")
              .matching("オープンソース 全文検索システムの構築")
              .createQuery

          luceneQuery.toString should be ("(title:オープン title:ソース title:全文 title:検索 title:システム title:構築) (summary:オープン summary:ソース summary:全文 summary:検索 summary:システム summary:構築)")

          val query = searchManager.getQuery(luceneQuery, classOf[Book])

          val result = query.list

          result should have size 2
          result.get(0) should be (luceneBook)
          result.get(1) should be (solrBook)

          cache.getCacheManager.getCacheNames should contain theSameElementsAs(Array("bookCache",
                                                                                     "My-LuceneIndexesLocking",
                                                                                     "My-LuceneIndexesData",
                                                                                     "My-LuceneIndexesMetadata",
                                                                                     "__cluster_registry_cache__"))
        }
      }
    }

withCacheを2回使っているので、InfinispanのNodeは2つあることになります。そして、片方はデータを登録してすぐにシャットダウンします。

      withCache { _ =>
        withCache { cache =>
          books.foreach(book => cache.put(book.isbn, book))
        }

その後、別のInfinispanのNodeを起動し、検索を行います。

        withCache { cache =>
          val searchManager = Search.getSearchManager(cache)
          val queryBuilder = searchManager.buildQueryBuilderForClass(classOf[Book]).get

          val luceneQuery =
            queryBuilder
              .keyword
              .onField("title")
              .andField("summary")
              .matching("オープンソース 全文検索システムの構築")
              .createQuery

          luceneQuery.toString should be ("(title:オープン title:ソース title:全文 title:検索 title:システム title:構築) (summary:オープン summary:ソース summary:全文 summary:検索 summary:システム summary:構築)")

          val query = searchManager.getQuery(luceneQuery, classOf[Book])

          val result = query.list

          result should have size 2
          result.get(0) should be (luceneBook)
          result.get(1) should be (solrBook)

          cache.getCacheManager.getCacheNames should contain theSameElementsAs(Array("bookCache",
                                                                                     "My-LuceneIndexesLocking",
                                                                                     "My-LuceneIndexesData",
                                                                                     "My-LuceneIndexesMetadata",
                                                                                     "__cluster_registry_cache__"))
        }
      }

最後に、CacheManagerが知っているCacheの名前を確認すると、設定したCacheの名前が入っていることがわかります(ひとつ、見知らぬものもありますが…)。今回のように設定変更をしなかった場合は、デフォルトのCache名で3つのCacheが増えていることが確認できるはずです。

また、片方のNodeを落とした時、DirectoryProviderが「ram」だったり、Luceneで使うCacheの設定をクラスタリング(ReplicationやDistribution)にしなかった場合は、インデックスは失われてしまい検索結果は0件となります。

というわけで、Node間でインデックスの共有までできたことが確認できました。なかなか大変でしたけど、ちゃんと動かせるところまでいって良かったです。

今回のソースコードは、こちらにアップしています。
https://github.com/kazuhira-r/infinispan-examples/tree/master/infinispan-query-config-analyzerdef