CLOVER🍀

That was when it all began.

Spring Data Geodeで、SpringのCache Abstractionを使う

Spring Data Geodeには、SpringのCache Abstractionで使うCacheManagerの実装が含まれています。
※もとはもちろん、Spring Data Gemfireのものですが

Support for Spring Cache Abstraction

今回、こちらを試してみたいと思います。

なお、構成はClient/Server Modeで行うものとします。

準備

Maven依存関係の定義。Spring Bootを使いつつやります。

    <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.1.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>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-cache</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.data</groupId>
            <artifactId>spring-data-geode</artifactId>
            <version>1.0.0.INCUBATING-RELEASE</version>
        </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 Bootのバージョンは、1.5.1としました。

Spring Data Geode 1.0.0.INCUBATING-RELEASEとSpring Boot 1.5.xの組み合わせはRepositoryがうまく動かない
感じなのですが、CacheManagerなら問題なかろうと…。
で、実際問題ありませんでした。

サンプルアプリケーションの作成

SpringのCache Abstractionを試す、サンプルアプリケーションを作成します。

簡単に、こんなクラスを作成。それぞれ、動作がわかりやすいように3秒間のスリープを入れています。

引数を2倍、足し算を行うService。
src/main/java/org/littlewings/geode/spring/CalcService.java

package org.littlewings.geode.spring;

import java.util.concurrent.TimeUnit;

import org.springframework.cache.annotation.CacheConfig;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Service
@CacheConfig(cacheNames = "calcRegion")
public class CalcService {
    @Cacheable
    public int doubling(int x) {
        try {
            TimeUnit.SECONDS.sleep(3L);
        } catch (InterruptedException e) {
            // ignore
        }

        return x * 2;
    }

    @Cacheable
    public int add(int a, int b) {
        try {
            TimeUnit.SECONDS.sleep(3L);
        } catch (InterruptedException e) {
            // ignore
        }

        return a + b;
    }
}

書籍の登録と取得を行うService。
src/main/java/org/littlewings/geode/spring/BookService.java

package org.littlewings.geode.spring;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;

import org.springframework.cache.annotation.CacheConfig;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Service
@CacheConfig(cacheNames = "bookRegion")
public class BookService {
    Map<String, Book> books = new ConcurrentHashMap<>();

    @Cacheable
    public Book find(String isbn) {
        try {
            TimeUnit.SECONDS.sleep(3L);
        } catch (InterruptedException e) {
            // ignore
        }

        return books.get(isbn);
    }

    public void put(Book book) {
        books.put(book.getIsbn(), book);
    }
}

Bookクラスの定義は、ふつうのSerializableなJavaBeansです。
src/main/java/org/littlewings/geode/spring/Book.java

package org.littlewings.geode.spring;

import java.io.Serializable;

public class Book implements Serializable {
    private static final long serialVersionUID = 1L;

    private String isbn;
    private String title;
    private Integer price;

    public Book(String isbn, String title, Integer price) {
        this.isbn = isbn;
        this.title = title;
        this.price = price;
    }

    public Book() {
    }

    // getter/setterは省略
}

Cacheの定義(Client側)

Client側、Spring Data Geode側で使うCacheの定義は、こんな感じにしました。
src/main/resources/client-cache.xml

<?xml version="1.0" encoding="UTF-8"?>
<client-cache
        xmlns="http://geode.apache.org/schema/cache"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://geode.apache.org/schema/cache
                      http://geode.apache.org/schema/cache/cache-1.0.xsd"
        version="1.0">
    <pool name="client-pool" subscription-enabled="true">
        <locator host="localhost" port="10334"/>
    </pool>

    <region name="calcRegion" refid="PROXY">
        <region-attributes pool-name="client-pool"/>
    </region>
    <region name="bookRegion" refid="PROXY">
        <region-attributes pool-name="client-pool"/>
    </region>
</client-cache>

2つのRegionを定義しているだけですね。

Spring Data GeodeとCacheManagerの設定

用意した設定を読み込んで使うように、Spring Data GeodeのCacheの設定を行います。
src/main/java/org/littlewings/geode/spring/CachingConfig.java

package org.littlewings.geode.spring;

import org.apache.geode.cache.client.ClientCache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.data.gemfire.client.ClientCacheFactoryBean;
import org.springframework.data.gemfire.support.GemfireCacheManager;

@Configuration
@EnableCaching
public class CachingConfig {
    @Bean
    public ClientCacheFactoryBean geodeCache() throws Exception {
        ClientCacheFactoryBean clientCacheFactory = new ClientCacheFactoryBean();
        clientCacheFactory.setCacheXml(new ClassPathResource("client-cache.xml"));
        clientCacheFactory.afterPropertiesSet();
        return clientCacheFactory;
    }

    @Bean
    public CacheManager cacheManager(ClientCache cache) {
        GemfireCacheManager cacheManager = new GemfireCacheManager();
        cacheManager.setCache(cache);
        cacheManager.afterPropertiesSet();
        return cacheManager;
    }
}

@EnableCachingは付与しておきます。

Spring Data GeodeでRepositoryを使う時のように、Region自体をBean定義する必要はありません。
※Region自体はCache XMLに定義されているので

Regionも、Cacheの定義に含まれて入れば特にCacheManagerに指定する必要はありませんが、Cache定義してあるものの中から
CacheManagerで使えるRegionを絞りたい場合は、GemfireCacheManager#setCacheNames、
もしくはGemfireCacheManager#setRegionsで使えるRegionを絞ることができます

    @Bean
    public CacheManager cacheManager(ClientCache cache) {
        GemfireCacheManager cacheManager = new GemfireCacheManager();
        cacheManager.setCache(cache);

        // Region名を指定
        cacheManager.setCacheNames(new HashSet<>(Arrays.asList("calcRegion", "bookRegion")));
        /* もしくは
        cacheManager.setRegions(new HashSet<>(
                Arrays.asList(cache.getRegion("calcRegion"), cache.getRegion("bookRegion"))
        ));
        */

        cacheManager.afterPropertiesSet();
        return cacheManager;
    }

これらを指定しなかった場合は、ClientCacheから取得可能なRegionがすべてCacheManagerで使用可能に
なります(ClientCache#getRegionで定義があれば使います)。

Spring Bootの有効化

実行自体ははテストコードで行いますが、Spring Boot有効化のために、@SpringBootApplicationアノテーション
付与したクラスを用意しておきます。

src/main/java/org/littlewings/geode/spring/App.java 
package org.littlewings.geode.spring;

import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class App {
}

Server側のCacheの設定

Client/Server Modeになるので、Server側にCacheの定義が必要です。

今回は、このような定義にしました。
cache.xml

<?xml version="1.0" encoding="UTF-8"?>
<cache
    xmlns="http://geode.apache.org/schema/cache"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://geode.apache.org/schema/cache
                        http://geode.apache.org/schema/cache/cache-1.0.xsd"
    version="1.0">
  <region name="calcRegion" refid="PARTITION_REDUNDANT">
    <region-attributes>
      <entry-time-to-live>
        <expiration-attributes timeout="5"/>
      </entry-time-to-live>
    </region-attributes>
  </region>
  <region name="bookRegion" refid="PARTITION_REDUNDANT">
    <region-attributes>
      <entry-time-to-live>
        <expiration-attributes timeout="5"/>
      </entry-time-to-live>
    </region-attributes>
  </region>
</cache>

Regionに有効期限を設定していますが、この確認自体はこのあとに出てくるテストコードでは行いません。

gfshでの「start server」時に、「--cache-xml-file」オプションでCache XMLを指定してServerを起動しましょう。

テストコードの作成と確認

それでは、確認を兼ねてテストコードを書いていきます。

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

package org.littlewings.geode.spring;

import org.apache.geode.cache.Region;
import org.apache.geode.cache.client.ClientCache;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cache.interceptor.SimpleKey;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.util.StopWatch;

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

@RunWith(SpringRunner.class)
@SpringBootTest
public class GeodeCacheTest {
    // ここに、テストを書く!
}
シンプルなパターンで確認

まずは、CalcServiceの単純に引数を2倍するメソッドをテストしてみます。

    @Autowired
    ClientCache cache;

    @Autowired
    CalcService calcService;

    @Test
    public void calcCacheDoubling() {
        StopWatch stopWatch = new StopWatch();

        // 初回は低速
        stopWatch.start();
        assertThat(calcService.doubling(4))
                .isEqualTo(8);
        stopWatch.stop();
        assertThat((long) stopWatch.getLastTaskInfo().getTimeSeconds())
                .isGreaterThanOrEqualTo(3L);

        // 2回目は高速
        stopWatch.start();
        assertThat(calcService.doubling(4))
                .isEqualTo(8);
        stopWatch.stop();
        assertThat((long) stopWatch.getLastTaskInfo().getTimeSeconds())
                .isEqualTo(0L);

        // 別のキーにすると、キャッシュに乗っていないので低速
        stopWatch.start();
        assertThat(calcService.doubling(15))
                .isEqualTo(30);
        stopWatch.stop();
        assertThat((long) stopWatch.getLastTaskInfo().getTimeSeconds())
                .isGreaterThanOrEqualTo(3L);

        // Regionで確認もできる
        Region<Object, Object> region = cache.getRegion("calcRegion");
        assertThat(region.get(4))
                .isEqualTo(8);
    }

1回目は低速、2回目は高速と、特に問題なく動作していてCacheManagerを介して登録した値がRegionを
使って取得できることも確認できます。

キーが複数となるメソッドを使用する

続いては、CalcServiceの足し算のメソッドをテストしましょう。

こんなコードを用意します。

    @Test
    public void calcCacheAdd() {
        StopWatch stopWatch = new StopWatch();

        // 初回は低速
        stopWatch.start();
        assertThat(calcService.add(1, 5))
                .isEqualTo(6);
        stopWatch.stop();
        assertThat((long) stopWatch.getLastTaskInfo().getTimeSeconds())
                .isGreaterThanOrEqualTo(3L);

        // 2回目は高速
        stopWatch.start();
        assertThat(calcService.add(1, 5))
                .isEqualTo(6);
        stopWatch.stop();
        assertThat((long) stopWatch.getLastTaskInfo().getTimeSeconds())
                .isEqualTo(0L);

        // SimpleKeyを指定して、Regionより確認
        Region<Object, Object> region = cache.getRegion("calcRegion");
        assertThat(region.get(new SimpleKey(1, 5)))
                .isEqualTo(6);
    }

ところがこのコードは、そのまま動かすとエラーになります。Server側でこんな感じでコケて、Client側にも
例外が飛んできます。

Caused by: java.lang.ClassNotFoundException: org.springframework.cache.interceptor.SimpleKey
	at java.net.URLClassLoader.findClass(URLClassLoader.java:381)
	at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
	at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:331)
	at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
	at java.lang.Class.forName0(Native Method)
	at java.lang.Class.forName(Class.java:348)
	at java.io.ObjectInputStream.resolveClass(ObjectInputStream.java:677)
	at org.apache.geode.internal.InternalDataSerializer$DSObjectInputStream.resolveClass(InternalDataSerializer.java:3599)
	at java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:1819)
	at java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1713)
	at java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:1986)
	at java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1535)
	at java.io.ObjectInputStream.readObject(ObjectInputStream.java:422)
	at org.apache.geode.internal.InternalDataSerializer.basicReadObject(InternalDataSerializer.java:2996)
	at org.apache.geode.DataSerializer.readObject(DataSerializer.java:3281)
	at org.apache.geode.internal.util.BlobHelper.deserializeBlob(BlobHelper.java:103)
	at org.apache.geode.internal.cache.tier.sockets.CacheServerHelper.deserialize(CacheServerHelper.java:82)
	at org.apache.geode.internal.cache.tier.sockets.Part.getObject(Part.java:273)
	at org.apache.geode.internal.cache.tier.sockets.Part.getObject(Part.java:282)
	at org.apache.geode.internal.cache.tier.sockets.Part.getStringOrObject(Part.java:287)
	at org.apache.geode.internal.cache.tier.sockets.command.Get70.cmdExecute(Get70.java:95)
	at org.apache.geode.internal.cache.tier.sockets.BaseCommand.execute(BaseCommand.java:147)
	at org.apache.geode.internal.cache.tier.sockets.ServerConnection.doNormalMsg(ServerConnection.java:783)
	at org.apache.geode.internal.cache.tier.sockets.ServerConnection.doOneMessage(ServerConnection.java:913)
	at org.apache.geode.internal.cache.tier.sockets.ServerConnection.run(ServerConnection.java:1180)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
	at org.apache.geode.internal.cache.tier.sockets.AcceptorImpl$1$1.run(AcceptorImpl.java:546)
	at java.lang.Thread.run(Thread.java:745)

シリアライズしようとして、SimpleKeyが見つからないと言われております…。

仕方がないので、SpringのJARファイルをServer側にデプロイします。対象は、とりあえず「spring-core-4.3.6.RELEASE.jar」、
「spring-context-4.3.6.RELEASE.jar」の2つとします。
※SimpleKeyがクラス定義として見れればいいのかな?という安易な発想

gfsh>deploy --dir=/path/to/deploy-targetdir

Deploying files: spring-core-4.3.6.RELEASE.jar, spring-context-4.3.6.RELEASE.jar
Total file size is: 2.15MB

Continue?  (Y/n): y
      Member        | Deployed JAR | Deployed JAR Location
------------------- | ------------ | ----------------------------------------------------------------
server-d67e9da1dd69 |              | ERROR: java.lang.NoClassDefFoundError: org/apache/tools/ant/Task

なんか、エラーになりましたけど。依存関係が足りません、ってことですねぇ…。

ちなみに、この状態でもJARファイル自体は認識しているようで、先ほどのテストコードはパスするように
なります。

アンデプロイも可能です。

gfsh>undeploy --jar=spring-context-4.3.6.RELEASE.jar
      Member        |         Un-Deployed JAR          | Un-Deployed From JAR Location
------------------- | -------------------------------- | ------------------------------------------------------------------------------
server-d67e9da1dd69 | spring-context-4.3.6.RELEASE.jar | /opt/apache-geode/server-d67e9da1dd69/vf.gf#spring-context-4.3.6.RELEASE.jar#1

gfsh>undeploy --jar=spring-core-4.3.6.RELEASE.jar
      Member        |        Un-Deployed JAR        | Un-Deployed From JAR Location
------------------- | ----------------------------- | ---------------------------------------------------------------------------
server-d67e9da1dd69 | spring-core-4.3.6.RELEASE.jar | /opt/apache-geode/server-d67e9da1dd69/vf.gf#spring-core-4.3.6.RELEASE.jar#1

まあ、ふつうにやる時は依存関係ごとデプロイする感じでしょうねぇ…。

ユーザー定義のクラスを値として登録する

最後は、BookServiceを使ったテストコードです。このコードの場合は、Cacheに登録する値がユーザー定義のものになります。

テストコードはこんな感じ。

    @Autowired
    BookService bookService;

    @Test
    public void bookCache() {
        StopWatch stopWatch = new StopWatch();

        Book book = new Book("978-4798142470", "Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発", 4320);

        // データの登録
        bookService.put(book);

        // 初回の取得は低速
        stopWatch.start();
        assertThat(bookService.find("978-4798142470").getTitle())
                .isEqualTo("Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発");

        stopWatch.stop();

        assertThat((long) stopWatch.getLastTaskInfo().getTimeSeconds())
                .isGreaterThanOrEqualTo(3L);

        // 2回目は高速
        stopWatch.start();
        assertThat(bookService.find("978-4798142470").getTitle())
                .isEqualTo("Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発");
        stopWatch.stop();

        assertThat((long) stopWatch.getLastTaskInfo().getTimeSeconds())
                .isEqualTo(0L);

        // Regionでも確認できる
        Region<String, Book> region = cache.getRegion("bookRegion");
        Book foundBook = region.get("978-4798142470");
        assertThat(foundBook.getTitle())
                .isEqualTo("Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発");
    }

先ほどSimpleKeyを使ったテストコードがエラーになったので、こちらもNGになるかと思いきやふつうにパスします。

Apache GeodeのServer側から見ればSpringのSimpleKeyクラスも今回作ったBookクラスも未知の値ですが、
キーに対して使ってしまうとデシリアライズしないと比較できないので困ります、ということなのでしょうね。
※値はキーで取得時には、中身がわからなくてもよい

@Cacheableを付与したメソッドの引数が単一であれば(key属性でSpELで調整しなければ)そのままキーとなり、
複数のものであればSimpleKeyになるので、キーは単一にできるだけした方がいいよーってことでしょう。

まとめ

Spring Data Geodeを使って、SpringのCache Abstractionを試してみました。

こちらはそれほどハマることなく、割と簡単に使えたと思います。…ある程度Apache Geodeに慣れていれば。