CLOVER🍀

That was when it all began.

Standard Caching

はじめに

この記事は、「Java EE Advent Calendar 2013 - Adventar」の4日目の記事となります。
昨日は、@backpaper0さんの「私のBeanValidationの使い方(Java EE Advent Calendar 2013) — 裏紙」でした。
明日は、@glory_ofさんのご担当となります。

自己紹介的な

Java SEの一部とServlet APIの範囲で、淡々と生きてきたプログラマ?です。仕事では、ここ数年はSAStrutsS2JDBCMayaa&Velocity、そしてTomcatを使ってWeb系の開発を行っています。このスタックは、現在進行形です。

それで、Servletの範囲を越えたJava EEですが、まだこの1ヶ月の間くらいにJava EE 6を試し始めたばかりです。Java EE Advent Calendarのようなイベントに関わることはないと思っていましたが、主催の@kikutaro_さんにお誘いいただき、今回参加させていただきました。

どうぞよろしくお願いします。

それで、何をテーマにするのかというところですが、前述の通り触り始めたばかりなのでJAX-RSJPAとかではちょっとネタに苦しいので、「自分なりに、他の方とはできるだけ被らなさそうなテーマ」を選んでみました。

JCache

選んだテーマは、JCacheです。JCacheはJavaでの標準キャッシュAPIで、だいぶ時間がかかっていますが現在PFD(Proposed Final Draft)のステージですね。

JSR 107: JCACHE - Java Temporary Caching API
http://www.jcp.org/en/jsr/detail?id=107

GitHubにあるSpecは、こちら。
https://github.com/jsr107/jsr107spec

今年、少し名前を聞きましたが、それはJava EE 7の収録は見送られたということだったと思います。

Oracle、Java EE 7のJCache対応を見送る | マイナビニュース
Oracle Blogs 日本語のまとめ: [Java] JCache Marches Onward!

それに、以前のJCacheのサンプルも検索すると日本語情報でもひっかかりますが、パッケージが

net.sf.jsr107cache.Cache

みたいな時代のものであまり多くありませんし、この機会に少し早いですけど、ちょっと試してみようと思いまして。

JCacheの特徴

仕様としては割と緩いというか、キャッシュAPIとして標準化できる範囲で定めようとしています。仕様的には、以下のようなことを決めていたり、サポートしていたりします。

  • Mapのような、キーをもとにして値を保存するデータ構造とする
  • 有効期限の設定
  • CacheLoader/Writer
  • Read Through/Write Through
  • キャッシュのキー、値はシリアライズできるべき(オプションで、参照保持も可能)
  • CAS操作
  • リスナー
  • EntryProcessor
  • アノテーションを使った、キャッシュとユーザ定義のクラスの統合

また、以下のような項目は標準化されていません。

  • キャッシュエントリの保持方法(メモリ?分散保持?など)
  • キャッシュエントリの数の制限や、制限値をオーバーした場合の振る舞い(エビクション)

キャッシュ内にある、エントリの検索もできません。

なので、EhcacheなどのJCacheの実装そのものが持つ機能と見比べてしまうと、どうしても見劣りすると思います。が、まずは標準化ということで。

JCacheの実装

Javaの、特にOSSのキャッシュライブラリで有名なのはEhcacheで、JCacheの実装のひとつではあります。

Ehcache JSR107 Support
Ehcache-JCache

なんですけど、JCacheへの追従は0.8で止まっていて、このモジュールはEhcache本体の更新にも付いていっていない状態です。なので、今回はJCache 1.0.0 PFDまで追従している、Infinispanを使用します。

Infinispan
http://infinispan.org/

InfinispanはJBoss系のインメモリ・データグリッドの実装(JSR 347)ですが、JSR 107の実装でもあります。Javaでのデータグリッドについての説明は、以下がわかりやすいと思います。

Javaのデータグリッドの仕様:JSR-347

11月後半にリリースされたInfinispan 6.0.0 Finalが、JSR 107 1.0.0 PFDを実装しています。

ちなみに、テスト目的な位置付けではありますが、Reference Implementationも存在します。

https://github.com/jsr107/RI

今回は、このInfinispanを使ってJCacheを単体で使う例と、CDIとの統合の例をご紹介したいと思います。

JCache単体で使う

前置きがだいぶ長くなりましたが、JCacheを使ってみます。主に参照したのは、JSR 107のPDF(斜め読み)とJavadoc、Infinispanのドキュメントですね。

Using Infinispan as a JSR107 (JCache) provider
http://infinispan.org/docs/6.0.x/user_guide/user_guide.html#_using_infinispan_as_a_jsr107_jcache_provider

まずはMaven依存関係。依存関係に以下を定義します。

    <dependency>
      <groupId>org.infinispan</groupId>
      <artifactId>infinispan-jcache</artifactId>
      <version>6.0.0.Final</version>
    </dependency>

あと、サンプルの都合上、JUnitも加えています。

なお、この依存関係以外に後の方で設定ファイルが登場するまで、Infinispan独自の記述はありません。

JCacheで主に使うのは、以下のクラス/インターフェースになります。

import javax.cache.Cache;
import javax.cache.CacheManager;
import javax.cache.Caching;
import javax.cache.configuration.MutableConfiguration;
import javax.cache.spi.CachingProvider;

まずは、Cacheのインスタンスを作成/取得します。

    @Test
    public void simpleTest() {
        try (CachingProvider provider = Caching.getCachingProvider();
             CacheManager cacheManager = provider.getCacheManager();
             Cache<String, String> cache = cacheManager.createCache("simpleCache",
                                                                    new MutableConfiguration<String, String>())) {
            // Cacheを使ったコード
        }
    }

Cacheのインスタンスを取得するまでは、CacheProvider→CacheManager→Cacheという繋ぎになります。Cacheを作成/取得する時には、キャッシュの名前と設定情報が必要です。CacheManager#createCacheメソッドは、Cacheの定義を行い、インスタンスを返却します。

ここでは設定としてMutableConfigurationクラスのインスタンスを使用していますが、キャッシュライブラリの設定ファイルを指定することもできます。が、その場合は実装異存の設定ファイルとなります。

いずれの型も、Closeableなのでtry-with-resourcesで扱えます。

Cacheインターフェースが持つメソッドはMapによく似たものなので、それなりに直感的に使用することができます。

            // putとget
            cache.put("key1", "value1");
            assertThat(cache.get("key1"), is("value1"));

            // remove
            cache.remove("key1");
            assertThat(cache.get("key1"), nullValue());

            // 全削除
            cache.clear();
            //cache.removeAll(); // 左記でも可。clearの場合は、Listenerが起動しないらしい
            assertThat(cache.get("key1"), nullValue());

ConcurrentMap的なメソッドも、いくつかあります。

ただ、MapではないのでkeySetやentrySetなどのメソッドは持ちません。Cacheインターフェース自身が、Iterableにはなってはいます。

            // イテレーション
            for (Cache.Entry<String, String> entry : cache) {
                 // ...
            }

Cacheに格納するエントリについてですが、前述の通りJCacheではエントリをシリアライズするようになっています。実装がサポートしていれば、オブジェクトの参照を保存するように設定することも可能ですが、少なくともシリアライズ可能なエントリを格納するようにはしておいた方がよいと思います。

キャッシュの設定をしてみる

JCacheは、標準APIでの設定(MutableConfiguration)だとそれほど触れる項目がありません。設定可能な項目はMutableConfigurationのset系のメソッドを見ればよいのですが、今回は有効期限の設定をしてみます。

有効期限には、以下の3つの考え方(ExpiryPolicy)があります。

名前 意味
Creation エントリを登録してからの経過時間で、有効かどうかを判断
Access エントリにアクセスしてからの経過時間で、有効かどうかを判断
Update エントリを更新してからの経過時間で、有効かどうかを判断

この3つと、Cacheの各メソッドの関係(例えば、getはAccessとして扱われ、replaceはUpdateとして扱われる、など)は、JSRの仕様に対応表がずらずらと書かれているので、そちらを見た方がよいです。

そして、この3つに対して有効期限の設定を以下の中から選択することができます。

名前 対応するExpiryPolicy
CreatedExpiryPolicy Creation
ModifiedExpiryPolicy Update
AccessedExpiryPolicy Access
TouchedExpiryPolicy Access/Update
EternalExpiryPolicy なし(有効期限切れしない:デフォルト)

AccessとUpdateは、キャッシュへエントリを登録してから有効期限が有効になります。

では、この中からいくつか使ってみましょう。

まずは、EternalExpiryPolicy。明示的に指定せずとも、これがデフォルトです。

        // 有効期限なしのキャッシュ(デフォルト)
        Cache<String, String> eternalCache
            = cacheManager.createCache("eternalCache",
                                       new MutableConfiguration<String, String>()
                                       .setExpiryPolicyFactory(EternalExpiryPolicy.factoryOf()));
        // some code...
        eternalCache.close();

いずれのExpiryPolicyも、factoryOfメソッドでインスタンスを取得できるので、MutableConfiguration#setExpiryPolicyFactoryメソッドで設定します。

続いて、CreatedExpiryPolicy。

        // putしてから、5秒後に有効期限切れするキャッシュ
        Cache<String, String> createdCache
            = cacheManager.createCache("createdCache",
                                       new MutableConfiguration<String, String>()
                                       .setExpiryPolicyFactory(CreatedExpiryPolicy
                                                               .factoryOf(new Duration(TimeUnit.SECONDS, 5))));
        createdCache.put("key1", "value1");
        Thread.sleep(3 * 1000L);

        assertThat(createdCache.get("key1"), is("value1"));

        Thread.sleep(3 * 1000L);

        // Creationの場合は、getやreplaceのアクセスでは有効期限は延長されない
        assertThat(createdCache.get("key1"), nullValue());

        createdCache.close();

CreatedExpiryPolicyなどの有効期限のあるポリシーの場合は、factoryOfにDurationを指定します。今回はDurationを極端に短くしているので、newして設定していますが、1時間などのそこそこ汎用的なものはDurationのstaticフィールドとして定義されているので、そちらを使用してもよいと思います。

その他

あまり細かくは触れませんが、他にもCacheLoader/Writerを設定したり、キャッシュのイベントに対するListenerを設定することもできます。

また、キャッシュ内に登録されているキーを指定して、エントリに対して処理を行うEntryProcessorというものもあります。こちらは、使用例です。

             java.util.Set<String> keySet = new java.util.HashSet<>();
             for (int i = 1; i <= 10; i++) {
                 cache.put("key" + i, i);
                 keySet.add("key" + i);
             }

             // あらかじめ、処理対象のキー集合を渡す必要がある
             Map<String, Integer> result =
                 cache.invokeAll(keySet,
                                 new EntryProcessor<String, Integer, Integer>() {
                                     @Override
                                     public Integer process(MutableEntry<String, Integer> entry, Object... arguments) {
                                         if (entry.exists()) {
                                             Integer current = entry.getValue();
                                             // キャッシュエントリの値を変更
                                             entry.setValue(entry.getValue() * 2);
                                             // EntryProcessorの戻り値
                                             return current + 1;
                                         } else {
                                             entry.setValue(0);
                                             return -1;
                                         }
                                     }});

             // キャッシュの中の値は2倍に、invokeAllの戻り値は+1に
             for (int i = 1; i <= 10; i++) {
                 assertThat(cache.get("key" + i), is(i * 2));
                 assertThat(result.get("key" + i), is(i + 1));
             }

invokeAllメソッドだとキーのSetを渡す必要がありますが、invokeメソッドだと単一のキーを指定すればOKです。とはいえ、keySetは無いので、あらかじめキーを想定できる必要がありますけどね。

あと、普通こんな使い方はしないと思いますが、エントリと戻りの値を一緒にするサンプルが多いような気がしますので、ちょっと違いを出すためにこういうサンプルにしました。

ここまでの完全なソースコード(にAccessedExpiryPolicyの例を加えたもの)は、以下に置いてあります。

https://github.com/kazuhira-r/javaee-advent-calendar/tree/master/2013/jcache-example

CDIと統合する

と、ここまでJCache単体ばっかりで、Java EEについて書いている感じがしないと思いますので、ここからはCDIと統合して宣言的にキャッシュを使用する方法について書きます。

Use JCache caching annotations
http://infinispan.org/docs/6.0.x/user_guide/user_guide.html#_use_jcache_caching_annotations

Maven依存関係には、以下を定義します。

    <dependency>
      <groupId>javax</groupId>
      <artifactId>javaee-web-api</artifactId>
      <version>6.0</version>
      <scope>provided</scope>
    </dependency>

    <dependency>
      <groupId>org.infinispan</groupId>
      <artifactId>infinispan-jcache</artifactId>
      <version>6.0.0.Final</version>
    </dependency>

すいません、Java EE 6です…。

beans.xmlに、以下の内容を定義します。

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://java.sun.com/xml/ns/javaee"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/beans_1_0.xsd">
  <interceptors>
    <class>org.infinispan.jcache.annotation.CacheResultInterceptor</class>
    <class>org.infinispan.jcache.annotation.CachePutInterceptor</class>
    <class>org.infinispan.jcache.annotation.CacheRemoveEntryInterceptor</class>
    <class>org.infinispan.jcache.annotation.CacheRemoveAllInterceptor</class>
  </interceptors>  
</beans>

CDI 1.0です…。オマケに、Infinispanのドキュメントはクラス名が古いです…。

キャッシュ操作を行う対象のクラスとして、以下のようなものを用意しました。

@ApplicationScoped
@CacheDefaults(cacheName = "calcCache")
public class CalcService {
    @CacheResult
    public int add(int left, int right) {
        try { Thread.sleep(3 * 1000L); } catch (InterruptedException e) { }
        return left + right;
    }

    @CachePut
    public void update(int left, int right, @CacheValue int result) {
    }

    @CacheRemove
    public void remove(int left, int right) {
    }

    @CacheRemoveAll
    public void removeAll() {
    }
}

addメソッドは足し算を行うだけのメソッドですが、@CacheResultアノテーションが付いているので引数をキーにメソッドの戻り値をキャッシュに格納します。キャッシュが有効な間はメソッドの実行が飛ばされるため、わかりやすい用にスリープを入れています。

@CachePutアノテーションを付与すると、@CacheValueアノテーションが付いた引数をキャッシュの値に、他の引数をキーとして扱います。他にも、@CacheKeyアノテーションというものもあります。

@CacheRemove、@CacheRemoveAllは、キャッシュエントリ削除用のアノテーションです。

アノテーションにはcacheNameパラメータを取ることができ、省略した場合はデフォルトのキャッシュを使用するらしいのですが、@CacheRemoveと@CacheRemoveAllアノテーションは指定しないとうまく動きませんでした…。今回は、このクラスで使用するキャッシュをすべて同じにするために、以下のアノテーションを付与しています。

@CacheDefaults(cacheName = "calcCache")

これで、各アノテーションのcacheNameに、「calcCache」を指定したのと同じ効果があります。

ちなみに、デフォルトのキャッシュというのは、各アノテーションのcacheNameの説明によると

If not specified defaults first to CacheDefaults.cacheName() an if that is not set it defaults to: package.name.ClassName.methodName(package.ParameterType,package.ParameterType)

だそうです。

ここで使っている、JCacheの主なアノテーションに対するimport文はこちらになります。

import javax.cache.annotation.CacheDefaults;
import javax.cache.annotation.CachePut;
import javax.cache.annotation.CacheRemove;
import javax.cache.annotation.CacheRemoveAll;
import javax.cache.annotation.CacheResult;
import javax.cache.annotation.CacheValue;

と、ここまできてお気付きかもしれませんが、キャッシュのキーはメソッドの引数に強く影響を受けます。なので、このようなメソッドを定義したりすると

    // こういうの作ると、addとキーが被る…
    @CacheResult
    public int multiply(int left, int right) {
        try { Thread.sleep(3 * 1000L); } catch (InterruptedException e) { }
        return left * right;
    }

キャッシュのキーが被って、えらいことになります…。

では、このクラスを使用するJAX-RSのクラスを用意します。
*Applicationのサブクラスは端折ります

@RequestScoped
@Path("calc")
public class CalcResource {
    @Inject
    private CalcService calcService;

    @GET
    @Path("add")
    public String add(@QueryParam("p1") int p1, @QueryParam("p2") int p2) {
        return "Result = " + calcService.add(p1, p2);
    }

    @GET
    @Path("update")
    public void update(@QueryParam("p1") int p1, @QueryParam("p2") int p2) {
        calcService.update(p1, p2, p1 + p2);
    }

    @DELETE
    @Path("delete")
    public void delete(@QueryParam("p1") int p1, @QueryParam("p2") int p2) {
        calcService.remove(p1, p2);
    }

    @DELETE
    @Path("delete-all")
    public void deleteAll() {
        calcService.removeAll();
    }
}

あとは、適当にパッケージングしてデプロイします。今回は、JBoss AS 7.1.1を使用しています。コンテキスト名は、「jcache-web」とします。

$ mvn package -DfailOnMissingWebXml=false

アクセスしてみます。

$ time curl "http://localhost:8080/jcache-web/calc/add?p1=3&p2=4"
Result = 7
real	0m4.855s
user	0m0.016s
sys	0m0.016s

QueryStringを足し算して返していますが、初回アクセスなこともあり5秒かかっています。
JBoss ASを起動してからの初回アクセスだと、もっと時間がかかると思います
が、キャッシュには乗っているので、2回目は高速になります。

$ time curl "http://localhost:8080/jcache-web/calc/add?p1=3&p2=4"
Result = 7
real	0m0.054s
user	0m0.020s
sys	0m0.016s

キャッシュを削除すれば、また時間がかかるようになりますし、

$ time curl -X DELETE "http://localhost:8080/jcache-web/calc/delete?p1=3&p2=4"

updateすれば、キャッシュにエントリを登録することもできます。

$ time curl "http://localhost:8080/jcache-web/calc/update?p1=3&p2=4"

そして、QueryStringのパラメータ値を変えても、キャッシュのキーが変わるのでやっぱり時間がかかるようになります。

calcCacheの全削除は、こちらで。

$ time curl -X DELETE "http://localhost:8080/jcache-web/calc/delete-all"

設定は?

アノテーションで宣言的にキャッシュの操作を行うようにしましたが、こういうCacheManagerを直接使わない書き方をしてしまうと、キャッシュの設定方法がわからなくなります。

今回は、設定ファイル使ってキャッシュの設定を行いました。

クラスパス直下に、「org.infinispan.jcache.JCachingProvider」というファイル名(InfinispanのCachingProviderの実装クラス名)で設定ファイルを用意します。設定ファイルの書き方は、完全にInfinispan依存です。

org.infinispan.jcache.JCachingProvider

<?xml version="1.0" encoding="UTF-8"?>
<infinispan
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="urn:infinispan:config:6.0 http://www.infinispan.org/schemas/infinispan-config-6.0.xsd"
    xmlns="urn:infinispan:config:6.0">

  <default />

  <namedCache name="calcCache">
    <expiration lifespan="10000" />
  </namedCache>

</infinispan>

これで、意味的にはCreatedExpiryPolicyとなり、キャッシュエントリの作成から10秒後にアクセスの有無に関わらず有効期限切れします。

なお、EE環境になったからといって、アノテーションで宣言的に書くだけではなくて、普通に@ProducesアノテーションでCacheを定義するのもありだと思います。アノテーションだけだと、細かい制御などができませんし、いろいろ条件判断を入れたい時とかもあるでしょうし。

public class CacheProvider {
    @Produces
    @ApplicationScoped
    public Cache<String, String> getCache() {
        return Caching.getCachingProvider().getCacheManager().getCache("simpleCache");
    }

    public void disposeCache(@Disposes Cache<String, String> cache) {
        cache.close();
    }
}

この時CacheManagerでcreateCacheするのではなく、getCacheとして設定ファイルにキャッシュの定義をしておけば設定ファイルとしては共有可能です。

  <namedCache name="simpleCache" />

createCacheはCacheを定義、作成するのに対して、getCacheは定義済みのCacheを取得するだけというのがポイントです。

@Injectアノテーションで自分でインジェクションするCacheと、アノテーションで制御するCacheは共有しない方がいいと思います。アノテーションで制御している方は、キーの指定にメソッドのパラメータをラップして使っている(キーの形式が異なる)ためです。

もちろん、設定ファイルを用意せずに単にCacheを生成してインジェクションするだけなら、CacheManager#createCacheしてもよいと思います。

クラスタ

最後、Infinispanがインメモリ・データグリッドであることを活かして、キャッシュをクラスタ化してみます。
※ここは、完全に実装依存の話です

org.infinispan.jcache.JCachingProvider>

<?xml version="1.0" encoding="UTF-8"?>
<infinispan
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="urn:infinispan:config:6.0 http://www.infinispan.org/schemas/infinispan-config-6.0.xsd"
    xmlns="urn:infinispan:config:6.0">

  <global>
    <transport clusterName="heterogeneous-cluster">
      <properties>
        <property name="configurationFile" value="jgroups-udp.xml" />
      </properties>
    </transport>
  </global>

  <default />

  <namedCache name="calcCache">
    <clustering mode="distribution" />
    <expiration lifespan="10000" />
  </namedCache>

</infinispan>

先ほどの設定ファイルに、global/transportとnamedCacheの直下にclusteringを入れ、クラスタの設定を行いました。「jgroups-udp.xml」というのは、Infinispan内にデフォルトで含まれるJGroupsの設定ファイルです。

ネットワークでマルチキャストが有効であれば、JGroupsが他のNodeを探してきてクラスタに組み込んでくれます。

もうひとつJBoss ASを用意して、2つのJBoss ASにデプロイします。2つ目のJBoss ASは、起動する時にポートを1000ずらしました。

$ jboss-as-7.1.1.Final-2/bin/standalone.sh -Djboss.socket.binding.port-offset=1000

*「jboss-as-7.1.1.Final-2」は、JBoss ASを展開した2つ目のディレクト

で、とりあえず双方にアクセスします…。

$ time curl "http://localhost:8080/jcache-web/calc/add?p1=3&p2=4"
$ time curl "http://localhost:9080/jcache-web/calc/add?p1=3&p2=4"

初回なので、JBoss ASの起動を含めてけっこう時間がかかりますが、以下のようなログがJBoss ASに出力されます。
*ネットワークバッファのチューニングを行っていない場合、JGroupsからいろいろ警告されますが、いったんスルーで

22:27:28,294 INFO  [org.infinispan.remoting.transport.jgroups.JGroupsTransport] (http--127.0.0.1-9080-1) ISPN000094: Received new cluster view: [xxxxx-yyyyy-32932|1] (2) [xxxxx-yyyyy-32932, xxxxx-yyyyy-825]

クラスタに、Nodeが2つあることになります。
*「xxxxx-yyyyy」は、ホスト名です

ちょっとパラメータを変えて、再度アクセスしてみます。まずは、ひとつめ。

$ time curl "http://localhost:8080/jcache-web/calc/add?p1=5&p2=5"
Result = 10
real	0m3.043s
user	0m0.008s
sys	0m0.020s

この後、10秒以内に2つ目にアクセスすると

$ time curl "http://localhost:9080/jcache-web/calc/add?p1=5&p2=5"
Result = 10
real	0m0.042s
user	0m0.016s
sys	0m0.012s

キャッシュが共有されているため、高速に結果が返却されます。

このCDIと統合した完全なソースコードは、以下に置いています。

https://github.com/kazuhira-r/javaee-advent-calendar/tree/master/2013/jcache-web

おわり

だいぶ長くなってしまいましたが、JCacheの機能をそこそこ流して、最後にInfinispanを使ったキャッシュのクラスタ化までやってみました。確定前のJSRの記事ではありますが、何か興味を引くところがあれば幸いです。

明日のご担当は、@glory_ofさんです。よろしくお願いします。