CLOVER🍀

That was when it all began.

Quarkus × Infinispan ClientでRemote Query(Query DSL + Ickle Query)

これは、なにをしたくて書いたもの?

QuarkusのInfinispan Client Extensionを使って、Queryを実行するのを試してみようかなと。

Quarkus - Infinispan Client

久しぶりに、InfinispanのRemote Queryを使ってみましょう、と。

お題

QuarkusのInfinispan Client ExtensionのQueryingのパートは、えらくあっさりしています。

Querying

ProtoStreamMarshallerを作成して、依存関係に「infinispan-query-dsl」を追加し、あとはInfinispanのドキュメントを参照してね、と。

Infinispan Query DSL

InfinispanのClient/Server Modeでは、Remote Queryを使います。

Remote Querying

ここでは、Query DSLとIckle Queryの2つを使うことができます。ドキュメント上は、Embedded Modeの方しかAPIの記載はしっかりと
書いていないですが…。

Infinispan Query DSL

Ickle

A remote query example

Remote Queryを使う時には、インデックスの有無、さらにインデックス有効時のAnalyzeの有効有無で利用できるAPIが異なります。
今回は、これらを網羅する以下の組み合わせで試していこうと思います。

  • インデックスなし
    • Query DSL
    • Ickle Query
  • インデックスあり、Analyzeなし
    • Query DSL
    • Ickle Query
  • インデックスあり、Analyzeあり
    • Ickle Query

インデックスを有効にして、さらにAnalyzeを有効にしておくとQuery DSLとは(Analyzedなフィールドを検索条件に使用する場合は)
併用不可になるので…。

お題は、書籍で。

各パターンごとに、以下をまとめて載せていく感じにします。

  • Protocol BuffersのIDL
  • Entity
  • MarshallerとProducerの定義
  • Queryを実行したりする、JAX-RSリソースクラス
  • Infinispan ServerのCache定義
  • 動作確認

環境

今回の環境は、こちら。

$ java -version
openjdk version "1.8.0_212"
OpenJDK Runtime Environment (build 1.8.0_212-8u212-b03-0ubuntu1.18.04.1-b03)
OpenJDK 64-Bit Server VM (build 25.212-b03, mixed mode)


$ mvn -version
Apache Maven 3.6.1 (d66c9c0b3152b2e69ee9bac180bb8fcc8e6af555; 2019-04-05T04:00:29+09:00)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 1.8.0_212, vendor: Oracle Corporation, runtime: /usr/lib/jvm/java-8-openjdk-amd64/jre
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "4.15.0-54-generic", arch: "amd64", family: "unix"


$ $GRAALVM_HOME/bin/native-image --version
GraalVM Version 19.0.2 CE

Infinispan Serverは、利用するQuarkus 0.18.0に合わせて、10.0.0 Beta3を使います。

起動は以下のコマンドで、1台ですがクラスタリングを有効に。また、動作しているInfinispan ServerのIPアドレスは172.17.0.2とします。

$ bin/standalone.sh \
    -Djboss.bind.address=`hostname -i` \
    -c clustered.xml

準備

まずは、QuarkusのMaven Pluginを使って、プロジェクトを作成します。

$ mvn io.quarkus:quarkus-maven-plugin:0.18.0:create \
    -DprojectGroupId=org.littlewings \
    -DprojectArtifactId=infinispan-client-query \
    -DprojectVersion=0.0.1-SNAPSHOT \
    -Dextensions="resteasy-jsonb,infinispan-client"

Extensionとしては、RESTEasy JSONBと、Infinispan Clientを指定。

あと、InfinispanのQueryを使うので、QuarkusのInfinispan Client Extensionのドキュメントに従い、「infinispan-query-dsl」の
依存関係を追加しておきます。

    <dependency>
      <groupId>org.infinispan</groupId>
      <artifactId>infinispan-query-dsl</artifactId>
    </dependency>

あとは、このプロジェクト内でソースコードを作成していきます。

$ cd infinispan-client-query

設定

Quarkusの設定ファイルには、 src/main/resources/application.properties

# Configuration file
# key = value

quarkus.infinispan-client.server-list=172.17.0.2:11222

インデックスなしの場合

まずは、インデックスを使用しない場合から。

Protocol BuffersのIDL。
src/main/resources/META-INF/grid-entity.proto

package org.littlewings.quarkus.infinispan;

option indexed_by_default = false;

message Book {
    required string isbn = 1;
    required string title = 2;
    required int32 price = 3;
    required string tag = 4;
}

「indexed_by_default」というのは、インデックスが有効なCacheを作成した場合に、デフォルトで各フィールドに対してインデックスを
作成しようとするらしく、Infinispan Server側で警告されます。

Indexing of Protobuf encoded entries

ここではインデックスが有効なCacheは作成しませんが、これ以降のCacheではインデックスは有効にしていくので、今回は
一律このオプションを付与していくことにします。

続いて、EntityやMarshallerですが、ここは割と定型的です。

Entity。
src/main/java/org/littlewings/quarkus/infinispan/Book.java

package org.littlewings.quarkus.infinispan;

public class Book {
    private String isbn;
    private String title;
    private int price;
    private String tag;

    // getter/setterは省略
}

Marshaller。
src/main/java/org/littlewings/quarkus/infinispan/BookMarshaller.java

package org.littlewings.quarkus.infinispan;

import java.io.IOException;

import org.infinispan.protostream.MessageMarshaller;

public class BookMarshaller implements MessageMarshaller<Book> {
    @Override
    public Book readFrom(ProtoStreamReader reader) throws IOException {
        Book book = new Book();

        book.setIsbn(reader.readString("isbn"));
        book.setTitle(reader.readString("title"));
        book.setPrice(reader.readInt("price"));
        book.setTag(reader.readString("tag"));

        return book;
    }

    @Override
    public void writeTo(MessageMarshaller.ProtoStreamWriter writer, Book book) throws IOException {
        writer.writeString("isbn", book.getIsbn());
        writer.writeString("title", book.getTitle());
        writer.writeInt("price", book.getPrice());
        writer.writeString("tag", book.getTag());
    }

    @Override
    public Class<? extends Book> getJavaClass() {
        return Book.class;
    }

    @Override
    public String getTypeName() {
        return "org.littlewings.quarkus.infinispan.Book";
    }
}

MarshallerのProducer。
src/main/java/org/littlewings/quarkus/infinispan/MarshallerFactory.java

package org.littlewings.quarkus.infinispan;

import javax.enterprise.context.ApplicationScoped;
import javax.enterprise.context.Dependent;
import javax.enterprise.inject.Produces;

import org.infinispan.protostream.MessageMarshaller;

@Dependent
public class MarshallerFactory {
    @Produces
    @ApplicationScoped
    public MessageMarshaller bookMarshaller() {
        return new BookMarshaller();
    }
}

書籍情報を含めたCacheを扱う、JAX-RSリソースクラス。
src/main/java/org/littlewings/quarkus/infinispan/BookResource.java

package org.littlewings.quarkus.infinispan;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.inject.Inject;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.MediaType;

import io.quarkus.infinispan.client.runtime.Remote;
import org.infinispan.client.hotrod.RemoteCache;
import org.infinispan.client.hotrod.Search;
import org.infinispan.query.dsl.Expression;
import org.infinispan.query.dsl.FilterConditionContextQueryBuilder;
import org.infinispan.query.dsl.Query;
import org.infinispan.query.dsl.QueryBuilder;
import org.infinispan.query.dsl.QueryFactory;
import org.infinispan.query.dsl.SortOrder;
import org.jboss.logging.Logger;

@Path("book")
public class BookResource {
    Logger logger = Logger.getLogger(BookResource.class);

    @Inject
    @Remote("bookCache")
    RemoteCache<String, Book> bookCache;

    @PUT
    @Path("{isbn}")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.TEXT_PLAIN)
    public String register(@PathParam("isbn") String isbn, Book book) {
        bookCache.put(isbn, book);

        return "OK!!";
    }

    @GET
    @Path("{isbn}")
    @Produces(MediaType.APPLICATION_JSON)
    public Book findByIsbn(@PathParam("isbn") String isbn) {
        return bookCache.get(isbn);
    }

    @GET
    @Path("search-dsl")
    @Produces(MediaType.APPLICATION_JSON)
    public List<Book> searchByQueryDsl(@QueryParam("title") String title, @QueryParam("price") Integer price, @QueryParam("tag") String tag) {
        QueryFactory qf = Search.getQueryFactory(bookCache);

        QueryBuilder qb = qf.from(Book.class);

        FilterConditionContextQueryBuilder cqb = null;
        Map<String, Object> parameters = new HashMap<>();

        if (title != null) {
            cqb = qb.having("title").like("%" + title + "%");
        }

        if (price != null) {
            if (cqb != null) {
                cqb = cqb.and().having("price").gte(Expression.param("price"));
            } else {
                cqb = qb.having("price").gte(Expression.param("price"));
            }

            parameters.put("price", price);
        }

        if (tag != null) {
            if (cqb != null) {
                cqb = cqb.and().having("tag").equal(Expression.param("tag"));
            } else {
                cqb = qb.having("tag").equal(Expression.param("tag"));
            }

            parameters.put("tag", tag);
        }

        cqb.orderBy("price", SortOrder.DESC);

        Query query = cqb.build();

        if (!parameters.isEmpty()) {
            query.setParameters(parameters);
        }

        logger.infof("query = %s", query.getQueryString());

        return query.list();
    }

    @GET
    @Path("search-ickle")
    @Produces(MediaType.APPLICATION_JSON)
    public List<Book> searchByIckleQuery(@QueryParam("title") String title, @QueryParam("price") Integer price, @QueryParam("tag") String tag) {
        QueryFactory qf = Search.getQueryFactory(bookCache);

        List<String> conditions = new ArrayList<>();
        Map<String, Object> parameters = new HashMap<>();

        if (title != null) {
            conditions.add("title like :title");
            parameters.put("title", "%" + title + "%");
        }

        if (price != null) {
            conditions.add("price >= :price");
            parameters.put("price", price);
        }

        if (tag != null) {
            conditions.add("tag = :tag");
            parameters.put("tag", tag);
        }

        Query query =
                qf.create(
                        String.format(
                                "from %s where %s order by price desc",
                                Book.class.getName(),
                                String.join(" and ", conditions)
                        )
                );

        query.setParameters(parameters);

        logger.infof("query = %s", query.getQueryString());

        return query.list();
    }
}

こちらは、少し解説を。

まず、Cacheのインジェクションと、データ登録、1件取得用のメソッドを作成。Cacheの名前は、「bookCache」とします。

    @Inject
    @Remote("bookCache")
    RemoteCache<String, Book> bookCache;

    @PUT
    @Path("{isbn}")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.TEXT_PLAIN)
    public String register(@PathParam("isbn") String isbn, Book book) {
        bookCache.put(isbn, book);

        return "OK!!";
    }

    @GET
    @Path("{isbn}")
    @Produces(MediaType.APPLICATION_JSON)
    public Book findByIsbn(@PathParam("isbn") String isbn) {
        return bookCache.get(isbn);
    }

こちらは、検索APIとしてQuery DSLを使った版。

    @GET
    @Path("search-dsl")
    @Produces(MediaType.APPLICATION_JSON)
    public List<Book> searchByQueryDsl(@QueryParam("title") String title, @QueryParam("price") Integer price, @QueryParam("tag") String tag) {
        QueryFactory qf = Search.getQueryFactory(bookCache);

        QueryBuilder qb = qf.from(Book.class);

        FilterConditionContextQueryBuilder cqb = null;
        Map<String, Object> parameters = new HashMap<>();

        if (title != null) {
            cqb = qb.having("title").like("%" + title + "%");
        }

        if (price != null) {
            if (cqb != null) {
                cqb = cqb.and().having("price").gte(Expression.param("price"));
            } else {
                cqb = qb.having("price").gte(Expression.param("price"));
            }

            parameters.put("price", price);
        }

        if (tag != null) {
            if (cqb != null) {
                cqb = cqb.and().having("tag").equal(Expression.param("tag"));
            } else {
                cqb = qb.having("tag").equal(Expression.param("tag"));
            }

            parameters.put("tag", tag);
        }

        cqb.orderBy("price", SortOrder.DESC);

        Query query = cqb.build();

        if (!parameters.isEmpty()) {
            query.setParameters(parameters);
        }

        logger.infof("query = %s", query.getQueryString());

        return query.list();
    }

titleはLIKE検索、priceは条件以上の値、tagは等値で検索条件を付与。QueryStringに指定されたものだけ指定します…が、全部の条件が
指定されていない場合は考慮していません…。

        if (title != null) {
            cqb = qb.having("title").like("%" + title + "%");
        }

        if (price != null) {
            if (cqb != null) {
                cqb = cqb.and().having("price").gte(Expression.param("price"));
            } else {
                cqb = qb.having("price").gte(Expression.param("price"));
            }

            parameters.put("price", price);
        }

        if (tag != null) {
            if (cqb != null) {
                cqb = cqb.and().having("tag").equal(Expression.param("tag"));
            } else {
                cqb = qb.having("tag").equal(Expression.param("tag"));
            }

            parameters.put("tag", tag);
        }

検索条件に指定する値は、いずれもプレースホルダーにしています(title…というかLIKE検索除く)。

priceの降順にソート。

        cqb.orderBy("price", SortOrder.DESC);

Queryの作成と、検索条件の値へのバインド。

        Query query = cqb.build();

        if (!parameters.isEmpty()) {
            query.setParameters(parameters);
        }

という感じです。以降のQueryも、だいたいこんな感じの雰囲気で作っていこうと思います。

Ickle Query。やっていることは、ほぼQuery DSLの時と同じです。こちらは、LIKE検索でもパラメーターへのバインドが可能です。

    @GET
    @Path("search-ickle")
    @Produces(MediaType.APPLICATION_JSON)
    public List<Book> searchByIckleQuery(@QueryParam("title") String title, @QueryParam("price") Integer price, @QueryParam("tag") String tag) {
        QueryFactory qf = Search.getQueryFactory(bookCache);

        List<String> conditions = new ArrayList<>();
        Map<String, Object> parameters = new HashMap<>();

        if (title != null) {
            conditions.add("title like :title");
            parameters.put("title", "%" + title + "%");
        }

        if (price != null) {
            conditions.add("price >= :price");
            parameters.put("price", price);
        }

        if (tag != null) {
            conditions.add("tag = :tag");
            parameters.put("tag", tag);
        }

        Query query =
                qf.create(
                        String.format(
                                "from %s where %s order by price desc",
                                Book.class.getName(),
                                String.join(" and ", conditions)
                        )
                );

        query.setParameters(parameters);

        logger.infof("query = %s", query.getQueryString());

        return query.list();
    }

では、動作確認の前にInfinispan ServerにCacheを作成します。名前は「bookCache」で、Distributed Cacheとします(Nodeひとつですが)。
※この操作は、Infinispan Serverが動作している環境で行っています

$ bin/ispn-cli.sh -c --command='/subsystem=datagrid-infinispan/cache-container=clustered/configurations=CONFIGURATIONS/distributed-cache-configuration=bookCacheConfiguration:add(start=EAGER,mode=SYNC)'
{"outcome" => "success"}
$ bin/ispn-cli.sh -c --command='/subsystem=datagrid-infinispan/cache-container=clustered/distributed-cache=bookCache:add(configuration=bookCacheConfiguration)'
{"outcome" => "success"}

ビルドして実行。

$ mvn package
$ java -jar target/infinispan-client-query-0.0.1-SNAPSHOT-runner.jar

データの登録。以降の例でも、登録するデータは同じものを使います。

$ curl -XPUT -H 'Content-Type: application/json' localhost:8080/book/978-4621303252 -d '{"isbn": "978-4621303252", "title": "Effective Java 第3版", "price":4320, "tag": "java"}'
OK!!

$ curl -XPUT -H 'Content-Type: application/json' localhost:8080/book/978-4774189093 -d '{"isbn": "978-4774189, "title": "Java本格入門 〜モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", "price":3218, "tag": "java"}'
OK!!

$ curl -XPUT -H 'Content-Type: application/json' localhost:8080/book/978-4774182179 -d '{"isbn": "978-4774182, "title": "[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ", "price": 4104, "tag": "spring"}'
OK!!

$ curl -XPUT -H 'Content-Type: application/json' localhost:8080/book/978-4295003915 -d '{"isbn": "978-4295003, "title": "Elasticsearch実践ガイド", "price": 3024, "tag": "全文検索"}'
OK!!

$ curl -XPUT -H 'Content-Type: application/json' localhost:8080/book/978-4774161631 -d '{"isbn": "978-4774161, "title": "[改訂新版] Apache Solr入門 ~オープンソース全文検索エンジン", "price": 3888, "tag": "全文検索"}'

1件取得。

$ curl localhost:8080/book/978-4621303252
{"isbn":"978-4621303252","price":4320,"tag":"java","title":"Effective Java 第3版"}

検索してみます。Query DSLから。

$ curl -s 'localhost:8080/book/search-dsl?title=Java&price=4000&tag=java' | jq
[
  {
    "isbn": "978-4621303252",
    "price": 4320,
    "tag": "java",
    "title": "Effective Java 第3版"
  }
]


$ curl -s 'localhost:8080/book/search-dsl?title=Java&price=4000' | jq
[
  {
    "isbn": "978-4621303252",
    "price": 4320,
    "tag": "java",
    "title": "Effective Java 第3版"
  },
  {
    "isbn": "978-4774182179",
    "price": 4104,
    "tag": "spring",
    "title": "[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ"
  }
]

ログに出てくるQuery Stringは、こんな感じです。

2019-07-06 22:48:33,899 INFO  [org.lit.qua.inf.BookResource] (executor-thread-1) query = FROM org.littlewings.quarkus.infinispan.Book _gen0 WHERE _gen0.title LIKE '%Java%' AND _gen0.price >= :price AND _gen0.tag = :tag ORDER BY _gen0.price DESC
2019-07-06 22:48:43,263 INFO  [org.lit.qua.inf.BookResource] (executor-thread-1) query = FROM org.littlewings.quarkus.infinispan.Book _gen0 WHERE _gen0.title LIKE '%Java%' AND _gen0.price >= :price ORDER BY _gen0.price DESC

Ickle Query。Queryを作るAPIが違うだけで意味は同じなので、当然ながらQuery DSLの時と同じ結果になります。

$ curl -s 'localhost:8080/book/search-ickle?title=Java&price=4000&tag=java' | jq
[
  {
    "isbn": "978-4621303252",
    "price": 4320,
    "tag": "java",
    "title": "Effective Java 第3版"
  }
]


$ curl -s 'localhost:8080/book/search-ickle?title=Java&price=4000' | jq
[
  {
    "isbn": "978-4621303252",
    "price": 4320,
    "tag": "java",
    "title": "Effective Java 第3版"
  },
  {
    "isbn": "978-4774182179",
    "price": 4104,
    "tag": "spring",
    "title": "[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ"
  }
]

ログ出力されるQuery Stringは、こんな感じになります。。

2019-07-06 22:49:14,951 INFO  [org.lit.qua.inf.BookResource] (executor-thread-1) query = from org.littlewings.quarkus.infinispan.Book where title like :title and price >= :price and tag = :tag order by price desc
2019-07-06 22:49:33,950 INFO  [org.lit.qua.inf.BookResource] (executor-thread-1) query = from org.littlewings.quarkus.infinispan.Book where title like :title and price >= :price order by price desc

最後にネイティブイメージで確認、ですが、結果自体は同じになるのでそちらの掲載は割愛します。

$ mvn -P native package
$ ./target/infinispan-client-query-0.0.1-SNAPSHOT-runner

インデックスあり、Analyzeなしの場合

次は、インデックスは使いますがAnalyzerは使わないパターンです。

Protocol BufferesのIDL。
src/main/resources/META-INF/grid-indexed-entity.proto

package org.littlewings.quarkus.infinispan;

option indexed_by_default = false;

/* @Indexed */
message IndexedBook {
    /* @Field(store = Store.YES, analyze = Analyze.NO) */
    required string isbn = 1;
    /* @Field(store = Store.YES, analyze = Analyze.NO) */
    required string title = 2;
    /* @Field(store = Store.YES, analyze = Analyze.NO)
       @SortableField */
    required int32 price = 3;
    /* @Field(store = Store.YES, analyze = Analyze.NO) */
    required string tag = 4;
}

Infinispanのドキュメントに従い、デフォルトでフィールドはインデックス対象にせず、インデックスに保存するものは明示しました。
結局全部なんですけど。

また、priceフィールドについてはソートで使うので、@SortableFieldアノテーションを付与しています。

これらのアノテーションは、Hibernate Searchのものですね。

EntityからMarshallerまでを一気に。

Entity。
src/main/java/org/littlewings/quarkus/infinispan/IndexedBook.java

package org.littlewings.quarkus.infinispan;

public class IndexedBook {
    private String isbn;
    private String title;
    private int price;
    private String tag;

    // getter/setterは省略
}

Marshaller。
src/main/java/org/littlewings/quarkus/infinispan/IndexedBookMarshaller.java

package org.littlewings.quarkus.infinispan;

import java.io.IOException;

import org.infinispan.protostream.MessageMarshaller;

public class IndexedBookMarshaller implements MessageMarshaller<IndexedBook> {
    @Override
    public IndexedBook readFrom(ProtoStreamReader reader) throws IOException {
        IndexedBook indexedBook = new IndexedBook();

        indexedBook.setIsbn(reader.readString("isbn"));
        indexedBook.setTitle(reader.readString("title"));
        indexedBook.setPrice(reader.readInt("price"));
        indexedBook.setTag(reader.readString("tag"));

        return indexedBook;
    }

    @Override
    public void writeTo(ProtoStreamWriter writer, IndexedBook indexedBook) throws IOException {
        writer.writeString("isbn", indexedBook.getIsbn());
        writer.writeString("title", indexedBook.getTitle());
        writer.writeInt("price", indexedBook.getPrice());
        writer.writeString("tag", indexedBook.getTag());
    }

    @Override
    public Class<? extends IndexedBook> getJavaClass() {
        return IndexedBook.class;
    }

    @Override
    public String getTypeName() {
        return "org.littlewings.quarkus.infinispan.IndexedBook";
    }
}

MarshallerのProducer。先ほど作成したクラスに、追加しています。
src/main/java/org/littlewings/quarkus/infinispan/MarshallerFactory.java

package org.littlewings.quarkus.infinispan;

import javax.enterprise.context.ApplicationScoped;
import javax.enterprise.context.Dependent;
import javax.enterprise.inject.Produces;

import org.infinispan.protostream.MessageMarshaller;

@Dependent
public class MarshallerFactory {
    @Produces
    @ApplicationScoped
    public MessageMarshaller bookMarshaller() {
        return new BookMarshaller();
    }

    @Produces
    @ApplicationScoped
    public MessageMarshaller indexedBookMarshaller() {
        return new IndexedBookMarshaller();
    }
}

JAX-RSリソースクラス。
src/main/java/org/littlewings/quarkus/infinispan/IndexedBookResource.java

package org.littlewings.quarkus.infinispan;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import javax.inject.Inject;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.MediaType;

import io.quarkus.infinispan.client.runtime.Remote;
import org.infinispan.client.hotrod.RemoteCache;
import org.infinispan.client.hotrod.Search;
import org.infinispan.query.dsl.Expression;
import org.infinispan.query.dsl.FilterConditionContextQueryBuilder;
import org.infinispan.query.dsl.Query;
import org.infinispan.query.dsl.QueryBuilder;
import org.infinispan.query.dsl.QueryFactory;
import org.infinispan.query.dsl.SortOrder;
import org.jboss.logging.Logger;

@Path("indexed-book")
public class IndexedBookResource {
    Logger logger = Logger.getLogger(IndexedBookResource.class);

    @Inject
    @Remote("indexedBookCache")
    RemoteCache<String, IndexedBook> indexedBookCache;

    @PUT
    @Path("{isbn}")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.TEXT_PLAIN)
    public String register(@PathParam("isbn") String isbn, IndexedBook indexedBook) {
        indexedBookCache.put(isbn, indexedBook);

        return "OK!!";
    }

    @GET
    @Path("{isbn}")
    @Produces(MediaType.APPLICATION_JSON)
    public IndexedBook findByIsbn(@PathParam("isbn") String isbn) {
        return indexedBookCache.get(isbn);
    }

    @GET
    @Path("search-dsl")
    @Produces(MediaType.APPLICATION_JSON)
    public List<IndexedBook> searchByQueryDsl(@QueryParam("title") String title, @QueryParam("price") Integer price, @QueryParam("tag") String tag) {
        QueryFactory qf = Search.getQueryFactory(indexedBookCache);

        QueryBuilder qb = qf.from(IndexedBook.class);

        FilterConditionContextQueryBuilder cqb = null;
        Map<String, Object> parameters = new HashMap<>();

        if (title != null) {
            cqb = qb.having("title").like("%" + title + "%");
        }

        if (price != null) {
            if (cqb != null) {
                cqb = cqb.and().having("price").gte(Expression.param("price"));
            } else {
                cqb = qb.having("price").gte(Expression.param("price"));
            }

            parameters.put("price", price);
        }

        if (tag != null) {
            if (cqb != null) {
                cqb = cqb.and().having("tag").equal(Expression.param("tag"));
            } else {
                cqb = qb.having("tag").equal(Expression.param("tag"));
            }

            parameters.put("tag", tag);
        }

        cqb.orderBy("price", SortOrder.DESC);

        Query query = cqb.build();

        if (!parameters.isEmpty()) {
            query.setParameters(parameters);
        }

        logger.infof("query = %s", query.getQueryString());

        return query.list();
    }

    @GET
    @Path("search-ickle")
    @Produces(MediaType.APPLICATION_JSON)
    public List<IndexedBook> searchByIckleQuery(@QueryParam("title") String title, @QueryParam("price") Integer price, @QueryParam("tag") String tag) {
        QueryFactory qf = Search.getQueryFactory(indexedBookCache);

        List<String> conditions = new ArrayList<>();
        Map<String, Object> parameters = new HashMap<>();

        if (title != null) {
            conditions.add("title like :title");
            parameters.put("title", "%" + title + "%");
        }

        if (price != null) {
            conditions.add("price >= :price");
            parameters.put("price", price);
        }

        if (tag != null) {
            conditions.add("tag = :tag");
            parameters.put("tag", tag);
        }

        Query query =
                qf.create(
                        String.format(
                                "from %s where %s order by price desc",
                                IndexedBook.class.getName(),
                                String.join(" and ", conditions)
                        )
                );

        query.setParameters(parameters);

        logger.infof("query = %s", query.getQueryString());

        return query.list();
    }
}

実はこれ、先ほどのインデックスなしの場合と使っているCacheと

    @Inject
    @Remote("indexedBookCache")
    RemoteCache<String, IndexedBook> indexedBookCache;

扱うEntityが違うだけで、構成は同じなのです。

        QueryBuilder qb = qf.from(IndexedBook.class);


        Query query =
                qf.create(
                        String.format(
                                "from %s where %s order by price desc",
                                IndexedBook.class.getName(),
                                String.join(" and ", conditions)
                        )
                );

なので、ここでのAPIの解説は割愛…。

では、Infinispan ServerのCacheの作成。今回は、「indexedBookCache」という名前で作成します。

$ bin/ispn-cli.sh -c --command='/subsystem=datagrid-infinispan/cache-container=clustered/configurations=CONFIGURATIONS/distributed-cache-configuration=indexedBookCacheConfiguration:add(start=EAGER,mode=SYNC)'
{"outcome" => "success"}
$ bin/ispn-cli.sh -c --command='/subsystem=datagrid-infinispan/cache-container=clustered/configurations=CONFIGURATIONS/distributed-cache-configuration=indexedBookCacheConfiguration/indexing=INDEXING:add(indexing=LOCAL,indexing-properties={default.directory_provider=local-heap,lucene_version=LUCENE_CURRENT})'
{"outcome" => "success"}
$ bin/ispn-cli.sh -c --command='/subsystem=datagrid-infinispan/cache-container=clustered/distributed-cache=indexedBookCache:add(configuration=indexedBookCacheConfiguration)'
{"outcome" => "success"}

データの登録。

$ curl -XPUT -H 'Content-Type: application/json' localhost:8080/indexed-book/978-4621303252 -d '{"isbn": "978-4621303252", "title": "Effective Java 第3版", "price":4320, "tag": "java"}'
OK!!

$ curl -XPUT -H 'Content-Type: application/json' localhost:8080/indexed-book/978-4774189093 -d '{"isbn": "9784189093", "title": "Java本格入門 〜モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", "price":3218, "tag": "java"}'
OK!!

$ curl -XPUT -H 'Content-Type: application/json' localhost:8080/indexed-book/978-4774182179 -d '{"isbn": "9784182179", "title": "[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ", "price": 4104, "tag": "spring"}'
OK!!

$ curl -XPUT -H 'Content-Type: application/json' localhost:8080/indexed-book/978-4295003915 -d '{"isbn": "9785003915", "title": "Elasticsearch実践ガイド", "price": 3024, "tag": "全文検索"}'
OK!!

$ curl -XPUT -H 'Content-Type: application/json' localhost:8080/indexed-book/978-4774161631 -d '{"isbn": "9784161631", "title": "[改訂新版] Apache Solr入門 ~オープンソース全文検索エンジン", "price": 3888, "tag": "全文検索"}'
OK!!

Query DSLを使う方から、検索してみます。結果は、先ほどと同じ。

$ curl -s 'localhost:8080/indexed-book/search-dsl?title=Java&price=4000&tag=java' | jq
[
  {
    "isbn": "978-4621303252",
    "price": 4320,
    "tag": "java",
    "title": "Effective Java 第3版"
  }
]


$ curl -s 'localhost:8080/indexed-book/search-dsl?title=Java&price=4000' | jq
[
  {
    "isbn": "978-4621303252",
    "price": 4320,
    "tag": "java",
    "title": "Effective Java 第3版"
  },
  {
    "isbn": "978-4774182179",
    "price": 4104,
    "tag": "spring",
    "title": "[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ"
  }
]

Query Stringも同じです。

2019-07-06 23:10:20,386 INFO  [org.lit.qua.inf.IndexedBookResource] (executor-thread-1) query = FROM org.littlewings.quarkus.infinispan.IndexedBook _gen0 WHERE _gen0.title LIKE '%Java%' AND _gen0.price >= :price AND _gen0.tag = :tag ORDER BY _gen0.price DESC
2019-07-06 23:11:06,228 INFO  [org.lit.qua.inf.IndexedBookResource] (executor-thread-1) query = FROM org.littlewings.quarkus.infinispan.IndexedBook _gen0 WHERE _gen0.title LIKE '%Java%' AND _gen0.price >= :price ORDER BY _gen0.price DESC

Ickle Query。

$ curl -s 'localhost:8080/indexed-book/search-ickle?title=Java&price=4000&tag=java' | jq
[
  {
    "isbn": "978-4621303252",
    "price": 4320,
    "tag": "java",
    "title": "Effective Java 第3版"
  }
]


$ curl -s 'localhost:8080/indexed-book/search-ickle?title=Java&price=4000' | jq
[
  {
    "isbn": "978-4621303252",
    "price": 4320,
    "tag": "java",
    "title": "Effective Java 第3版"
  },
  {
    "isbn": "978-4774182179",
    "price": 4104,
    "tag": "spring",
    "title": "[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ"
  }
]

Query String。

2019-07-06 23:11:59,353 INFO  [org.lit.qua.inf.IndexedBookResource] (executor-thread-1) query = from org.littlewings.quarkus.infinispan.IndexedBook where title like :title and price >= :price and tag = :tag order by price desc
2019-07-06 23:12:10,671 INFO  [org.lit.qua.inf.IndexedBookResource] (executor-thread-1) query = from org.littlewings.quarkus.infinispan.IndexedBook where title like :title and price >= :price order by price desc

インデックスが有効になっただけで、見た感じは先ほどの例と変わりませんね。

インデックスあり、Analyzeありの場合

最後、インデックスありで、Analyzeもありの場合です。

Protocol BuffersのIDL。
src/main/resources/META-INF/grid-indexed-analyzed-entity.proto

package org.littlewings.quarkus.infinispan;

option indexed_by_default = false;

/* @Indexed */
message IndexedAnalyzedBook {
    /* @Field(store = Store.YES, analyze = Analyze.NO) */
    required string isbn = 1;
    /* @Field(store = Store.YES, analyze = Analyze.YES, analyzer = @Analyzer(definition = "standard")) */
    required string title = 2;
    /* @Field(store = Store.YES, analyze = Analyze.NO)
       @SortableField */
    required int32 price = 3;
    /* @Field(store = Store.YES, analyze = Analyze.YES, analyzer = @Analyzer(definition = "keyword")) */
    required string tag = 4;
}

先ほどまで使っていた@Fieldアノテーションのanalyze属性を、Analyze.YESとするようにしています。

さらに、analyzerも指定していますが、Analyzerの使い方はドキュメントの以下の部分を読むとよいでしょう。

Default Analyzers

Using Analyzer Definitions

今回は、titleにStandardAnalyzer、tagにKeywordAnalyzerを使用しています。

EntityからMarshallerまで。

Entity。
src/main/java/org/littlewings/quarkus/infinispan/IndexedAnalyzedBook.java

package org.littlewings.quarkus.infinispan;

public class IndexedAnalyzedBook {
    private String isbn;
    private String title;
    private int price;
    private String tag;

    // getter/setterは省略
}

Marshaller。
src/main/java/org/littlewings/quarkus/infinispan/IndexedAnalyzedBookMarshaller.java

package org.littlewings.quarkus.infinispan;

import java.io.IOException;

import org.infinispan.protostream.MessageMarshaller;

public class IndexedAnalyzedBookMarshaller implements MessageMarshaller<IndexedAnalyzedBook> {
    @Override
    public IndexedAnalyzedBook readFrom(ProtoStreamReader reader) throws IOException {
        IndexedAnalyzedBook indexedAnalyzedBook = new IndexedAnalyzedBook();

        indexedAnalyzedBook.setIsbn(reader.readString("isbn"));
        indexedAnalyzedBook.setTitle(reader.readString("title"));
        indexedAnalyzedBook.setPrice(reader.readInt("price"));
        indexedAnalyzedBook.setTag(reader.readString("tag"));

        return indexedAnalyzedBook;
    }

    @Override
    public void writeTo(ProtoStreamWriter writer, IndexedAnalyzedBook indexedAnalyzedBook) throws IOException {
        writer.writeString("isbn", indexedAnalyzedBook.getIsbn());
        writer.writeString("title", indexedAnalyzedBook.getTitle());
        writer.writeInt("price", indexedAnalyzedBook.getPrice());
        writer.writeString("tag", indexedAnalyzedBook.getTag());
    }

    @Override
    public Class<? extends IndexedAnalyzedBook> getJavaClass() {
        return IndexedAnalyzedBook.class;
    }

    @Override
    public String getTypeName() {
        return "org.littlewings.quarkus.infinispan.IndexedAnalyzedBook";
    }
}

MarshallerのProducer。
src/main/java/org/littlewings/quarkus/infinispan/MarshallerFactory.java

package org.littlewings.quarkus.infinispan;

import javax.enterprise.context.ApplicationScoped;
import javax.enterprise.context.Dependent;
import javax.enterprise.inject.Produces;

import org.infinispan.protostream.MessageMarshaller;

@Dependent
public class MarshallerFactory {
    @Produces
    @ApplicationScoped
    public MessageMarshaller bookMarshaller() {
        return new BookMarshaller();
    }

    @Produces
    @ApplicationScoped
    public MessageMarshaller indexedBookMarshaller() {
        return new IndexedBookMarshaller();
    }

    @Produces
    @ApplicationScoped
    public MessageMarshaller indexedAnalyzedBookMarshaller() {
        return new IndexedAnalyzedBookMarshaller();
    }
}

JAX-RSリソースクラス。
src/main/java/org/littlewings/quarkus/infinispan/IndexedAnalyzedBookResource.java

package org.littlewings.quarkus.infinispan;

import java.util.ArrayList;
import java.util.List;
import javax.inject.Inject;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.MediaType;

import io.quarkus.infinispan.client.runtime.Remote;
import org.infinispan.client.hotrod.RemoteCache;
import org.infinispan.client.hotrod.Search;
import org.infinispan.query.dsl.Query;
import org.infinispan.query.dsl.QueryFactory;
import org.jboss.logging.Logger;

@Path("indexed-analyzed-book")
public class IndexedAnalyzedBookResource {
    Logger logger = Logger.getLogger(IndexedAnalyzedBookResource.class);

    @Inject
    @Remote("indexedAnalyzedBookCache")
    RemoteCache<String, IndexedAnalyzedBook> indexedAnalyzedBookCache;

    @PUT
    @Path("{isbn}")
    @Consumes(MediaType.APPLICATION_JSON)
    @Produces(MediaType.TEXT_PLAIN)
    public String register(@PathParam("isbn") String isbn, IndexedAnalyzedBook indexedAnalyzedBook) {
        indexedAnalyzedBookCache.put(isbn, indexedAnalyzedBook);

        return "OK!!";
    }

    @GET
    @Path("{isbn}")
    @Produces(MediaType.APPLICATION_JSON)
    public IndexedAnalyzedBook findByIsbn(@PathParam("isbn") String isbn) {
        return indexedAnalyzedBookCache.get(isbn);
    }

    @GET
    @Path("search-ickle")
    @Produces(MediaType.APPLICATION_JSON)
    public List<IndexedAnalyzedBook> searchByIckleQuery(@QueryParam("title") String title, @QueryParam("price") Integer price, @QueryParam("tag") String tag) {
        QueryFactory qf = Search.getQueryFactory(indexedAnalyzedBookCache);

        List<String> conditions = new ArrayList<>();

        if (title != null) {
            conditions.add("title: '" + title + "'");
        }

        if (price != null) {
            conditions.add("price: [" + price + " TO *]");
        }

        if (tag != null) {
            conditions.add("tag: '" + tag + "'");
        }

        Query query =
                qf.create(
                        String.format(
                                "from %s where %s order by price desc",
                                IndexedAnalyzedBook.class.getName(),
                                String.join(" and ", conditions)
                        )
                );

        logger.infof("query = %s", query.getQueryString());

        return query.list();
    }
}

こちらは、Ickle Queryのみです。

Cacheの名前以外に他と違うのは、Ickle Queryで使っているクエリの構文です。

        if (title != null) {
            conditions.add("title: '" + title + "'");
        }

        if (price != null) {
            conditions.add("price: [" + price + " TO *]");
        }

        if (tag != null) {
            conditions.add("tag: '" + tag + "'");
        }

こちらは、Apache LuceneのQuery Parser形式での記載になっています。

Deviations from the Lucene Query Parser Syntax

また、パラメーターのバインドは行いません(行なえません)。

Infinispan Serverに、Cacheの作成。名前は、「indexedAnalyzedBookCache」です。

$ bin/ispn-cli.sh -c --command='/subsystem=datagrid-infinispan/cache-container=clustered/configurations=CONFIGURATIONS/distributed-cache-configuration=indexedAnalyzedBookCacheConfiguration:add(start=EAGER,mode=SYNC)'
{"outcome" => "success"}
$ bin/ispn-cli.sh -c --command='/subsystem=datagrid-infinispan/cache-container=clustered/configurations=CONFIGURATIONS/distributed-cache-configuration=indexedAnalyzedBookCacheConfiguration/indexing=INDEXING:add(indexing=LOCAL,indexing-properties={default.directory_provider=local-heap,lucene_version=LUCENE_CURRENT})'
{"outcome" => "success"}
$ bin/ispn-cli.sh -c --command='/subsystem=datagrid-infinispan/cache-container=clustered/distributed-cache=indexedAnalyzedBookCache:add(configuration=indexedAnalyzedBookCacheConfiguration)'
{"outcome" => "success"}

アプリケーションを起動して、データの登録。

$ curl -XPUT -H 'Content-Type: application/json' localhost:8080/indexed-analyzed-book/978-4621303252 -d '{"isbn": "978-4621303252", "title": "Effective Java 第3版", "price":4320, "tag": "java"}'
OK!!

$ curl -XPUT -H 'Content-Type: application/json' localhost:8080/indexed-analyzed-book/978-4774189093 -d '{"is "978-4774189093", "title": "Java本格入門 〜モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", "price":3218, "tag": "java"}'
OK!!

$ curl -XPUT -H 'Content-Type: application/json' localhost:8080/indexed-analyzed-book/978-4774182179 -d '{"is "978-4774182179", "title": "[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ", "price": 4104, "tag": "spring"}'
OK!!

$ curl -XPUT -H 'Content-Type: application/json' localhost:8080/indexed-analyzed-book/978-4295003915 -d '{"is "978-4295003915", "title": "Elasticsearch実践ガイド", "price": 3024, "tag": "全文検索"}'
OK!!

$ curl -XPUT -H 'Content-Type: application/json' localhost:8080/indexed-analyzed-book/978-4774161631 -d '{"is "978-4774161631", "title": "[改訂新版] Apache Solr入門 ~オープンソース全文検索エンジン", "price": 3888, "tag": "全文検索"}'

確認。今回は、Ickle Queryしかありません。

これまでのIckle Queryと違って、例えばtitleフィールドであればStandardAnalyzerが効いているので、小文字でも検索できたりします。

$ curl -s 'localhost:8080/indexed-analyzed-book/search-ickle?title=java' | jq
[
  {
    "isbn": "978-4621303252",
    "price": 4320,
    "tag": "java",
    "title": "Effective Java 第3版"
  },
  {
    "isbn": "978-4774182179",
    "price": 4104,
    "tag": "spring",
    "title": "[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ"
  },
  {
    "isbn": "978-4774189093",
    "price": 3218,
    "tag": "java",
    "title": "Java本格入門 〜モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで"
  }
]

この時のQuery String。

2019-07-07 01:19:58,424 INFO  [org.lit.qua.inf.IndexedAnalyzedBookResource] (executor-thread-1) query = from org.littlewings.quarkus.infinispan.IndexedAnalyzedBook where title: 'java' order by price desc

その他。

$ curl -s 'localhost:8080/indexed-analyzed-book/search-ickle?title=java&price=4000&tag=java' | jq
[
  {
    "isbn": "978-4621303252",
    "price": 4320,
    "tag": "java",
    "title": "Effective Java 第3版"
  }
]


$ curl -s 'localhost:8080/indexed-analyzed-book/search-ickle?title=java&price=4000' | jq
[
  {
    "isbn": "978-4621303252",
    "price": 4320,
    "tag": "java",
    "title": "Effective Java 第3版"
  },
  {
    "isbn": "978-4774182179",
    "price": 4104,
    "tag": "spring",
    "title": "[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ"
  }
]

Query String。

2019-07-07 01:21:49,469 INFO  [org.lit.qua.inf.IndexedAnalyzedBookResource] (executor-thread-1) query = from org.littlewings.quarkus.infinispan.IndexedAnalyzedBook where title: 'java' and price: [4000 TO *] and tag: 'java' order by price desc
2019-07-07 01:22:02,146 INFO  [org.lit.qua.inf.IndexedAnalyzedBookResource] (executor-thread-1) query = from org.littlewings.quarkus.infinispan.IndexedAnalyzedBook where title: 'java' and price: [4000 TO *] order by price desc

こんな感じですね。

まとめ

QuarkusのInfinispan Client Extensionで、InfinispanのRemote Query(Query DSL + Ickle Query)を試してみました。

Quarkusでも、InfinispanのQuery DSLやIckle Queryが使えることが、確認できました。

それにしても、Query DSLとかは久しぶりに使ったのでけっこう忘れていましたが…まあなんとかなりました。
Protocol BuffersのIDLを使って、@Fieldなどを指定するのも久しぶりでしたねぇ。