CLOVER🍀

That was when it all began.

InfinispanのQuery DSLを試してみる

Infinispan 6.0から、Query DSLというものが追加されました。ちょっと気になっていたので、試してみようと思います。

前提知識

InfinispanのQueryですが、LuceneHibernate Searchという形で実現されています。よって、最終的にはLuceneのQueryが投げられるということは、押さえていた方がよいと思います。

現状は、Query DSL以外の方法でクエリを投げる場合は、LuceneのQueryを直接組み立てるか、Hibernate SearchのDSLを使用します。

って言ってますが、自分はHibernate Searchには詳しくないですけどね…。

将来的には、検索の機能をLuceneHibernate Search非依存にすることも考えているみたいです。現状は、LuceneHibernate Searchですが。

また、まだ開発中のものなので、Query DSLAPIは変更される可能性もあるみたいです。

準備

読むべきドキュメントは、以下の2つですね。

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

Infinispan’s Query DSL
http://infinispan.org/docs/6.0.x/user_guide/user_guide.html#_infinispan_s_query_dsl

Query DSLについて書かれているのは、Querying Infinispanの中にあるのですが、クローズアップした方がよいでしょう。

依存関係の定義は、ひとまずこんな感じ。
build.sbt

fork in test := true

libraryDependencies ++= Seq(
  "org.infinispan" % "infinispan-query" % "6.0.0.Final",
  "net.jcip" % "jcip-annotations" % "1.0",
  "org.scalatest" %% "scalatest" % "2.0" % "test"
)

InfinispanのQuery Moduleを入れれば、Query DSLも付いてきます。Query DSL自体は別のアーティファクトとして切り出されていますが、APIだけなので現状query-dslだけを追加しても無効です。

そういえば、sbtを0.13.1にアップデートしたら、Infinispanの依存関係が解決できるようになりました!素晴らしい!

Infinispanの設定。
src/main/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>
    <globalJmxStatistics
        enabled="true"
        jmxDomain="org.infinispan"
        cacheManagerName="DefaultCacheManager"
        />

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

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

</infinispan>

なんとなく、LuceneのDirectory ProviderはInfinispanにしてみました。今回は、あまり意味がありませんが。「indexLocalOnly」などは、ドキュメント参照。

検索対象のEntity。
src/main/scala/org/littlewings/infinispan/querydsl/entity/Book.scala

package org.littlewings.infinispan.querydsl.entity

import scala.collection._
import scala.collection.JavaConverters._

import java.util.{Date, Objects}

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

object Book {
  def apply(isbn: String,
            title: String,
            summary: String,
            price: Int,
            publisherDate: Date,
            authors: Author*): Book = {
    val book = new Book
    book.isbn = isbn
    book.title = title
    book.summary = summary
    book.price = price
    book.publisherDate = publisherDate
    book.authorsAsJava = Set(authors: _*).asJava
    book
  }
}

@SerialVersionUID(1L)
@Indexed
class Book extends Serializable {
  @Field(analyze = Analyze.NO, store = Store.YES)  // Projectionを使用する場合は、Store.YES
  var isbn: String = _

  @Field(analyze = Analyze.NO, store = Store.YES)  // Projectionを使用する場合は、Store.YES
  var title: String = _

  @Field(analyze = Analyze.NO)
  var summary: String = _

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

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

  @IndexedEmbedded
  var authorsAsJava: java.util.Set[Author] = _

  var authors: mutable.Set[Author] = authorsAsJava.asScala

  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 &&
      authorsAsJava == ob.authorsAsJava
    case _ => false
  }

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

  override def toString: String =
    s"""Book[isbn = $isbn,
       |     title = $title,
       |     summary = $summary,
       |     price = $price,
       |     publisherDate = $publisherDate,
       |     authors = { ${Option(authorsAsJava).getOrElse(new java.util.HashSet).asScala.mkString(", ")} }]""".stripMargin

}

EmbeddedなEntity。
src/main/scala/org/littlewings/infinispan/querydsl/entity/Author.scala

package org.littlewings.infinispan.querydsl.entity

import java.util.Objects

import org.hibernate.search.annotations.{Analyze, Field}

object Author {
  def apply(name: String): Author = {
    val author = new Author
    author.name = name
    author
  }
}

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

  override def equals(other: Any): Boolean = other match {
    case oa: Author => name == oa.name
    case _ => false
  }

  override def hashCode: Int =
    Objects.hash(name)

  override def toString: String =
    s"Author[name = $name]"
}

注意点ですが、Query DSLを使用する時はFieldアノテーションは必ず

  @Field(analyze = Analyze.NO)

とアナライズしないようにしてください。また、Projectionを使用する場合は値を保存することが必要です。

  @Field(analyze = Analyze.NO, store = Store.YES)  // Projectionを使用する場合は、Store.YES

使ってみる

では、この定義したEntityをCacheに放り込んで、Query DSLを試してみましょう。

以下のような、雛形コードを用意。
src/test/scala/org/littlewings/infinispan/querydsl/BookQueryDslSpec.scala

package org.littlewings.infinispan.querydsl

import scala.collection.JavaConverters._

import java.text.SimpleDateFormat

import org.infinispan.Cache
import org.infinispan.manager.DefaultCacheManager
import org.infinispan.query.Search
import org.infinispan.query.dsl.{Query, SortOrder}
// import org.infinispan.query.dsl.impl.AttributeCondition

import org.littlewings.infinispan.querydsl.entity.{Book, Author}

import org.scalatest.{BeforeAndAfterAll, FunSpec}
import org.scalatest.Matchers._

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

  val infinispanBook: Book =
    Book("978-1849518222",
         "Infinispan Data Grid Platform",
         "Making use of data grids for performance and scalability in Enterprise Java, using Infinispan from JBoss",
         3186,
         toDate("2012/06/30"),
         Author("Francesco Marchioni"), Author("Manik Surtani"))

  val hazelcastBook: Book =
    Book("978-1782167303",
         "Getting Started With Hazelcast",
         "An easy-to-follow and hands-on introduction to the highly scalable data distribution system, Hazelcast, and its advanced features.",
         4336,
         toDate("2013/08/27"),
         Author("Mat Johns"))

  val luceneBook: Book =
    Book("978-1933988177",
         "Lucene in Action",
         "New edition of top-selling book on the new version of Lucene the core open-source technology behind most full-text search and Intelligent Web applications.",
         5421,
         toDate("2010/06/30"),
         Author("Michael McCandless"), Author("Erik Hatcher"), Author("Otis Gospodnetic"))

  val books =
    Array(infinispanBook, hazelcastBook, luceneBook)

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

  def withCache[K, V](fileName: String, cacheName: String)(fun: Cache[K, V] => Unit): Unit = {
    val manager = new DefaultCacheManager(fileName)
    try {
      fun(manager.getCache[K, V](cacheName))
    } finally {
      manager.stop()
    }
  }
}

とりあえず、Infinispan、Hazelcast、Luceneの書籍を扱うことにしましょう。また、テストケースごとに、InfinispanのCacheを作成・破棄します。

まずは、簡単な使い方。

    it("search simple in") {
      withCache[String, Book]("infinispan.xml", "bookCache") { cache =>
        books.foreach(book => cache.put(book.isbn, book))

        val searchManager = Search.getSearchManager(cache)
        val queryFactory = searchManager.getQueryFactory

        val query: Query =
          queryFactory
            .from(classOf[Book])
            .having("isbn")
            .in("978-1782167303")
            .toBuilder
            .build

        query.toString should include ("query=isbn:978-1782167303")
        query.list[Book] should have size 1
        query.list[Book].get(0) should be (hazelcastBook)
      }
    }

SearchクラスからSearchManagerを取得、SearchManagerからQueryFactoryを取得します。

        val searchManager = Search.getSearchManager(cache)
        val queryFactory = searchManager.getQueryFactory

ここで取得したQueryFactoryに対して、DSLを組んでQueryを組み立てます。

        val query: Query =
          queryFactory
            .from(classOf[Book])
            .having("isbn")
            .in("978-1782167303")
            .toBuilder
            .build

fromでEntityを指定し、havingで検索を行うためのフィールド名を指定します。その後は、

  • eq
  • gt
  • gte
  • lt
  • lte
  • in
  • like
  • between
  • contains
  • isNull
  • and
  • or

などが使えます。詳しくは、以下のインターフェース定義を見てみるとよいでしょう。

QueryFactory
https://docs.jboss.org/infinispan/6.0/apidocs/org/infinispan/query/dsl/QueryFactory.html

QueryBuilder
https://docs.jboss.org/infinispan/6.0/apidocs/org/infinispan/query/dsl/QueryBuilder.html

FilterConditionContext
https://docs.jboss.org/infinispan/6.0/apidocs/org/infinispan/query/dsl/FilterConditionContext.html

FilterConditionEndContext
https://docs.jboss.org/infinispan/6.0/apidocs/org/infinispan/query/dsl/FilterConditionEndContext.html

また、今回の検索条件だと、以下のようなLucene Queryが投げられ、1件書籍がヒットします。

        query.toString should include ("query=isbn:978-1782167303")
        query.list[Book] should have size 1
        query.list[Book].get(0) should be (hazelcastBook)

なぜ1件探しているだけなのに、「eq」を使っていないのかは、Scalaでこのコードを書いているからです…。理由は、以下のエントリに書きました。

Scalaから、Javaのインターフェースに定義されたeq(Object)メソッドを呼べない?
http://d.hatena.ne.jp/Kazuhira/20131215/1387109792

続いて、いくつか試してみましょう。以後、こんな感じでDSLと実際に投げられるLucene Queryをペアで書いていきます。

like。

    it("search simple like") {
      withCache[String, Book]("infinispan.xml", "bookCache") { cache =>
        books.foreach(book => cache.put(book.isbn, book))

        val searchManager = Search.getSearchManager(cache)
        val queryFactory = searchManager.getQueryFactory

        val query: Query =
          queryFactory
            .from(classOf[Book])
            .having("summary")
            .like("%data%")
            .toBuilder
            .build

        query.toString should include ("query=summary:*data*")
        query.list[Book].asScala should contain theSameElementsAs (Array(infinispanBook,
                                                                         hazelcastBook))
      }
    }

between(range)。

    it("search price range") {
      withCache[String, Book]("infinispan.xml", "bookCache") { cache =>
        books.foreach(book => cache.put(book.isbn, book))

        val searchManager = Search.getSearchManager(cache)
        val queryFactory = searchManager.getQueryFactory

        val query: Query =
          queryFactory
            .from(classOf[Book])
            .orderBy("price", SortOrder.ASC)
            .having("price")
            .between(3000, 4500)
            .toBuilder
            .build

        query.toString should include ("query=price:[3000 TO 4500]")
        query.list[Book].asScala should contain theSameElementsInOrderAs (Array(infinispanBook,
                                                                                hazelcastBook))
      }
    }

gte、lte。まあ、今回の場合はbetweenでもいいですが…。

    it("search date gte lte") {
      withCache[String, Book]("infinispan.xml", "bookCache") { cache =>
        books.foreach(book => cache.put(book.isbn, book))

        val searchManager = Search.getSearchManager(cache)
        val queryFactory = searchManager.getQueryFactory

        val query: Query =
          queryFactory
            .from(classOf[Book])
            .orderBy("price", SortOrder.ASC)
            .having("publisherDate")
            .gte(toDate("2012/01/01"))
            .and
            .having("publisherDate")
            .lte(toDate("2013/12/31"))
            .toBuilder
            .build

        query.toString should include ("query=+publisherDate:[20111231 TO *] +publisherDate:[* TO 20131230]")
        query.list[Book].asScala should contain theSameElementsInOrderAs (Array(infinispanBook,
                                                                                hazelcastBook))
      }
    }

likeをandでつなげて。

    it("search like and like") {
      withCache[String, Book]("infinispan.xml", "bookCache") { cache =>
        books.foreach(book => cache.put(book.isbn, book))

        val searchManager = Search.getSearchManager(cache)
        val queryFactory = searchManager.getQueryFactory

        val query: Query =
          queryFactory
            .from(classOf[Book])
            .having("summary")
            .like("%data%")
            .and
            .having("title")
            .like("%Infinispan%")
            .toBuilder
            .build

        query.toString should include ("query=+summary:*data* +title:*Infinispan*")
        query.list[Book].asScala should contain theSameElementsAs (Array(infinispanBook))
      }
    }

sortとワイルドカード

    it("search wildcard and sort") {
      withCache[String, Book]("infinispan.xml", "bookCache") { cache =>
        books.foreach(book => cache.put(book.isbn, book))

        val searchManager = Search.getSearchManager(cache)
        val queryFactory = searchManager.getQueryFactory

        val query: Query =
          queryFactory
            .from(classOf[Book])
            .orderBy("price", SortOrder.DESC)
            .having("isbn")
            .like("*")
            .toBuilder
            .build

        query.toString should include ("query=isbn:*")
        query.list[Book].asScala should contain theSameElementsInOrderAs (Array(luceneBook,
                                                                                hazelcastBook,
                                                                                infinispanBook))
      }
    }

EmbeddedなEntityに対しての条件指定。

    it("search embedded entity") {
      withCache[String, Book]("infinispan.xml", "bookCache") { cache =>
        books.foreach(book => cache.put(book.isbn, book))

        val searchManager = Search.getSearchManager(cache)
        val queryFactory = searchManager.getQueryFactory

        val query: Query =
          queryFactory
            .from(classOf[Book])
            .having("authorsAsJava.name")
            .like("Manik%")
            .toBuilder
            .build

        query.toString should include ("query=authorsAsJava.name:Manik*")
        query.list should have size 1
        query.list[Book].asScala.apply(0) should be (infinispanBook)
      }
    }

条件のネスト。

    it("search nested condition") {
      withCache[String, Book]("infinispan.xml", "bookCache") { cache =>
        books.foreach(book => cache.put(book.isbn, book))

        val searchManager = Search.getSearchManager(cache)
        val queryFactory = searchManager.getQueryFactory

        val query: Query =
          queryFactory
            .from(classOf[Book])
            .orderBy("price", SortOrder.ASC)
            .having("summary")
            .like("%data%")
            .and(queryFactory
                  .having("title")
                  .like("%Infinispan%")
                  .or
                  .having("title")
                  .like("%Hazelcast%"))
            .toBuilder
            .build


        query.toString should include ("query=+summary:*data* +(title:*Infinispan* title:*Hazelcast*)")
        query.list[Book].asScala should contain theSameElementsInOrderAs (Array(infinispanBook,
                                                                                hazelcastBook))
      }
    }

Projection。

    it("search projection") {
      withCache[String, Book]("infinispan.xml", "bookCache") { cache =>
        books.foreach(book => cache.put(book.isbn, book))

        val searchManager = Search.getSearchManager(cache)
        val queryFactory = searchManager.getQueryFactory

        val query: Query =
          queryFactory
            .from(classOf[Book])
            .setProjection("isbn", "title")
            .having("isbn")
            .like("978-1849518222")
            .toBuilder
            .build

        query.toString should include ("query=isbn:978-1849518222")
        query.list should have size 1
        // Projectionの場合、Objectの配列が返ってくる
        query
          .list[Any]
          .asScala
          .apply(0)
          .asInstanceOf[Array[Any]] should contain theSameElementsInOrderAs (Array(infinispanBook.isbn,
                                                                                   infinispanBook.title))
      }
    }

Projectionの場合は、Entityが返るのではなく指定した項目を含むObjecの配列(のList)が戻ってくるので注意です。このため、Store.YESにする必要があるのだと思われます。

内部的には、JPAのクエリに変換してHibernate Searchに投げているみたいです。この時、Analyze.NOにしていなかった場合は例外が飛んできます。

LuceneのQueryって、最初にAnalyzerを指定するのでQuery DSLではそのあたりどうするのかなぁ?と思っていましたが(fromでAnalyzerを指定しない)、むしろアナライズするなということですね。

転置インデックスを活用した全文検索というよりは、普通の検索っぽいのである意味わかりやすいですかね。実際、あとで日本語を使った検索とかしようと考えていたのですが、Analyzerの定義をEntityにしていくことを考えると、ちょっと気が重くなりましたし…。

とりあえず、けっこう単純に使えるので良いと思います。DSL以外にも、JPQLとか投げられるといいのですが…それは、OGMの方を待っていた方がよいでしょうか。

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