CLOVER🍀

That was when it all began.

Queryアノテーションが使えるようになった、Spring Data Hazelcastを試す

Spring Data Hazelcast 1.1が半年くらい前にリリースされていたのですが、このバージョンから@Queryアノテーションを
使うことができるようになっていたみたいです。

GitHub - hazelcast/spring-data-hazelcast: Hazelcast Spring Data integration Project http://projects.spring.io/spring-data/

Release 1.1 · hazelcast/spring-data-hazelcast · GitHub

ちょっと気になるなーということで、試してみましょうかと。

なお、Spring Data Hazelcast 1.0については、以前に試しています。基本的な説明はこちらでしているので、興味のある方は
見ておくとよろしいかと思います。

Spring Data Hazelcastで遊ぶ - CLOVER

準備

pom.xmlの定義は、このようにしました。

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <java.version>1.8</java.version>
        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>1.8</maven.compiler.target>
        <spring.boot.version>1.5.9.RELEASE</spring.boot.version>
    </properties>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring.boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
        </dependencies>
    </dependencyManagement>

    <dependencies>
        <dependency>
            <groupId>com.hazelcast</groupId>
            <artifactId>spring-data-hazelcast</artifactId>
            <version>1.1.1</version>
        </dependency>

        <dependency>
            <groupId>com.hazelcast</groupId>
            <artifactId>hazelcast</artifactId>
            <version>3.9.1</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <version>${spring.boot.version}</version>
                <executions>
                    <execution>
                        <goals>
                            <goal>repackage</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

まずは、Spring Data Hazelcast。

        <dependency>
            <groupId>com.hazelcast</groupId>
            <artifactId>spring-data-hazelcast</artifactId>
            <version>1.1.1</version>
        </dependency>

Spring Data Hazelcastが依存しているHazelcastのバージョンは3.6なので、なんとなく引き上げてみます。

        <dependency>
            <groupId>com.hazelcast</groupId>
            <artifactId>hazelcast</artifactId>
            <version>3.9.1</version>
        </dependency>

あとは、お手軽に動作確認するために、Spring Bootで。

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>

Entity

とりあえず、お題は書籍で。
src/main/java/org/littlewings/hazelcast/spring/entity/Book.java

package org.littlewings.hazelcast.spring.entity;

import java.io.Serializable;

import org.springframework.data.annotation.Id;
import org.springframework.data.keyvalue.annotation.KeySpace;

@KeySpace("books")
public class Book implements Serializable {
    private static final long serialVersionUID = 1L;

    @Id
    private String isbn;
    private String title;
    private int price;

    public static Book create(String isbn, String title, int price) {
        Book book = new Book();
        book.setIsbn(isbn);
        book.setTitle(title);
        book.setPrice(price);

        return book;
    }

    public String getIsbn() {
        return isbn;
    }

    public void setIsbn(String isbn) {
        this.isbn = isbn;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public int getPrice() {
        return price;
    }

    public void setPrice(int price) {
        this.price = price;
    }
}

Repository

続いて、Repositoryを定義します。HazelcastRepositoryインターフェースを拡張したインターフェースを作成します。
src/main/java/org/littlewings/hazelcast/spring/repository/BookRepository.java

package org.littlewings.hazelcast.spring.repository;

import java.util.List;

import org.littlewings.hazelcast.spring.entity.Book;
import org.springframework.data.hazelcast.repository.HazelcastRepository;
import org.springframework.data.hazelcast.repository.query.Query;

public interface BookRepository extends HazelcastRepository<Book, String> {
    @Query("title = '%s'")
    List<Book> findByTitle(String title);

    @Query("price > %d")
    List<Book> findByPriceGreaterThan(int price);

    @Query("isbn = '%s' and price > %d")
    List<Book> findByIsbnAndGreaterThanPrice(String isbn, int price);
}

@Queryでクエリを定義できるのですが、いくつか注意点があるので、そちらについては後ほど。

なお、従来どおりメソッド名でのクエリの作成も可能です。

Config

最後はConfigです。こちらについては、GitHubリポジトリに書かれているサンプルとはちょっと変えています。
src/main/java/org/littlewings/hazelcast/spring/HazelcastConfig.java

package org.littlewings.hazelcast.spring;

import com.hazelcast.config.Config;
import com.hazelcast.core.Hazelcast;
import com.hazelcast.core.HazelcastInstance;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.hazelcast.HazelcastKeyValueAdapter;
import org.springframework.data.hazelcast.repository.config.Constants;
import org.springframework.data.hazelcast.repository.config.EnableHazelcastRepositories;
import org.springframework.data.keyvalue.core.KeyValueOperations;
import org.springframework.data.keyvalue.core.KeyValueTemplate;

@Configuration
@EnableHazelcastRepositories
public class HazelcastConfig {
    @Bean
    public KeyValueOperations keyValueTemplate(HazelcastKeyValueAdapter keyValueAdapter) {
        return new KeyValueTemplate(keyValueAdapter);
    }

    @Bean
    public HazelcastKeyValueAdapter hazelcastKeyValueAdapter() {
        return new HazelcastKeyValueAdapter();
    }
}

変えた理由は落とし穴があるからなのですが、こちらも後で記載します。

とりあえず、最低限としてはEnableHazelcastRepositoriesアノテーションを付与して、HazelcastKeyValueAdapterとKeyValueOperationsをBean定義
しておけばOKです。

動かしてみる

では、こちらをテストコードを書いて動かしてみます。

テストコードの雛形は、こちら。
src/test/java/org/littlewings/hazelcast/spring/SpringDataHazelcastTest.java

package org.littlewings.hazelcast.spring;

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

import com.hazelcast.core.Hazelcast;
import com.hazelcast.core.HazelcastInstance;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.littlewings.hazelcast.spring.entity.Book;
import org.littlewings.hazelcast.spring.repository.BookRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

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

@RunWith(SpringRunner.class)
@SpringBootTest(classes = HazelcastConfig.class)
public class SpringDataHazelcastTest {
    List<Book> books =
            Arrays.asList(
                    Book.create("978-1785285332", "Getting Started With Hazelcast", 3812),
                    Book.create("978-4798142470", "Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発", 4320),
                    Book.create("978-4774182179", "[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ", 4104),
                    Book.create("978-4777519699", "はじめてのSpring Boot―スプリング・フレームワークで簡単Javaアプリ開発", 2700)
            );

    @Autowired
    BookRepository bookRepository;

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

    void withOtherHazelcastInstances(int numInstances, Runnable runnable) {
        List<HazelcastInstance> hazelcasts =
                IntStream
                        .rangeClosed(1, numInstances)
                        .mapToObj(i -> Hazelcast.newHazelcastInstance())
                        .collect(Collectors.toList());

        try {
            runnable.run();
        } finally {
            hazelcasts.forEach(HazelcastInstance::shutdown);
        }
    }
}

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

テストデータはあらかじめ用意しておくのと、先ほど定義したRepositoryはAutowiredするように作成しておきます。

    List<Book> books =
            Arrays.asList(
                    Book.create("978-1785285332", "Getting Started With Hazelcast", 3812),
                    Book.create("978-4798142470", "Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発", 4320),
                    Book.create("978-4774182179", "[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ", 4104),
                    Book.create("978-4777519699", "はじめてのSpring Boot―スプリング・フレームワークで簡単Javaアプリ開発", 2700)
            );

    @Autowired
    BookRepository bookRepository;

まずは、1件だけヒットするように仕組んだものでテストしてみます。

    @Test
    public void simpleQuery() {
        withOtherHazelcastInstances(2, () -> {
            bookRepository.save(books);

            List<Book> resultBooks = bookRepository.findByTitle(books.get(0).getTitle());

            assertThat(resultBooks).hasSize(1);
            assertThat(resultBooks.get(0).getIsbn()).isEqualTo("978-1785285332");
            assertThat(resultBooks.get(0).getTitle()).isEqualTo("Getting Started With Hazelcast");
        });
    }

HazelcastのNode数は、3にしてみました。テスト内で起動するNodeが2、Spring Data Hazelcastを使うためのBean定義で起動するNodeが1、です。

ここで、返ってくる件数とかメソッドの定義によらず、返ってくるデータ型はコレクションとなります。

            List<Book> resultBooks = bookRepository.findByTitle(books.get(0).getTitle());

1件しか返らないと思って、こんなメソッドを定義して

    @Query("title = '%s'")
    Book findByTitle(String title);

1件だけ受け取るようなコードを書いていると

            Book resultBook = bookRepository.findByTitle(books.get(0).getTitle());

キャストできないと怒られます。

java.lang.ClassCastException: com.hazelcast.map.impl.query.QueryResultCollection cannot be cast to org.littlewings.hazelcast.spring.entity.Book

また、よくよく見るとメソッドの引数をあてている箇所は、%sで書く上にシングルクォートで囲っておく必要があります。

    @Query("title = '%s'")
    List<Book> findByTitle(String title);

はい。

もうちょっとバリエーションを。

数値を使う検索。

    @Test
    public void greaterThanQuery() {
        withOtherHazelcastInstances(2, () -> {
            bookRepository.save(books);

            List<Book> resultBooks = bookRepository.findByPriceGreaterThan(4000);

            assertThat(resultBooks).hasSize(2);
            assertThat(resultBooks.get(0).getIsbn()).isEqualTo("978-4798142470");
            assertThat(resultBooks.get(0).getTitle()).isEqualTo("Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発");
            assertThat(resultBooks.get(0).getPrice()).isEqualTo(4320);
            assertThat(resultBooks.get(1).getIsbn()).isEqualTo("978-4774182179");
            assertThat(resultBooks.get(1).getTitle()).isEqualTo("[改訂新版]Spring入門 ――Javaフレームワーク・より良い設計とアーキテクチャ");
            assertThat(resultBooks.get(1).getPrice()).isEqualTo(4104);
        });
    }

元のクエリ定義は、こちらです。

    @Query("price > %d")
    List<Book> findByPriceGreaterThan(int price);

%dで定義。

andとかも使えます。

    @Query("isbn = '%s' and price > %d")
    List<Book> findByIsbnAndGreaterThanPrice(String isbn, int price);

利用例。

    @Test
    public void andQuery() {
        withOtherHazelcastInstances(2, () -> {
            bookRepository.save(books);

            List<Book> resultBooks1 =
                    bookRepository.findByIsbnAndGreaterThanPrice("978-1785285332", 3000);

            assertThat(resultBooks1).hasSize(1);
            assertThat(resultBooks1.get(0).getIsbn()).isEqualTo("978-1785285332");
            assertThat(resultBooks1.get(0).getTitle()).isEqualTo("Getting Started With Hazelcast");

            List<Book> resultBooks2 =
                    bookRepository.findByIsbnAndGreaterThanPrice("978-1785285332", 4000);

            assertThat(resultBooks2).isEmpty();
        });
    }

ちょっと気になるところ

クエリのパラメータに指定する文字列?

@Queryアノテーションでメソッドの引数を指定する際に、%sとか%dとかを使っていました。

    @Query("title = '%s'")
    List<Book> findByTitle(String title);

    @Query("price > %d")
    List<Book> findByPriceGreaterThan(int price);

    @Query("isbn = '%s' and price > %d")
    List<Book> findByIsbnAndGreaterThanPrice(String isbn, int price);

想像には難くありませんが、これはString#formatで指定できる書式と同じです。というか、String#formatが使われています。

文字列が条件になる時には、シングルクォートを省略することもできないのでバインド変数っぽいものではありません。

@Configurationのサンプル?

GitHubのREADME.mdを見ると、@Configurationはこんな感じに書かれているのですが、これは実は@Queryを使う時には動作しません。

@Configuration
@EnableHazelcastRepositories(basePackages={"example.springdata.keyvalue.chemistry"}) (1)
public class ApplicationConfiguration {

    @Bean
    HazelcastInstance hazelcastInstance() {     (2)
        return Hazelcast.newHazelcastInstance();
        // return HazelcastClient.newHazelcastClient();
    }

    @Bean
    public KeyValueOperations keyValueTemplate() {  (3)
        return new KeyValueTemplate(new HazelcastKeyValueAdapter(hazelcastInstance()));
    }

    @Bean
    public HazelcastKeyValueAdapter hazelcastKeyValueAdapter(HazelcastInstance hazelcastInstance) {
        return new HazelcastKeyValueAdapter(hazelcastInstance);
    }
}

HazelcastInstanceの作り方が良くなくて、インスタンスの名前が「spring-data-hazelcast-instance」となるように指定してあげる必要があります。
もしくは、HazelcastKeyValueAdapterを引数なしのコンストラクタを呼び出してインスタンス化するかです。

ソート例がなかったのでは?

残念ながら、@Queryを使った場合にはできません…。その場合は、KeyValueOperationsとKeyValueQueryを使用しましょう。

そもそも、Hazelcastのクエリー単体にはPagingPredicateを除いてソートできる方法が…。

どういうことか?

ここまでの疑問点は、すべてがこのクラスに集約されます。
https://github.com/hazelcast/spring-data-hazelcast/blob/v1.1.1/src/main/java/org/springframework/data/hazelcast/repository/support/StringBasedHazelcastRepositoryQuery.java

@Queryで指定された文字列をHazelcastのPredicateに変換する際に、単純にString#formatで変換しているだけになります。

    @Override
    public Object execute(Object[] parameters) {

        String queryStringTemplate = queryMethod.getAnnotatedQuery();

        String queryString = String.format(queryStringTemplate, parameters);

        SqlPredicate sqlPredicate = new SqlPredicate(queryString);

        return getMap(keySpace).values(sqlPredicate);
    }

また、検索はIMap#valuesなので、戻り値はそりゃあCollectionになりますよね、と…。

中で使われているのは、SqlPredicateです。
Querying with SQL

さらに、ここでgetMapというメソッドがあるのですが、この中でHazelcastInstanceを取得する際には、名前で指定して取得することを期待しています。
この名前が、「spring-data-hazelcast-instance」です(Constantsというクラスに定数として宣言されていますが)。

    private IMap getMap(String keySpace) {
        return Hazelcast.getHazelcastInstanceByName(Constants.HAZELCAST_INSTANCE_NAME).getMap(keySpace);
    }

ここまで見ると、@Queryの引数の指定がString#formatっぽかったり、取得結果についての扱いとかいろいろわかるでしょう。

また@Configurationなクラスについては、明示的にHazelcastInstanceを作成する場合は以下のように名前を指定して作成するのが正解、ということになります。

    @Bean(destroyMethod = "shutdown")
    public HazelcastInstance hazelcastInstance() {
        Config config = new Config();
        config.setInstanceName(Constants.HAZELCAST_INSTANCE_NAME);
        return Hazelcast.newHazelcastInstance(config);
    }

    @Bean
    public KeyValueOperations keyValueTemplate(HazelcastKeyValueAdapter keyValueAdapter) {
        return new KeyValueTemplate(keyValueAdapter);
    }

    @Bean
    public HazelcastKeyValueAdapter hazelcastKeyValueAdapter(HazelcastInstance hazelcast) {
        return new HazelcastKeyValueAdapter(hazelcast);
    }

HazelcastKeyValueAdapterの引数なしのコンストラクタを呼び出した場合は、内部的に「Constants.HAZELCAST_INSTANCE_NAME」が指定された状態で
HazelcastInstanceが作成されます。
https://github.com/hazelcast/spring-data-hazelcast/blob/v1.1.1/src/main/java/org/springframework/data/hazelcast/HazelcastKeyValueAdapter.java#L42

これが、最初のコード例では動作した理由です。

まとめ

まあいろいろあったのですが、とりあえずSpring Data Hazelcastで@Queryを使うことができました。

なんかいろいろ踏んで中身を見ることにはなりましたが、雰囲気はわかったのでよしとしましょう。結論としては、なんとも
微妙な機能な気がしますけどね…。

今回作成したコードは、こちらに置いています。
https://github.com/kazuhira-r/hazelcast-examples/tree/master/hazelcast-spring-data-query