CLOVER🍀

That was when it all began.

Infinispan 15.0のMarshalling(ProtoStream)で追加されたProtocol Buffers 3とRecordsのサポートを試す

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

Infinispan 15.0.0.Finalのリリースブログの中に、Marshallingの改善がトピックとしてありました。

Infinispan 15.0.0.Final

ProtoStreamに関するものになるのですが、今回はこちらを見ていこうと思います。

ProtoStream 5.0での変更

Infinispanでは、Marshalling(いわゆるシリアライズ)の選択肢としてProtocol Buffersがあります。他にも選択肢がありますが、
まずはProtocol Buffersを使うのが推奨です。

そしてオブジェクトをProtocol Buffersの形式にエンコード・デコードするのに、ProtoStreamというライブラリーを使用しています。

GitHub - infinispan/protostream: ProtoStream is a serialization library based on Protocol Buffers

このため、見るべき内容は主にProtoStreamの変更内容ということになります。

ProtoStream 5.0.0.Finalまでの変更内容はこちらです。

ブログにも記載がありますが、次が大きな変更点のようです。

今回は特にProtocol Buffers 3、@Protoアノテーション、Recordsのサポートを見ていこうと思います。

ProtoStreamの使い方については、Infinipspanのドキュメントのこちらのページに記載があるのですが。

Encoding Infinispan caches and marshalling data

今回の新機能については特にドキュメントには書かれていないので、少し記載しておきます。

まあ、@ProtoアノテーションとRecordsのサポートについては、ブログのコード例を貼った方が早いですね。

@Proto
@Indexed
public record Book(
    @Text String title,
    @Keyword(projectable = true, sortable = true, normalizer = "lowercase", indexNullAs = "unnamed", norms = false) String description,
    int publicationYear,
    Set<Author> authors,
    Type bookType,
    BigDecimal price
) { }

Infinispanに保存するクラスとしてRecordを、また各フィールドに@ProfoFieldアノテーションを付与するのではなく、@Protoという
アノテーションを付与するだけで簡単にProtocol Buffersのメッセージとして定義できるようになりました。
@Indexed@Text@Keywordはインデックス用のアノテーションです

これを今までの形式で書くと、こんな感じになると思います。

@Indexed
public class Book(
    @Text
    @ProtoField(number = 1)
    String title,
    @Keyword(projectable = true, sortable = true, normalizer = "lowercase", indexNullAs = "unnamed", norms = false)
    @ProtoField(number = 2)
    String description,
    @ProtoField(number = 3)
    int publicationYear,
    @ProtoField(number = 4)
    Set<Author> authors,
    @ProtoField(number = 5)
    Type bookType,
    @ProtoField(number = 6)
    BigDecimal price
) { }

@Protoアノテーションを使うことで、だいぶすっきり書けるようになるわけですね。

次にProtocol Buffers 3のサポートについてです。

Infinispan 14.x(ProtoStream 4.x)までは、ProtoStreamで生成したIDLはProtocol Buffers 2のものでした。

Language Guide (proto 2) | Protocol Buffers Documentation

これがInfinispan 15.0(ProtoStream 5.0)からは、Protocol Buffers 3の構文が使えるようになります。

Language Guide (proto 3) | Protocol Buffers Documentation

Protocol Buffers 2と3の違いは、こちらにコンパクトにまとまっている感じがしますね。

プロトコル バッファ v3  |  Cloud APIs  |  Google Cloud

生成するIDLをProtocol Buffers 2とするかProtocol Buffers 3とするかは選択式になります。指定はProtoSyntaxという列挙型で行います。

https://github.com/infinispan/protostream/blob/5.0.1.Final/core/src/main/java/org/infinispan/protostream/annotations/ProtoSyntax.java

Protocol BuffersのIDLをアノテーションで指定して生成する場合は、@ProtoSchemaアノテーションまたは
@AutoProtoSchemaBuilderアノテーション(こちらは非推奨で、@ProtoSchemaアノテーションが推奨の模様)のsyntax属性で
指定します。

https://github.com/infinispan/protostream/blob/5.0.1.Final/core/src/main/java/org/infinispan/protostream/annotations/ProtoSchema.java#L114

https://github.com/infinispan/protostream/blob/5.0.1.Final/core/src/main/java/org/infinispan/protostream/annotations/AutoProtoSchemaBuilder.java#L136

こんな感じです。

@ProtoSchema(
        ...
        syntax = ProtoSyntax.PROTO3,
        ...
)
public interface EntitiesInitializer extends GeneratedSchema {
}

syntax属性を指定しない場合は、デフォルトでProtocol Buffers 2を指定したことになります。

少し余談を。@ProtoSchemaアノテーション@AutoProtoSchemaBuilderアノテーションのどちらを使うかもちょっと気になる
ところですが、どちらを使っても結果は同じになります。

https://github.com/infinispan/protostream/blob/5.0.1.Final/processor/src/main/java/org/infinispan/protostream/annotations/impl/processor/AutoProtoSchemaBuilderAnnotationProcessor.java#L192-L197

Schema APIで指定する場合は、こんな感じになりそうです。
※この後も動かして確認していません

Schema schema = new Schema.Builder("magazine.proto")
    ...
    .syntax(ProtoSyntax.PROTO3)
    ...
    .build();

なお、実装を見るとこちらはsyntaxを指定しない場合はデフォルトでProtocol Buffers 3を指定したことになりそうです。

https://github.com/infinispan/protostream/blob/5.0.1.Final/core/src/main/java/org/infinispan/protostream/schema/Schema.java#L84

どうしてデフォルトのバージョンが違うんですか?とか思わなくもないのですが、Schema APIはProtoStream 5.0で追加された新しいAPIなのに
対して、アノテーションの方は後方互換性を重視してるからなんでしょうね、きっと。

Schema APIを見るに、Protocol Buffers 3を使って欲しそうな感じではありますね。

また、プロパティ個別に設定を行う場合は@ProtoFieldアノテーションを付与するのですが、requireddefaultValueに2つの属性は
Protocol Buffers 2でのみ有効です。Protocol Buffers 3で使うと、無視されます。

https://github.com/infinispan/protostream/blob/5.0.1.Final/core/src/main/java/org/infinispan/protostream/annotations/ProtoField.java

ProtoStreamでアノテーションからProtocol BuffersのIDLを生成する場合、ドキュメントに習うとMaven Compiler Pluginに
ProtoStream Processorアノテーションプロセッサーとして追加すればよいことになっているのですが。

<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-compiler-plugin</artifactId>
      <version>...</version>
      <configuration>
        <annotationProcessorPaths>
          <annotationProcessorPath>
            <groupId>org.infinispan.protostream</groupId>
            <artifactId>protostream-processor</artifactId>
            <version>...</version>
          </annotationProcessorPath>
        </annotationProcessorPaths>
      </configuration>
    </plugin>
  </plugins>
</build>

Encoding Infinispan caches and marshalling data / Marshalling custom objects with ProtoStream / Creating serialization context initializers / Adding the ProtoStream processor

実際には、dependencyにも必要です…。

        <dependency>
            <groupId>org.infinispan.protostream</groupId>
            <artifactId>protostream-processor</artifactId>
            <version>...</version>
            <scope>provided</scope>
        </dependency>

Infinispan 14.xのドキュメントではこちらだけになっていました。

Encoding Infinispan caches and marshalling data / Marshalling custom objects with ProtoStream / Creating serialization context initializers / Adding the ProtoStream processor

このあたりは、また後で。

では、説明はこれくらいにして使っていってみましょう。

今回は、以下の内容を試してみたいと思います。

  • Recordsの利用
    • 検索の利用
  • @Protoアノテーションの利用
  • Protocol Buffers 3構文でのIDLの生成

お題は書籍として、Infinispan自体はHot Rod Clientで利用します。

環境

今回の環境はこちら。

$ java --version
openjdk 21.0.2 2024-01-16
OpenJDK Runtime Environment (build 21.0.2+13-Ubuntu-122.04.1)
OpenJDK 64-Bit Server VM (build 21.0.2+13-Ubuntu-122.04.1, mixed mode, sharing)


$ mvn --version
Apache Maven 3.9.6 (bc0240f3c744dd6b6ec2920b3cd08dcc295161ae)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 21.0.2, vendor: Private Build, runtime: /usr/lib/jvm/java-21-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.15.0-101-generic", arch: "amd64", family: "unix"

Infinispan Server。

$ java --version
openjdk 21.0.2 2024-01-16 LTS
OpenJDK Runtime Environment Temurin-21.0.2+13 (build 21.0.2+13-LTS)
OpenJDK 64-Bit Server VM Temurin-21.0.2+13 (build 21.0.2+13-LTS, mixed mode, sharing)


$ bin/server.sh --version

Infinispan Server 15.0.0.Final (I'm Still Standing)
Copyright (C) Red Hat Inc. and/or its affiliates and other contributors
License Apache License, v. 2.0. http://www.apache.org/licenses/LICENSE-2.0

Infinispan Serverは172.18.0.3〜5の3ノードのクラスター構成で動作しているものとして、以下のコマンドで起動させます。

$ bin/server.sh \
    -b 0.0.0.0 \
    -Djgroups.tcp.address=$(hostname -i)

Infinispan Serverには、以下のコマンドで管理用ユーザーとアプリケーション用ユーザーを作成しているものとします。

$ bin/cli.sh user create -g admin -p password ispn-admin
$ bin/cli.sh user create -g application -p password ispn-user

準備

Maven依存関係など。

    <properties>
        <maven.compiler.release>21</maven.compiler.release>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.infinispan</groupId>
            <artifactId>infinispan-client-hotrod</artifactId>
            <version>15.0.0.Final</version>
        </dependency>
        <dependency>
            <groupId>org.infinispan.protostream</groupId>
            <artifactId>protostream-processor</artifactId>
            <version>5.0.1.Final</version>
            <scope>provided</scope>
        </dependency>

        <!-- Infinispanのインデックスアノテーションで利用 -->
        <dependency>
            <groupId>org.infinispan</groupId>
            <artifactId>infinispan-api</artifactId>
            <version>15.0.0.Final</version>
        </dependency>

        <!-- Queryを使う場合に必要 -->
        <dependency>
            <groupId>org.infinispan</groupId>
            <artifactId>infinispan-remote-query-client</artifactId>
            <version>15.0.0.Final</version>
        </dependency>
        <dependency>
            <groupId>org.infinispan</groupId>
            <artifactId>infinispan-query-dsl</artifactId>
            <version>15.0.0.Final</version>
        </dependency>


        <!-- テスト -->
        <dependency>
            <groupId>org.infinispan</groupId>
            <artifactId>infinispan-core</artifactId>
            <version>15.0.0.Final</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>5.10.2</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.25.3</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.11.0</version>
                <configuration>
                    <annotationProcessorPaths>
                        <annotationProcessorPath>
                            <groupId>org.infinispan.protostream</groupId>
                            <artifactId>protostream-processor</artifactId>
                            <version>5.0.1.Final</version>
                        </annotationProcessorPath>
                    </annotationProcessorPaths>
                </configuration>
            </plugin>
        </plugins>
    </build>

InfinispanのHot Rod ClientとProtoStreamを扱うだけなら、以下の依存関係があればOKです。

        <dependency>
            <groupId>org.infinispan</groupId>
            <artifactId>infinispan-client-hotrod</artifactId>
            <version>15.0.0.Final</version>
        </dependency>
        <dependency>
            <groupId>org.infinispan.protostream</groupId>
            <artifactId>protostream-processor</artifactId>
            <version>5.0.1.Final</version>
            <scope>provided</scope>
        </dependency>

アノテーションプロセッサーの設定も入りますが。

            <plugin>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.11.0</version>
                <configuration>
                    <annotationProcessorPaths>
                        <annotationProcessorPath>
                            <groupId>org.infinispan.protostream</groupId>
                            <artifactId>protostream-processor</artifactId>
                            <version>5.0.1.Final</version>
                        </annotationProcessorPath>
                    </annotationProcessorPaths>
                </configuration>
            </plugin>

他のものは、検索機能(クエリー)を使う場合に追加してください。

RecordsからProtocol BuffersのIDLを生成してみる

まずはRecordsからProtocol BuffersのIDLを生成してみましょう。せっかくなので、最初から@Protoアノテーションも付与してみます。

src/main/java/org/littlewings/infinispan/remote/proto3record/Book.java

package org.littlewings.infinispan.remote.proto3record;

import org.infinispan.protostream.annotations.Proto;

@Proto
public record Book(
        String isbn,
        String title,
        int price
) {
}

各フィールドにアノテーションを付与しなくても済むので、とてもすっきりしますね。

GeneratedSchemaインターフェースを拡張し、@ProtoSchemaアノテーションを付与したインターフェースを作成します。

src/main/java/org/littlewings/infinispan/remote/proto3record/EntitiesInitializer.java

package org.littlewings.infinispan.remote.proto3record;

import org.infinispan.protostream.GeneratedSchema;
import org.infinispan.protostream.annotations.ProtoSchema;
import org.infinispan.protostream.annotations.ProtoSyntax;

@ProtoSchema(
        includeClasses = {Book.class},
        schemaFileName = "entities.proto",
        schemaFilePath = "proto/",
        schemaPackageName = "entity"
)
public interface EntitiesInitializer extends GeneratedSchema {
}

コンパイル

$ mvn compile

RecordsからProtocol BuffersのIDLが生成されました。デフォルトではsyntax = "proto2";、つまりProtocol Buffers 2ですね。

target/classes/proto/entities.proto

// File name: entities.proto
// Generated from : entities.proto
syntax = "proto2";
package entity;



message Book {

   optional string isbn = 1;

   optional string title = 2;

   optional int32 price = 3;
}

では、syntax = ProtoSyntax.PROTO3としてコンパイルしてみましょう。

@ProtoSchema(
        includeClasses = {Book.class},
        schemaFileName = "entities.proto",
        schemaFilePath = "proto/",
        schemaPackageName = "entity",
        syntax = ProtoSyntax.PROTO3
)
public interface EntitiesInitializer extends GeneratedSchema {
}

今度はsyntax = "proto3";になりました。Protocol Buffers 3の構文になりましたね。optionalもなくなっています。

target/classes/proto/entities.proto

// File name: entities.proto
// Generated from : entities.proto
syntax = "proto3";
package entity;



message Book {

   string isbn = 1;

   string title = 2;

   int32 price = 3;
}

ちなみに、@ProtoSchemaアノテーション(または@AutoProtoSchemaBuilderアノテーション)にはincludeClassesおよび
basePackagesというパラメーターがあり、これでIDLを生成する対象の範囲を指定のクラスまたは指定のパッケージ配下に
絞ることができます。

@ProtoSchema(
        includeClasses = {Book.class},

なにも指定しない場合は、ソースパス全体をスキャンして検出するようなのですが、それは推奨されていません。

The includeClasses or basePackages parameters reference classes that the ProtoStream processor should scan and include in the Protobuf schema and marshaller. If you do not set either of these parameters, the ProtoStream processor scans the entire source path, which can lead to unexpected results and is not recommended. You can also use the excludeClasses parameter with the basePackages parameter to exclude classes.

Encoding Infinispan caches and marshalling data / Marshalling custom objects with ProtoStream / ProtoStream marshalling / ProtoStream annotations

検索用のエンティティを定義する

次は、検索用のエンティティを定義します。

といっても、先ほどのRecordsの例と同じように@Protoアノテーションを付与したRecordsを作成し、@Indexedアノテーション
各フィールドに検索用のアノテーションを付与していきます。

src/main/java/org/littlewings/infinispan/remote/proto3record/IndexedBook.java

package org.littlewings.infinispan.remote.proto3record;

import org.infinispan.api.annotations.indexing.Basic;
import org.infinispan.api.annotations.indexing.Indexed;
import org.infinispan.api.annotations.indexing.Keyword;
import org.infinispan.api.annotations.indexing.Text;
import org.infinispan.protostream.annotations.Proto;

@Proto
@Indexed
public record IndexedBook(
        @Keyword(sortable = true)
        String isbn,
        @Text(analyzer = "standard")
        String title,
        @Basic(sortable = true)
        int price
) {
}

これらのアノテーションはInfinispan 14.0で追加されたInfinispan用のアノテーションHibernate Searchではなく)で、infinispan-api
含まれています。

        <!-- Infinispanのインデックスアノテーションで利用 -->
        <dependency>
            <groupId>org.infinispan</groupId>
            <artifactId>infinispan-api</artifactId>
            <version>15.0.0.Final</version>
        </dependency>

Infinispan 14.0の新しいインデックス用のアノテーションをHot Rodで試す - CLOVER🍀

Infinispan 14.xからは、以下の2つが増えています。

  • org.infinispan.api.annotations.indexing.@Vector
  • org.infinispan.api.annotations.indexing.option.VectorSimilarity列挙型

どちらもベクトル検索用ですね。

ドキュメントにはまだ載っていませんが。

Querying Infinispan caches / Indexing Infinispan caches / Infinispan native indexing annotations

余談でした。

では、エンティティをIDLの生成対象に追加して

@ProtoSchema(
        includeClasses = {Book.class, IndexedBook.class},
        schemaFileName = "entities.proto",
        schemaFilePath = "proto/",
        schemaPackageName = "entity",
        syntax = ProtoSyntax.PROTO3
)
public interface EntitiesInitializer extends GeneratedSchema {
}

ビルドして結果を確認。

target/classes/proto/entities.proto

// File name: entities.proto
// Generated from : entities.proto
syntax = "proto3";
package entity;



message Book {

   string isbn = 1;

   string title = 2;

   int32 price = 3;
}


/**
 * @Indexed
 */
message IndexedBook {

   /**
    * @Keyword(sortable=true)
    */
   string isbn = 1;

   /**
    * @Text(analyzer="standard")
    */
   string title = 2;

   /**
    * @Basic(sortable=true)
    */
   int32 price = 3;
}

良さそうです。もうちょっと細かくバリエーション確認したいところですが、それまた後でやりましょう。

テストで確認してみる

それでは、テストを書いて確認していってみましょう。

まずはテストコードの雛形を作成。

src/test/java/org/littlewings/infinispan/remote/proto3record/SupportProto3RecordMessageTest.java

package org.littlewings.infinispan.remote.proto3record;

import org.infinispan.client.hotrod.RemoteCache;
import org.infinispan.client.hotrod.RemoteCacheManager;
import org.infinispan.client.hotrod.RemoteCacheManagerAdmin;
import org.infinispan.commons.api.query.Query;
import org.infinispan.configuration.cache.CacheMode;
import org.infinispan.configuration.cache.IndexStartupMode;
import org.infinispan.configuration.cache.IndexStorage;
import org.infinispan.query.remote.client.ProtobufMetadataManagerConstants;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.function.Consumer;

import static org.assertj.core.api.Assertions.assertThat;

class SupportProto3RecordMessageTest {
    String createUri(String userName, String password) {
        return String.format(
                "hotrod://%s:%s@172.18.0.2:11222,172.18.0.3:11222,172.18.0.4:11222"
                        + "?context-initializers=org.littlewings.infinispan.remote.proto3record.EntitiesInitializerImpl",
                userName,
                password
        );
    }

    @BeforeEach
    void setUp() {
        String uri = createUri("ispn-admin", "password");

        try (RemoteCacheManager manager = new RemoteCacheManager(uri)) {
            manager.getConfiguration().getContextInitializers().forEach(serializationContextInitializer -> {
                RemoteCache<String, String> protoCache = manager.getCache(ProtobufMetadataManagerConstants.PROTOBUF_METADATA_CACHE_NAME);
                protoCache.put("entities.proto", serializationContextInitializer.getProtoFile());
            });

            RemoteCacheManagerAdmin admin = manager.administration();

            // インデックスなしのDistributed Cache
            org.infinispan.configuration.cache.Configuration indexLessDistCacheConfiguration =
                    new org.infinispan.configuration.cache.ConfigurationBuilder()
                            .clustering()
                            .cacheMode(org.infinispan.configuration.cache.CacheMode.DIST_SYNC)
                            .encoding().key().mediaType("application/x-protostream")
                            .encoding().value().mediaType("application/x-protostream")
                            .build();

            // キャッシュがない場合は作成、すでにある場合はデータを削除
            admin.getOrCreateCache("bookCache", indexLessDistCacheConfiguration)
                    .clear();

            // インデックスありのDistributed Cache
            org.infinispan.configuration.cache.Configuration indexedDistCacheConfiguration =
                    new org.infinispan.configuration.cache.ConfigurationBuilder()
                            .clustering()
                            .cacheMode(CacheMode.DIST_SYNC)
                            .encoding().key().mediaType("application/x-protostream")
                            .encoding().value().mediaType("application/x-protostream")
                            // indexing
                            .indexing()
                            .enable()
                            .addIndexedEntities("entity.IndexedBook")
                            .storage(IndexStorage.FILESYSTEM)
                            .path("${infinispan.server.data.path}/index/indexedBookCache")
                            .startupMode(IndexStartupMode.REINDEX)
                            .reader().refreshInterval(0L)  // default 0
                            .writer().commitInterval(1000)  // default null
                            .build();

            // キャッシュがない場合は作成、すでにある場合はデータを削除
            admin.getOrCreateCache("indexedBookCache", indexedDistCacheConfiguration)
                    .clear();
        }
    }

    <K, V> void withCache(String cacheName, Consumer<RemoteCache<K, V>> func) {
        String uri = createUri("ispn-user", "password");

        try (RemoteCacheManager manager = new RemoteCacheManager(uri)) {
            RemoteCache<K, V> cache = manager.getCache(cacheName);

            func.accept(cache);
        }
    }

    // ここに、テストを書く!!

}

記載しているメソッドの説明を少ししておきます。

Infinispan Server接続用のURLを生成するメソッド。接続時のユーザー名とパスワードは引数で指定します。

    String createUri(String userName, String password) {
        return String.format(
                "hotrod://%s:%s@172.18.0.2:11222,172.18.0.3:11222,172.18.0.4:11222"
                        + "?context-initializers=org.littlewings.infinispan.remote.proto3record.EntitiesInitializerImpl",
                userName,
                password
        );
    }

context-initializersには、GeneratedSchemaインターフェースを継承したインターフェースから生成されたクラスのFQCNを指定します。
Implが接尾語になります。

テストで使用するキャッシュの定義。キャッシュがない場合は作成し、存在する場合はデータを削除します。

管理用ユーザーで接続して

    @BeforeEach
    void setUp() {
        String uri = createUri("ispn-admin", "password");

        try (RemoteCacheManager manager = new RemoteCacheManager(uri)) {

生成したProtocol BuffersのIDLを登録。

            manager.getConfiguration().getContextInitializers().forEach(serializationContextInitializer -> {
                RemoteCache<String, String> protoCache = manager.getCache(ProtobufMetadataManagerConstants.PROTOBUF_METADATA_CACHE_NAME);
                protoCache.put("entities.proto", serializationContextInitializer.getProtoFile());
            });

RemoteCacheManagerAdminを取得して

            RemoteCacheManagerAdmin admin = manager.administration();

インデックスなしのDistributed Cacheを作成。エンコーディングはProtocol Buffersです。

            // インデックスなしのDistributed Cache
            org.infinispan.configuration.cache.Configuration indexLessDistCacheConfiguration =
                    new org.infinispan.configuration.cache.ConfigurationBuilder()
                            .clustering()
                            .cacheMode(org.infinispan.configuration.cache.CacheMode.DIST_SYNC)
                            .encoding().key().mediaType("application/x-protostream")
                            .encoding().value().mediaType("application/x-protostream")
                            .build();

            // キャッシュがない場合は作成、すでにある場合はデータを削除
            admin.getOrCreateCache("bookCache", indexLessDistCacheConfiguration)
                    .clear();

インデックスありのキャッシュもDistributed Cache、エンコーディングはProtocol Buffersで作成します。

            // インデックスありのDistributed Cache
            org.infinispan.configuration.cache.Configuration indexedDistCacheConfiguration =
                    new org.infinispan.configuration.cache.ConfigurationBuilder()
                            .clustering()
                            .cacheMode(CacheMode.DIST_SYNC)
                            .encoding().key().mediaType("application/x-protostream")
                            .encoding().value().mediaType("application/x-protostream")
                            // indexing
                            .indexing()
                            .enable()
                            .addIndexedEntities("entity.IndexedBook")
                            .storage(IndexStorage.FILESYSTEM)
                            .path("${infinispan.server.data.path}/index/indexedBookCache")
                            .startupMode(IndexStartupMode.REINDEX)
                            .reader().refreshInterval(0L)  // default 0
                            .writer().commitInterval(1000)  // default null
                            .build();

            // キャッシュがない場合は作成、すでにある場合はデータを削除
            admin.getOrCreateCache("indexedBookCache", indexedDistCacheConfiguration)
                    .clear();
        }
    }

インデックス登録対象のエンティティを指定して、インデックスの保存先はファイルシステム、再起動時にはインデックスの再作成を
行うように設定してリフレッシュインターバルやコミット感覚なども調整しています。

これで、テストを実行するとInfinispan Server側に以下の設定のキャッシュが作成されます。

server/data/caches.xml

<?xml version="1.0"?>
<infinispan xmlns="urn:infinispan:config:15.0">
    <cache-container>
        <caches>
            <distributed-cache name="bookCache" mode="SYNC" remote-timeout="17500" statistics="true">
                <encoding>
                    <key media-type="application/x-protostream"/>
                    <value media-type="application/x-protostream"/>
                </encoding>
                <locking concurrency-level="1000" acquire-timeout="15000" striping="false"/>
                <state-transfer timeout="60000"/>
            </distributed-cache>
            <distributed-cache name="indexedBookCache" mode="SYNC" remote-timeout="17500" statistics="true">
                <encoding>
                    <key media-type="application/x-protostream"/>
                    <value media-type="application/x-protostream"/>
                </encoding>
                <locking concurrency-level="1000" acquire-timeout="15000" striping="false"/>
                <indexing enabled="true" storage="filesystem" startup-mode="REINDEX" path="/opt/infinispan-server/server/data/index/indexedBookCache">
                    <index-writer commit-interval="1000"/>
                    <indexed-entities>
                        <indexed-entity>entity.IndexedBook</indexed-entity>
                    </indexed-entities>
                </indexing>
                <state-transfer timeout="60000"/>
            </distributed-cache>
        </caches>
    </cache-container>
</infinispan>

インデックスの設定は、こちらを参照。

Querying Infinispan caches / Indexing Infinispan caches / Configuring Infinispan to index caches

あとはテスト内でアプリケーション用ユーザーでInfinispan Serverに接続して、RemoteCacheを使えるようにするための簡易メソッドです。

    <K, V> void withCache(String cacheName, Consumer<RemoteCache<K, V>> func) {
        String uri = createUri("ispn-user", "password");

        try (RemoteCacheManager manager = new RemoteCacheManager(uri)) {
            RemoteCache<K, V> cache = manager.getCache(cacheName);

            func.accept(cache);
        }
    }

これらを使って、テストを書いていきます。

Recordsをマーシャリングできることを確認する

最初は、単にRecordsを使えることを確認します。

    @Test
    void records() {
        this.<String, Book>withCache("bookCache", cache -> {
            Book infinispanBook = new Book("978-1782169970", "Infinispan Data Grid Platform Definitive Guide", 5242);
            Book redisBook = new Book("978-4798045733", "RDB技術者のためのNoSQLガイド", 3400);
            Book hazelcastBook = new Book("978-1785285332", "Getting Started with Hazelcast - Second Edition", 4129);
            Book igniteBook = new Book("978-1789347531", "Apache Ignite Quick Start Guide: Distributed data caching and processing made easy", 3476);

            cache.put(infinispanBook.isbn(), infinispanBook);
            cache.put(redisBook.isbn(), redisBook);
            cache.put(hazelcastBook.isbn(), hazelcastBook);
            cache.put(igniteBook.isbn(), igniteBook);

            assertThat(cache.size()).isEqualTo(4);

            assertThat(cache.get(infinispanBook.isbn())).isEqualTo(infinispanBook);
            assertThat(cache.get(redisBook.isbn())).isEqualTo(redisBook);
            assertThat(cache.get(hazelcastBook.isbn())).isEqualTo(hazelcastBook);
            assertThat(cache.get(igniteBook.isbn())).isEqualTo(igniteBook);

            cache.clear();
            assertThat(cache.size()).isEqualTo(0);
        });
    }

まあ、ふつうですね…。

インデックスなしの検索をしてみる

続いて、インデックスなしの検索をしてみましょう。

こんな感じです。

    @Test
    void indexLessQuery() {
        this.<String, Book>withCache("bookCache", cache -> {
            List<Book> books = List.of(
                    new Book("978-1782169970", "Infinispan Data Grid Platform Definitive Guide", 5242),
                    new Book("978-4798045733", "RDB技術者のためのNoSQLガイド", 3400),
                    new Book("978-1785285332", "Getting Started with Hazelcast - Second Edition", 4129),
                    new Book("978-1789347531", "Apache Ignite Quick Start Guide: Distributed data caching and processing made easy", 3476)
            );

            books.forEach(book -> cache.put(book.isbn(), book));

            assertThat(cache.size()).isEqualTo(4);

            Query<Book> query =
                    cache.query("from entity.Book where price > :price order by price desc");
            query.setParameter("price", 4000);

            List<Book> results = query.execute().list();

            assertThat(results).hasSize(2);
            assertThat(results.get(0).title()).isEqualTo("Infinispan Data Grid Platform Definitive Guide");
            assertThat(results.get(1).title()).isEqualTo("Getting Started with Hazelcast - Second Edition");
        });
    }

クエリーの書き方は、こちらを参照。

Querying Infinispan caches / Creating Ickle queries

今までと少し変わったポイントですが、RemoteCacheに対して直接クエリーを渡せるようになりました。

            Query<Book> query =
                    cache.query("from entity.Book where price > :price order by price desc");
            query.setParameter("price", 4000);

            List<Book> results = query.execute().list();

Infinispan 14.xまでは、以下のように1度SearchQueryFactoryを挟む必要がありました。

            QueryFactory queryFactory = Search.getQueryFactory(cache);
            Query<Book> query =
                    queryFactory.create("from entity.Book where price > :price order by price desc");
            query.setParameter("price", 4000);

            List<Book> results = query.execute().list();

このテストコードは問題なく動くのですが、Infinispan Server側では以下のようにWARNログが出力されます。

2024-03-23 09:57:24,793 WARN  [o.i.q.c.i.BaseEmbeddedQuery] ISPN014827: Distributed sort not supported for non-indexed query 'from entity.Book where price > :price order by price desc'. Consider using an index for optimal performance.

これはソートを使っているからですね。

            Query<Book> query =
                    cache.query("from entity.Book where price > :price order by price desc");

ソートを使う場合はインデックスを定義した方がよい、という示唆です。

インデックスを使った検索をしてみる

最後は、インデックスを使った検索です。

使用するキャッシュおよびエンティティは、インデックスの設定を行ったものにします。

    @Test
    void indexedQuery() {
        this.<String, IndexedBook>withCache("indexedBookCache", cache -> {
            List<IndexedBook> books = List.of(
                    new IndexedBook("978-1782169970", "Infinispan Data Grid Platform Definitive Guide", 5242),
                    new IndexedBook("978-4798045733", "RDB技術者のためのNoSQLガイド", 3400),
                    new IndexedBook("978-1785285332", "Getting Started with Hazelcast - Second Edition", 4129),
                    new IndexedBook("978-1789347531", "Apache Ignite Quick Start Guide: Distributed data caching and processing made easy", 3476)
            );

            books.forEach(book -> cache.put(book.isbn(), book));

            assertThat(cache.size()).isEqualTo(4);

            Query<IndexedBook> query1 =
                    cache.query("from entity.IndexedBook where title: 'guide' and price: [4000 to *] order by price");

            List<IndexedBook> results1 = query1.execute().list();

            assertThat(results1).hasSize(1);
            assertThat(results1.get(0).title()).isEqualTo("Infinispan Data Grid Platform Definitive Guide");

            Query<IndexedBook> query2 =
                    cache.query("from entity.IndexedBook where isbn = '978-4798045733'");

            List<IndexedBook> results2 = query2.execute().list();

            assertThat(results2).hasSize(1);
            assertThat(results2.get(0).title()).isEqualTo("RDB技術者のためのNoSQLガイド");

            Query<IndexedBook> query3 =
                    cache.query("from entity.IndexedBook where (title: 'data' and title: 'guide') or price: [* to 3400] order by price desc, isbn asc");

            List<IndexedBook> results3 = query3.execute().list();

            assertThat(results3).hasSize(3);
            assertThat(results3.get(0).title()).isEqualTo("Infinispan Data Grid Platform Definitive Guide");
            assertThat(results3.get(1).title()).isEqualTo("Apache Ignite Quick Start Guide: Distributed data caching and processing made easy");
            assertThat(results3.get(2).title()).isEqualTo("RDB技術者のためのNoSQLガイド");
        });
    }

インデックスを使った検索を行う場合、Hibernate SearchおよびApache Luceneを使った全文検索になり、クエリーの構文が少し変わります。
こちらを参照してください。

Querying Infinispan caches / Creating Ickle queries / Full-text queries

これで、RecordsおよびProtocol Buffers 3を使ってデータの操作や検索ができることを確認しました。

オマケ

ここまでで確認したかったことはだいたい終わったのですが、少し気になることを見ていきます。

Recordsへの対応はどうしているのか?

クラスに対してRecordのサブクラスかどうかの判定が追加され、Recordsかどうかを見分けています。

https://github.com/infinispan/protostream/blob/5.0.1.Final/core/src/main/java/org/infinispan/protostream/annotations/impl/types/XClass.java#L91-L93

こちらを使って、コンストラクターの取得と

https://github.com/infinispan/protostream/blob/5.0.1.Final/core/src/main/java/org/infinispan/protostream/annotations/impl/ProtoMessageTypeMetadata.java#L317-L320

各フィールドの取得がRecords用に分かれています。

https://github.com/infinispan/protostream/blob/5.0.1.Final/core/src/main/java/org/infinispan/protostream/annotations/impl/ProtoMessageTypeMetadata.java#L370-L373

https://github.com/infinispan/protostream/blob/5.0.1.Final/core/src/main/java/org/infinispan/protostream/annotations/impl/ProtoMessageTypeMetadata.java#L726-L797

Recordsでなくても@Protoアノテーションは使えるのか?

気になって試してみましたが、機能していましたね。

src/main/java/org/littlewings/infinispan/remote/proto3record/PlainBook.java

package org.littlewings.infinispan.remote.proto3record;

import org.infinispan.protostream.annotations.Proto;

@Proto
public class PlainBook {
    String isbn;
    String title;
    int price;

    // getter/setterは省略
}

生成されたIDLの一部。

message PlainBook {

   string isbn = 1;

   string title = 2;

   int32 price = 3;
}

ここから先は、以下のような@ProtoSchema#includeClassesへの追加は省略します。

@ProtoSchema(
        includeClasses = {Book.class, IndexedBook.class, PlainBook.class},
Recordsでも@ProtoFieldは使えるのか?

使えました。

src/main/java/org/littlewings/infinispan/remote/proto3record/BookPerField.java

package org.littlewings.infinispan.remote.proto3record;

import org.infinispan.protostream.annotations.ProtoField;

public record BookPerField(
        @ProtoField(number = 1)
        String isbn,
        @ProtoField(number = 2)
        String title,
        @ProtoField(number = 3)
        int price
) {
}
@Protoアノテーションと@ProtoFieldアノテーションを組み合わせると?

試してみます。

`src/main/java/org/littlewings/infinispan/remote/proto3record/BookPerField2.java

package org.littlewings.infinispan.remote.proto3record;

import org.infinispan.protostream.annotations.Proto;
import org.infinispan.protostream.annotations.ProtoField;

@Proto
public record BookPerField2(
        String isbn,
        @ProtoField(number = 2)
        String title,
        @ProtoField(number = 1, name = "price2")
        int price
) {
}

確認。

message BookPerField2 {

   string isbn = 1;

   string title = 2;

   int32 price = 3;
}

IDLは生成されましたが、どうも@ProtoFieldアノテーションの内容が無視されているように見えます…。

ふつうのクラスで試してみましょう。

src/main/java/org/littlewings/infinispan/remote/proto3record/PlainBookPerField2.java

package org.littlewings.infinispan.remote.proto3record;

import org.infinispan.protostream.annotations.Proto;
import org.infinispan.protostream.annotations.ProtoField;

@Proto
public class PlainBookPerField2 {
    String isbn;
    @ProtoField(number = 2)
    String title;
    @ProtoField(number = 1, name = "price2")
    int price;

    // getter/setterは省略

}

すると、コンパイル時にエラーになりました。

[ERROR] /path/to/src/main/java/org/littlewings/infinispan/remote/proto3record/EntitiesInitializer.java:[14,8] org.infinispan.protostream.annotations.ProtoSchemaBuilderException: Duplicate field number definition. Found two field definitions with number 1: in price and in isbn, while processing org.littlewings.infinispan.remote.proto3record.EntitiesInitializer

この感じからすると、@Protoアノテーション@ProtoFieldアノテーションを組み合わせることは想定していなさそうですね。

このように変更すると生成に成功するようになりますが、@Protoアノテーションの意味がないですね…。

@Proto
public class PlainBookPerField2 {
    @ProtoField(number = 3)
    String isbn;
    @ProtoField(number = 2)
    String title;
    @ProtoField(number = 1, name = "price2")
    int price;

それはそれとして、Recordsで以下のようにnumberを変更しても

public record BookPerField(
        @ProtoField(number = 3)
        String isbn,
        @ProtoField(number = 2)
        String title,
        @ProtoField(number = 1)
        int price
) {
}

生成される定義は変わらなかったので、Recordsの方は@ProtoFieldアノテーションの扱いがちょっと違うのかもしれません…。

message BookPerField {

   string isbn = 1;

   string title = 2;

   int32 price = 3;
}

ちなみに、@ProtoアノテーションJavadocの方を見てみるとnumber@ProtoFieldアノテーションでオーバーライドできそうな
ことが書かれているのですが…。

Use this annotation on records or classes with public fields to quickly generate protocol buffers messages.
Fields must be public and they will be assigned incremental numbers based on the declaration order.
It is possible to override the automated defaults for a field by using the {@link ProtoField} annotation.

https://github.com/infinispan/protostream/blob/5.0.1.Final/core/src/main/java/org/infinispan/protostream/annotations/Proto.java#L14

まあ、確認のやり方がちょっと意地悪気味だった気がします。

protostream-processorを2回書いたのは?

いろいろあって、pom.xmlprotostream-processorを2回書くことになりました。

dependency

        <dependency>
            <groupId>org.infinispan.protostream</groupId>
            <artifactId>protostream-processor</artifactId>
            <version>5.0.1.Final</version>
            <scope>provided</scope>
        </dependency>

Maven Compiler Pluginへの設定ですね。

    <build>
        <plugins>
            <plugin>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.11.0</version>
                <configuration>
                    <annotationProcessorPaths>
                        <annotationProcessorPath>
                            <groupId>org.infinispan.protostream</groupId>
                            <artifactId>protostream-processor</artifactId>
                            <version>5.0.1.Final</version>
                        </annotationProcessorPath>
                    </annotationProcessorPaths>
                </configuration>
            </plugin>
        </plugins>
    </build>

ドキュメントに習うとMaven Compiler Pluginに設定しておけばそれでよいはずなのですが、dependencyの方を削ると

        <!--
        <dependency>
            <groupId>org.infinispan.protostream</groupId>
            <artifactId>protostream-processor</artifactId>
            <version>5.0.1.Final</version>
            <scope>provided</scope>
        </dependency>
        -->

ビルド時にこういうエラーを見ることになります。

[ERROR] /path/to/target/generated-sources/annotations/org/littlewings/infinispan/remote/proto3record/EntitiesInitializerImpl.java:[16,55] パッケージorg.infinispan.protostream.annotations.impl.processorは存在しません

GeneratedSchemaインターフェースを拡張したインターフェースから自動生成されるのがこちらのクラスなのですが、

target/generated-sources/annotations/org/littlewings/infinispan/remote/proto3record/EntitiesInitializerImpl.java

/*
 Generated by org.infinispan.protostream.annotations.impl.processor.AutoProtoSchemaBuilderAnnotationProcessor
 for class org.littlewings.infinispan.remote.proto3record.EntitiesInitializer
 annotated with @org.infinispan.protostream.annotations.ProtoSchema(syntax=PROTO3, dependsOn={}, marshallersOnly=false, excludeClasses={}, includeClasses={org.littlewings.infinispan.remote.proto3record.Book.class, org.littlewings.infinispan.remote.proto3record.IndexedBook.class}, basePackages={}, value={}, schemaPackageName="entity", schemaFilePath="proto/", schemaFileName="entities.proto", className="")
 */

package org.littlewings.infinispan.remote.proto3record;

/**
 * WARNING: Generated code! Do not edit!
 */
@javax.annotation.processing.Generated(
   value = "org.infinispan.protostream.annotations.impl.processor.AutoProtoSchemaBuilderAnnotationProcessor",
   comments = "Please do not edit this file!"
)
@org.infinispan.protostream.annotations.impl.processor.OriginatingClasses({
   org.littlewings.infinispan.remote.proto3record.Book.class,
   org.littlewings.infinispan.remote.proto3record.IndexedBook.class
})
/*@@org.infinispan.protostream.annotations.ProtoSchema(syntax=PROTO3, dependsOn={}, marshallersOnly=false, excludeClasses={}, includeClasses={org.littlewings.infinispan.remote.proto3record.Book.class, org.littlewings.infinispan.remote.proto3record.IndexedBook.class}, basePackages={}, value={}, schemaPackageName="entity", schemaFilePath="proto/", schemaFileName="entities.proto", className="")(
   className = "EntitiesInitializerImpl",
   schemaFileName = "entities.proto",
   schemaFilePath = "proto/",
   schemaPackageName = "entity",
   service = true,
   marshallersOnly = false,
   autoImportClasses = false,
   includeClasses = {
      org.littlewings.infinispan.remote.proto3record.Book.class,
      org.littlewings.infinispan.remote.proto3record.IndexedBook.class
   }
)*/
@SuppressWarnings("all")
public class EntitiesInitializerImpl implements org.littlewings.infinispan.remote.proto3record.EntitiesInitializer {

   @Override
public String getProtoFileName() { return "entities.proto"; }

   @Override
public String getProtoFile() { return org.infinispan.protostream.impl.ResourceUtils.getResourceAsString(getClass(), "/proto/entities.proto"); }

   @Override
public java.io.Reader getProtoFileReader() { return org.infinispan.protostream.impl.ResourceUtils.getResourceAsReader(getClass(), "/proto/entities.proto"); }

   @Override
   public void registerSchema(org.infinispan.protostream.SerializationContext serCtx) {
      serCtx.registerProtoFiles(org.infinispan.protostream.FileDescriptorSource.fromString(getProtoFileName(), getProtoFile()));
   }

   @Override
   public void registerMarshallers(org.infinispan.protostream.SerializationContext serCtx) {
      serCtx.registerMarshaller(new org.littlewings.infinispan.remote.proto3record.Book$___Marshaller_26c7271529c2c72167f7f17b74ea1f6e329e1bd0bf8efce84d6a0b93c8880dd1());
      serCtx.registerMarshaller(new org.littlewings.infinispan.remote.proto3record.IndexedBook$___Marshaller_aa912249c465a82ad0457d9c154dafa36cb183ce1953c49a5e1ae3159aabb6fe());
   }
}

こちらの部分が解決できなくなります。

@org.infinispan.protostream.annotations.impl.processor.OriginatingClasses({
   org.littlewings.infinispan.remote.proto3record.Book.class,
   org.littlewings.infinispan.remote.proto3record.IndexedBook.class
})

一見、@ProtoSchema#includeClassesの指定が影響しているようにも見えますが、自動検出した結果もこのように記録されるので
回避できません。

ちなみに、こちらをdependencyに入れておけばアノテーションプロセッサーは動作するので

        <dependency>
            <groupId>org.infinispan.protostream</groupId>
            <artifactId>protostream-processor</artifactId>
            <version>5.0.1.Final</version>
            <scope>provided</scope>
        </dependency>

今度はMaven Compiler Pluginの設定を削ると

    <!--
    <build>
        <plugins>
            <plugin>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.11.0</version>
                <configuration>
                    <annotationProcessorPaths>
                        <annotationProcessorPath>
                            <groupId>org.infinispan.protostream</groupId>
                            <artifactId>protostream-processor</artifactId>
                            <version>5.0.1.Final</version>
                        </annotationProcessorPath>
                    </annotationProcessorPaths>
                </configuration>
            </plugin>
        </plugins>
    </build>
    -->

以下のように警告されるので微妙な気分になります。

[INFO] --- compiler:3.11.0:compile (default-compile) @ remote-support-proto3-record ---
[INFO] Changes detected - recompiling the module! :source
[INFO] Compiling 3 source files with javac [debug release 21] to target/classes
[INFO] クラス・パスに1つ以上のプロセッサが見つかったため、注釈処理が有効化されて
  います。少なくとも1つのプロセッサが名前(-processor)で指定されるか、検索パス
  (--processor-path、--processor-module-path)が指定されるか、注釈処理が明示的に
  有効化(-proc:only、-proc:full)されている場合を除き、将来のリリースのjavacでは
  注釈処理が無効化される可能性があります。
  -Xlint:オプションを使用すると、このメッセージを非表示にできます。
  -proc:noneを使用すると、注釈処理を無効化できます。

というわけで、両方ともに入れることになりました…。

おわりに

細かく見ていくといろいろ気になるところはあるのですが、ProtoStreamでProtocol Buffers 3とRecordsが使えるようになりました。

Protocol Buffers 3については、まだ対応しないのかな?と長らく思っていたりしたので良いことかなと思います。
Recordsも含め、ふつうに使ってみて問題なさそうでしたし。

これからはProtoStreamを使う時には、Protocol Buffers 3とRecordsを使っていくようにしましょう。

今回作成したソースコードは、こちらに置いています。

https://github.com/kazuhira-r/infinispan-getting-started/tree/master/remote-support-proto3-record