CLOVER🍀

That was when it all began.

Infinispan 9で追加された、Ickle Queryを試す

先日、Infinispan 9(9.0.0.Final)がリリースされました。

Infinispan: Infinispan 9

8系のリリースから1年くらいかかっていますが、まあいろいろ変わって新機能も追加されました。
これから順次見ていこうと思います。

Ickle Query

で、最初にInfinispanに新しく導入されたQuery、「Ickle」を試してみたいと思います。

ドキュメントには、またTODOになっていて記載がありません…。

Ickle

現時点で参考になるリソースは、以下になります。

Infinispan: Meet Ickle!

Infinispan Query - Konstanz, October 2016 - Google スライド

あとはテストコードでしょう。

https://github.com/infinispan/infinispan/blob/9.0.0.Final/query/src/test/java/org/infinispan/query/dsl/embedded/QueryStringTest.java

Ickleとは、InfinispanのQueryの一種ですが、これまでQuery DSLで書いていたものを、Stringとして表現できるように
したものです。
※もうちょっと前にこの方針にして欲しかったかも

ブログによると、特徴は以下のとおりです。JP-QLのサブセットで、JOINとか計算式、サブクエリなどは使えなかったりします。

・ is a light and small subset of JP-QL, hence the lovely name
・ queries Java classes and supports Protocol Buffers too
・ queries can target a single entity type・
・ queries can filter on properties of embedded objects too, including collections
・ supports projections, aggregations, sorting, named parameters
・ supports indexed and non-indexed execution
・ supports complex boolean expressions
・ does not support computations in expressions (eg. user.age > sqrt(user.shoeSize + 3) is not allowed but user.age >= 18 is fine)
・ does not support joins
・ but, navigations along embedded entities are implicit joins and are allowed
・ joining on embedded collections is allowed
・ other join types not supported
・ subqueries are not supported
・ besides the normal relational operators it offers full-text operators, similar to Lucene’s query parser
・ is now supported across various Infinispan APIs, wherever a Query produced by the QueryBuilder is accepted (even for continuous queries or in event filters for listeners!)

http://blog.infinispan.org/2016/12/meet-ickle.html

また、InfinispanのCacheのindexingを有効にしていれば、LuceneのFull Text Queryも使うことができます。

早速試してみましょう。

準備

今回利用した依存関係は、以下のとおり。

libraryDependencies ++= Seq(
  "org.infinispan" % "infinispan-query" % "9.0.0.Final" % Compile,
  "net.jcip" % "jcip-annotations" % "1.0" % Provided,
  "org.apache.lucene" % "lucene-analyzers-kuromoji" % "5.5.4" % Compile,
  "org.scalatest" %% "scalatest" % "3.0.1" % Test
)

Queryを使うだけであれば、「infinispan-query」があればOKです。テスト用にScalaTestと、Full Text Query用に
なんとなくKuromojiも追加しています。

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.0 http://www.infinispan.org/schemas/infinispan-config-9.0.xsd"
        xmlns="urn:infinispan:config:9.0">
    <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>

JGroupsの設定は、デフォルトとします。

テストコードの雛形

動作確認はテストコードを使って行っていきますが、雛形としては以下とします。
src/test/scala/org/littlewings/infinispan/icklequery/IckleQuerySpec.scala

package org.littlewings.infinispan.icklequery

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

class IckleQuerySpec 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(cacheName))

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

簡易的にクラスタを構成するヘルパーメソッド付き。

以降、テストコードを埋めていきます。

リフレクションベースのQuery

InfinispanのQuery DSLで扱えるQueryには、2種類あります。Cacheのindexingを有効にしている場合はFull Text Queryですが、
そうでない場合はリフレクションベースのQueryになります(全件スキャン)。

最初は、リフレクションベースのQueryからいってみます。お題は書籍としましょう。

まずは、Cacheの定義。Distributed Cacheとします。

        <distributed-cache name="bookCache"/>

他には特に設定は入れません。

Cacheに登録するEntityクラスは、こちら。
src/main/scala/org/littlewings/infinispan/icklequery/Book.scala

package org.littlewings.infinispan.icklequery

import scala.beans.BeanProperty

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

@SerialVersionUID(1L)
class Book extends Serializable {
  @BeanProperty
  var isbn: String = _

  @BeanProperty
  var title: String = _

  @BeanProperty
  var price: Int = _

  @BeanProperty
  var category: String = _
}

テストデータ。

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

では、使ってみたコードはこちら。

  test("index-less simple Ickle Query") {
    withCache[String, Book]("bookCache", 3) { cache =>
      books.foreach(b => cache.put(b.isbn, b))

      val queryFactory = Search.getQueryFactory(cache)
      val query =
        queryFactory.create(
          """|from org.littlewings.infinispan.icklequery.Book b
             |where b.price > 5000
             |and b.title = '高速スケーラブル検索エンジン ElasticSearch Server'""".stripMargin)

      val resultBooks = query.list[Book]()
      resultBooks should have size (1)
      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("全文検索")
    }
  }

これまでのQuery DSL同様、Search#getQueryFactoryでQueryFactoryを取得するところから始まります。

      val queryFactory = Search.getQueryFactory(cache)

Queryの作成は割と単純で、QueryFactory#createでQueryStringを渡せばOKです。

      val query =
        queryFactory.create(
          """|from org.littlewings.infinispan.icklequery.Book b
             |where b.price > 5000
             |and b.title = '高速スケーラブル検索エンジン ElasticSearch Server'""".stripMargin)

from句のEntity名は、FQCNで指定する必要があります。

結果の受け取り方は、Query DSLと変わりません。

      val resultBooks = query.list[Book]()
      resultBooks should have size (1)
      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("全文検索")

というか、Queryのインスタンスの作り方が違うだけですからね。そりゃあそうですねと…。

Queryに対して、パラメーターをバインドすることもできます。「:」を付けてQueryStringに埋め込めばOKです。

  test("index-less simple Ickle Query, parameterized") {
    withCache[String, Book]("bookCache", 3) { cache =>
      books.foreach(b => cache.put(b.isbn, b))

      val queryFactory = Search.getQueryFactory(cache)
      val query =
        queryFactory.create(
          """|from org.littlewings.infinispan.icklequery.Book b
             |where b.price > :price
             |and b.title = :title""".stripMargin)
      query.setParameter("price", 5000)
      query.setParameter("title", "高速スケーラブル検索エンジン ElasticSearch Server")

      val resultBooks = query.list[Book]()
      resultBooks should have size (1)
      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("全文検索")
    }
  }

こんな感じです。

        queryFactory.create(
          """|from org.littlewings.infinispan.icklequery.Book b
             |where b.price > :price
             |and b.title = :title""".stripMargin)

値の設定自体は、Query#setParameterで行います。Query DSLと同じです。

      query.setParameter("price", 5000)
      query.setParameter("title", "高速スケーラブル検索エンジン ElasticSearch Server")

Group Byといった、Aggregationも可能です。この場合、結果はObjectの配列になってしまいますが。
※Projectionも同様だと思います
※しれっとHavingとOrder Byも入れています

  test("index-less simple Ickle Query, aggregation") {
    withCache[String, Book]("bookCache", 3) { cache =>
      books.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.icklequery.Book b
             |where b.price > :price
             |group by b.category
             |having sum(b.price) > :sumPrice
             |order by sum(b.price) desc""".stripMargin)

      query.setParameter("price", 4000)
      query.setParameter("sumPrice", 5000)

      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("全文検索")
      results.get(1)(1) should be(6915)
    }
  }

Full Text Query

続いては、Full Text Queryを使ってみたいと思います。

今度は、先にEntityから。
src/main/scala/org/littlewings/infinispan/icklequery/IndexedBook.scala

package org.littlewings.infinispan.icklequery

import org.apache.lucene.analysis.ja.JapaneseAnalyzer
import org.hibernate.search.annotations._

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
@Analyzer(impl = classOf[JapaneseAnalyzer])
@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 = _
}

Hibernate Searchの、各種アノテーションを付与します。Analyzerは、なんとなくKuromojiに…。

Cacheの設定は、このようにしました。

        <distributed-cache name="indexedBookCache">
            <indexing index="LOCAL" auto-config="true">
                <indexed-entities>
                    <indexed-entity>org.littlewings.infinispan.icklequery.IndexedBook</indexed-entity>
                </indexed-entities>
                <property name="lucene_version">LUCENE_CURRENT</property>
            </indexing>
        </distributed-cache>

現在のInfinispanでは、インデックスに登録する対象のEntityは、設定として明示する方針となっています。また、設定自体に
ついては今回は「auto-config」を使ってはしょりました。

auto-configについては、こちら。

Automatic configuration

今回はDistributed Cacheを選んでいるので、LuceneのインデックスはInfinispanに保存されます。

テストデータは、内容的には先ほどと同じです。

  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")
  )

では、使ってみます。APIの使い方自体は先ほどと同じなのですが、「フィールド名:」のようなLuceneのQueryParserで
使えるような書式が指定できるようになります。

  test("indexed entity Ickle Query, 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.icklequery.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("全文検索")
    }
  }

なお、インデックスを有効にしていないCacheで、このようなQueryStringを指定すると例外がスローされます。

  test("index-less Ickle Query, can't applied full text predicate") {
    withCache[String, Book]("bookCache", 3) { cache =>
      books.foreach(b => cache.put(b.isbn, b))

      val queryFactory = Search.getQueryFactory(cache)
      val thrown =
        the[ParsingException] thrownBy
        queryFactory.create(
          """|from org.littlewings.infinispan.icklequery.Book b
             |where b.title: '高速スケーラブル検索エンジン ElasticSearch Server'""".stripMargin)

      thrown.getMessage should be("ISPN028521: Full-text queries cannot be applied to property 'title' in type org.littlewings.infinispan.icklequery.Book unless the property is indexed and analyzed.")
    }
  }

反対にインデックスを有効かつAnalyzedなフィールドに対して「=」などの演算子を使用すると、こちらも例外がスローされます。

  test("indexed entity Ickle Query, analyzed field can't applied eq") {
    withCache[String, IndexedBook]("indexedBookCache", 3) { cache =>
      indexedBooks.foreach(b => cache.put(b.isbn, b))

      val queryFactory = Search.getQueryFactory(cache)
      val thrown =
        the[ParsingException] thrownBy
          queryFactory.create(
            """|from org.littlewings.infinispan.icklequery.IndexedBook b
               |where b.price > 5000
               |and b.title = '全文検索'""".stripMargin)

      thrown.getMessage should be("ISPN028522: No relational queries can be applied to property 'title' in type org.littlewings.infinispan.icklequery.IndexedBook since the property is analyzed.")
    }
  }

まあ、Queryの実行対象は意識しましょうね、と。

Full Text Queryに対しても、Aggregationが可能です。

  test("indexed entity Ickle Query, full text query, 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.icklequery.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)
    }
  }

良いですね。

ざっと使い方の導入部としては、こんな感じでしょう。

もうちょっと中身を

で、このQueryStringですが、パーサーの部分はANTLRが使われているようです。

ANTLR

パーサーのもととなる定義は、こちらのようです。
https://github.com/infinispan/infinispan/tree/9.0.0.Final/object-filter/src/main/antlr3/org/infinispan/objectfilter/impl/ql/parse

また、最終的に生成されるQueryは、リフレクションベースのQueryならEmbeddedQueryになりますし
https://github.com/infinispan/infinispan/blob/9.0.0.Final/query/src/main/java/org/infinispan/query/dsl/embedded/impl/EmbeddedQuery.java

Full Text QueryであればEmbeddedLuceneQueryになります。
https://github.com/infinispan/infinispan/blob/9.0.0.Final/query/src/main/java/org/infinispan/query/dsl/embedded/impl/EmbeddedLuceneQuery.java

構文的には同じですが、今までのQuery DSL同様、検索対象によってQueryも分かれます、と…。

また、パラメーターを当て込んでいる箇所は、このあたりになったりします。
https://github.com/infinispan/infinispan/blob/9.0.0.Final/object-filter/src/main/java/org/infinispan/objectfilter/impl/syntax/parser/QueryRendererDelegateImpl.java

バインドパラメーターを見ている部分もあるので、中身を追う時には見ることになるんじゃないかなぁと。

困ったこと

バインドパラメーターが使えない箇所がある

最初にこれにハマりました。JDBCと同じノリでバインドパラメーターが使えると思いきや、使える演算子は現状だと限定的だったりします。

例えば、比較演算子はバインドパラメーターが使えますが、LIKEは使えません。また、Full Text系の演算子みはバインドパラメーターが
まったく使えなかったりします。

Full Text Queryの演算子で、適用されるフィールドがanalyzedかどうかが決まっている

RANGEで数値を指定した時に、見事にこれにハマりました…。基本的にFull Text系の演算子には対象のフィールドがanalyzedなことが
要求されます。

それがQueryのパース時に確認されるので、コケるのですが…これだと数値型のフィールドに対して、Full TextなRANGE Queryが投げられない…。
これはどうなんでしょう…。

とまあ、困ったのはこんなところです。

このあたりの話は、さっきも出てきましたがこちらを見ることになります。
https://github.com/infinispan/infinispan/blob/9.0.0.Final/object-filter/src/main/java/org/infinispan/objectfilter/impl/syntax/parser/QueryRendererDelegateImpl.java

このあたりの内容はドキュメントがない現状では、ソースコードを見ることになりますね。

また、Queryをパースしている間の関連クラスはこのあたりのパッケージに入っているので、ANTLRの定義と合わせて追うことになるでしょう。
https://github.com/infinispan/infinispan/tree/9.0.0.Final/object-filter/src/main/java/org/infinispan/objectfilter/impl/syntax
https://github.com/infinispan/infinispan/tree/9.0.0.Final/object-filter/src/main/java/org/infinispan/objectfilter/impl/syntax/parser

まとめ

Infinispan 9から導入された、Ickle Queryを試してみました。

今までHibernate Search、もしくはInfinispan自身の提供するQuery DSLしかQueryを使う方法がなかったのですが、こうやってStringでも
表現できる実装が追加されたのは嬉しいなぁと思います。

もうちょっと習熟していかないと。

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