CLOVER🍀

That was when it all began.

JCache(Hazelcast Client)で、独自のクラスをCacheに突っ込むとClassNotFoundになるの?という話

今日、JCacheについて、こんな質問を受けまして。

「Hazelcast ClientをJCacheの実装に使って値に独自のクラスを入れる時、サーバー側にも独自のクラスをデプロイしないといけないの?」

なんでこんなことを聞かれたかって、実際にやってみたらサーバー側で「クラスが見つからない」とエラーになったと言います。

ただ、Client/Server構成のグリッドの使い方から考えると、これでエラーになるのはちょっと不便なので、何が起こっているのか調べてみることにしました。

というわけでコード的にはJCacheの話ですが、内容としてはHazelcast ClientをJCacheの実装として採用したケースの話になりますのでその点についてはご注意ください。

準備

Client/Server構成となるので、両方のプログラムが必要です。

Server側は非常に単純に作ったので、あとで載せます。

Client側はふつうに作るので、まずはMaven依存関係から記載。

        <!-- JCache -->
        <dependency>
            <groupId>javax.cache</groupId>
            <artifactId>cache-api</artifactId>
            <version>1.0.0</version>
        </dependency>
        <dependency>
            <groupId>com.hazelcast</groupId>
            <artifactId>hazelcast-client</artifactId>
            <version>3.6.2</version>
        </dependency>

        <!-- for Testing -->
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.12</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.4.1</version>
            <scope>test</scope>
        </dependency>

JCache APIと、JCacheの実装としてHazelcast Clientを追加。Hazelcastの場合、Hazelcast Clientがクラスパス上に存在していた場合、デフォルトでClientとして起動します。

独自のクラスを作成

なんでもいいので、単純なSerializableなクラスを作ります。今回は、こんなのにしました。
src/main/java/org/littlewings/jcache/hazelcast/MyValueClass.java

package org.littlewings.jcache.hazelcast;

import java.io.Serializable;

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

    private String value;

    public String getValue() {
        return value;
    }

    public void setValue(String value) {
        this.value = value;
    }
}

はい。

Server側

ここで、Server側を。こちらは、Groovyで超簡易に作成しました。
server.groovy

@Grab('javax.cache:cache-api:1.0.0')
@Grab('com.hazelcast:hazelcast:3.6.2')
import com.hazelcast.core.Hazelcast

def hazelcast = Hazelcast.newHazelcastInstance()

System.console().readLine("> Enter stop Hazelcast server.")

hazelcast.lifecycleService.shutdown()

これだけです。

とりあえず、このServerに起動していてもらいます。クラスパスなどには、一切追加は行いません。

$ groovy server.groovy
5 19, 2016 9:46:46 午後 com.hazelcast.config.XmlConfigLocator
情報: Loading 'hazelcast-default.xml' from classpath.
5 19, 2016 9:46:46 午後 com.hazelcast.instance.DefaultAddressPicker
情報: [LOCAL] [dev] [3.6.2] Prefer IPv4 stack is true.
5 19, 2016 9:46:46 午後 com.hazelcast.instance.DefaultAddressPicker
情報: [LOCAL] [dev] [3.6.2] Picked Address[172.17.0.1]:5701, using socket ServerSocket[addr=/0:0:0:0:0:0:0:0,localport=5701], bind any local is true
5 19, 2016 9:46:46 午後 com.hazelcast.system
情報: [172.17.0.1]:5701 [dev] [3.6.2] Hazelcast 3.6.2 (20160405 - 0f88699) starting at Address[172.17.0.1]:5701
5 19, 2016 9:46:46 午後 com.hazelcast.system
情報: [172.17.0.1]:5701 [dev] [3.6.2] Copyright (c) 2008-2016, Hazelcast, Inc. All Rights Reserved.
5 19, 2016 9:46:46 午後 com.hazelcast.system
情報: [172.17.0.1]:5701 [dev] [3.6.2] Configured Hazelcast Serialization version : 1
5 19, 2016 9:46:46 午後 com.hazelcast.spi.OperationService
情報: [172.17.0.1]:5701 [dev] [3.6.2] Backpressure is disabled
5 19, 2016 9:46:46 午後 com.hazelcast.spi.impl.operationexecutor.classic.ClassicOperationExecutor
情報: [172.17.0.1]:5701 [dev] [3.6.2] Starting with 4 generic operation threads and 8 partition operation threads.
5 19, 2016 9:46:47 午後 com.hazelcast.instance.Node
情報: [172.17.0.1]:5701 [dev] [3.6.2] Creating MulticastJoiner
5 19, 2016 9:46:47 午後 com.hazelcast.core.LifecycleService
情報: [172.17.0.1]:5701 [dev] [3.6.2] Address[172.17.0.1]:5701 is STARTING
5 19, 2016 9:46:47 午後 com.hazelcast.nio.tcp.nonblocking.NonBlockingIOThreadingModel
情報: [172.17.0.1]:5701 [dev] [3.6.2] TcpIpConnectionManager configured with Non Blocking IO-threading model: 3 input threads and 3 output threads
5 19, 2016 9:46:49 午後 com.hazelcast.cluster.impl.MulticastJoiner
情報: [172.17.0.1]:5701 [dev] [3.6.2] 


Members [1] {
	Member [172.17.0.1]:5701 this
}

5 19, 2016 9:46:49 午後 com.hazelcast.core.LifecycleService
情報: [172.17.0.1]:5701 [dev] [3.6.2] Address[172.17.0.1]:5701 is STARTED
> Enter stop Hazelcast server.

テストコード

それでは、このServerに対してJCacheのAPIでアクセスするテストコードを書きます。

src/test/java/org/littlewings/jcache/hazelcast/CacheClientTest.java

package org.littlewings.jcache.hazelcast;

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

import com.hazelcast.nio.serialization.HazelcastSerializationException;
import org.junit.Test;

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

public class CacheClientTest {
    // ここに、テストを書く!
}

まず最初に、JCache APIを真っ当に使って書いてみます。だいたいこんな感じのコードになると思います。

    @Test
    public void failedCreateCacheWithMyDefinedClass() {
        try (CachingProvider provider = Caching.getCachingProvider();
             CacheManager manager = provider.getCacheManager()) {
            Configuration<String, MyValueClass> configuration =
                    new MutableConfiguration<String, MyValueClass>()
                    .setTypes(String.class, MyValueClass.class);

            assertThatThrownBy(() -> manager.createCache("mySimpleCache", configuration))
                    .isInstanceOf(HazelcastSerializationException.class)
                    .hasMessage("java.lang.ClassNotFoundException: org.littlewings.jcache.hazelcast.MyValueClass");
        }
    }

ところが、テストコードが示しているように、このコードは実行に失敗します。しかも、シリアライズのエラー、その原因としてClassNotFoundExceptionと言われるわけです。

この時、スタックトレースとしてはこのような状態になります。

com.hazelcast.nio.serialization.HazelcastSerializationException: java.lang.ClassNotFoundException: org.littlewings.jcache.hazelcast.MyValueClass

	at com.hazelcast.internal.serialization.impl.JavaDefaultSerializers$ClassSerializer.read(JavaDefaultSerializers.java:181)
	at com.hazelcast.internal.serialization.impl.JavaDefaultSerializers$ClassSerializer.read(JavaDefaultSerializers.java:169)
	at com.hazelcast.internal.serialization.impl.StreamSerializerAdapter.read(StreamSerializerAdapter.java:46)
	at com.hazelcast.internal.serialization.impl.AbstractSerializationService.readObject(AbstractSerializationService.java:214)
	at com.hazelcast.internal.serialization.impl.ByteArrayObjectDataInput.readObject(ByteArrayObjectDataInput.java:600)
	at com.hazelcast.config.CacheConfig.readData(CacheConfig.java:543)
	at com.hazelcast.internal.serialization.impl.DataSerializer.read(DataSerializer.java:121)
	at com.hazelcast.internal.serialization.impl.DataSerializer.read(DataSerializer.java:47)
	at com.hazelcast.internal.serialization.impl.StreamSerializerAdapter.read(StreamSerializerAdapter.java:46)
	at com.hazelcast.internal.serialization.impl.AbstractSerializationService.toObject(AbstractSerializationService.java:170)
	at com.hazelcast.spi.impl.NodeEngineImpl.toObject(NodeEngineImpl.java:234)
	at com.hazelcast.client.impl.protocol.task.cache.CacheCreateConfigMessageTask.prepareOperation(CacheCreateConfigMessageTask.java:46)
	at com.hazelcast.client.impl.protocol.task.AbstractPartitionMessageTask.processMessage(AbstractPartitionMessageTask.java:58)
	at com.hazelcast.client.impl.protocol.task.AbstractMessageTask.initializeAndProcessMessage(AbstractMessageTask.java:118)
	at com.hazelcast.client.impl.protocol.task.AbstractMessageTask.run(AbstractMessageTask.java:98)
	at com.hazelcast.spi.impl.operationservice.impl.OperationRunnerImpl.run(OperationRunnerImpl.java:127)
	at com.hazelcast.spi.impl.operationexecutor.classic.OperationThread.processPartitionSpecificRunnable(OperationThread.java:159)
	at com.hazelcast.spi.impl.operationexecutor.classic.OperationThread.process(OperationThread.java:142)
	at com.hazelcast.spi.impl.operationexecutor.classic.OperationThread.doRun(OperationThread.java:124)
	at com.hazelcast.spi.impl.operationexecutor.classic.OperationThread.run(OperationThread.java:99)
	at ------ End remote and begin local stack-trace ------.(Unknown Source)
	at com.hazelcast.client.spi.impl.ClientInvocationFuture.resolveResponse(ClientInvocationFuture.java:128)
	at com.hazelcast.client.spi.impl.ClientInvocationFuture.get(ClientInvocationFuture.java:95)
	at com.hazelcast.client.spi.impl.ClientInvocationFuture.get(ClientInvocationFuture.java:74)
	at com.hazelcast.client.spi.impl.ClientInvocationFuture.get(ClientInvocationFuture.java:37)
	at com.hazelcast.client.cache.impl.HazelcastClientCacheManager.createConfig(HazelcastClientCacheManager.java:188)
	at com.hazelcast.cache.impl.AbstractHazelcastCacheManager.createCacheInternal(AbstractHazelcastCacheManager.java:116)
	at com.hazelcast.cache.impl.AbstractHazelcastCacheManager.createCache(AbstractHazelcastCacheManager.java:145)
	at com.hazelcast.cache.impl.AbstractHazelcastCacheManager.createCache(AbstractHazelcastCacheManager.java:63)
	at org.littlewings.jcache.hazelcast.CacheClientTest.failedCreateCacheWithMyDefinedClass(CacheClientTest.java:20)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:498)
	at org.junit.runners.model.FrameworkMethod$1.runReflectiveCall(FrameworkMethod.java:50)
	at org.junit.internal.runners.model.ReflectiveCallable.run(ReflectiveCallable.java:12)
	at org.junit.runners.model.FrameworkMethod.invokeExplosively(FrameworkMethod.java:47)
	at org.junit.internal.runners.statements.InvokeMethod.evaluate(InvokeMethod.java:17)
	at org.junit.runners.ParentRunner.runLeaf(ParentRunner.java:325)
	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:78)
	at org.junit.runners.BlockJUnit4ClassRunner.runChild(BlockJUnit4ClassRunner.java:57)
	at org.junit.runners.ParentRunner$3.run(ParentRunner.java:290)
	at org.junit.runners.ParentRunner$1.schedule(ParentRunner.java:71)
	at org.junit.runners.ParentRunner.runChildren(ParentRunner.java:288)
	at org.junit.runners.ParentRunner.access$000(ParentRunner.java:58)
	at org.junit.runners.ParentRunner$2.evaluate(ParentRunner.java:268)
	at org.junit.runners.ParentRunner.run(ParentRunner.java:363)
	at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
	at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:119)
	at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:42)
	at com.intellij.rt.execution.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:234)
	at com.intellij.rt.execution.junit.JUnitStarter.main(JUnitStarter.java:74)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:498)
	at com.intellij.rt.execution.application.AppMain.main(AppMain.java:144)
Caused by: java.lang.ClassNotFoundException
	at com.hazelcast.client.impl.protocol.ClientExceptionFactory$12.createException(ClientExceptionFactory.java:168)
	at com.hazelcast.client.impl.protocol.ClientExceptionFactory.createException(ClientExceptionFactory.java:613)
	at com.hazelcast.client.impl.protocol.ClientExceptionFactory.createException(ClientExceptionFactory.java:580)
	at com.hazelcast.client.spi.impl.ClientInvocationServiceSupport$ResponseThread.handleClientMessage(ClientInvocationServiceSupport.java:314)
	at com.hazelcast.client.spi.impl.ClientInvocationServiceSupport$ResponseThread.process(ClientInvocationServiceSupport.java:296)
	at com.hazelcast.client.spi.impl.ClientInvocationServiceSupport$ResponseThread.doRun(ClientInvocationServiceSupport.java:289)
	at com.hazelcast.client.spi.impl.ClientInvocationServiceSupport$ResponseThread.run(ClientInvocationServiceSupport.java:266)

ちなみにこれ、以下の部分より上はServer側で生成されたスタックトレースです。

	at ------ End remote and begin local stack-trace ------.(Unknown Source)

その内容が、Client側にも現れます。この時、Server側は一切エラーを吐きません。

なので、ClientとServerのスタックトレースが交互に現れた状態になっています。で、よく見るとCacheManager#createCacheでコケているようですね。

なにが起こったのか?

これで、なにが起こっているのかを説明しておきます。

CacheManager#createCacheを呼び出した時、Hazelcast ClientによるJCache実装は、その定義内容をServer側へシリアライズして送信します。

            Data cacheConfigData = clientContext.getSerializationService().toData(config);

https://github.com/hazelcast/hazelcast/blob/v3.6.2/hazelcast-client/src/main/java/com/hazelcast/client/cache/impl/HazelcastClientCacheManager.java#L182

            Future<ClientMessage> future = clientInvocation.invoke();
            if (syncCreate) {
                final ClientMessage response = future.get();

https://github.com/hazelcast/hazelcast/blob/v3.6.2/hazelcast-client/src/main/java/com/hazelcast/client/cache/impl/HazelcastClientCacheManager.java#L186-L188

Server側は、この内容を受け取りCacheを定義しようとします。

この時、定義内容をデシリアライズしようとするわけですが、送信内容にMutableConfiguration#setTypesした時のClassクラスが含まれています。

            Configuration<String, MyValueClass> configuration =
                    new MutableConfiguration<String, MyValueClass>()
                    .setTypes(String.class, MyValueClass.class);

今回は、MyValueClassというクラスです。

で、これをServer側でデシリアライズする時にClassクラスをロードしちゃうわけですね。

        //SUPER
        keyType = in.readObject();
        valueType = in.readObject();

https://github.com/hazelcast/hazelcast/blob/v3.6.2/hazelcast/src/main/java/com/hazelcast/config/CacheConfig.java#L541-L543

結果、ClassNotFoundExceptionとなるわけです。Client側には、シリアライズ時の処理で失敗したということで、まとめて報告されることになります。

回避方法を考える

というわけで、このままでは使えません。

もちろんServer側のクラスパスに自作のクラスを加えれば問題は解消できますが、Client/Server構成であることを考えると、その案は却下としたいところです。

Client/Server構成だからといって、CacheManager#createCacheせずに、いきなりCacheManager#getCacheしてみても、これはうまくいきません。

    @Test
    public void notCreateCacheIsNull() {
        try (CachingProvider provider = Caching.getCachingProvider();
             CacheManager manager = provider.getCacheManager();
             Cache<String, MyValueClass> cache = manager.getCache("mySimpleCache")) {
            assertThat(cache).isNull();  // CacheManager#createCacheしない場合は、null
        }
    }

多くのJCacheのCacheManagerの実装は、createCacheした時のCacheのインスタンスを内部的にMapで持っていることが多いみたいで、Hazelcastもそのひとつになります。で、Client側のCacheManagerにその定義が見つからない、と…。

というわけで、CacheManager#createCacheは呼ぶ必要があるわけです。

となると、まあ泥縄的な案しかないわけですが。

結果としては、MutableConfiguration#setTypesで独自の型を指定するのをやめると回避することができます。。

    @Test
    public void workaround() {
        Configuration<String, MyValueClass> configuration =
                new MutableConfiguration<String, MyValueClass>();
                // .setTypes(String.class, MyValueClass.class);  // <- 削除

        try (CachingProvider provider = Caching.getCachingProvider();
             CacheManager manager = provider.getCacheManager();
             Cache<String, MyValueClass> cache = manager.createCache("mySimpleCache", configuration)) {
            MyValueClass value = new MyValueClass();
            value.setValue("Hello JCache!!");

            cache.put("key1", value);

            assertThat(cache.get("key1").getValue())
                    .isEqualTo("Hello JCache!!");

            cache.remove("key1");
            assertThat(cache.containsKey("key1")).isFalse();
        }
    }

この場合、キーと値の型にObjectを指定したのと同義になります。

もしくは、Java SE標準APIの範囲でならsetTypesしてもいいですが…。

一応、この実装方法をとってもCache自体はタイプセーフを保つことはできます。

そもそもMutableConfiguration#setTypesで指定した型をいつ使うかですが、CacheManager#getCacheする時に追加の引数として使うもので(取得しようとしているCache定義の検証として)、指定しなくても動かないなんてことはありません。

ですが、極力指定すべきだとは思いますけどねぇ…。

かなり実装を透かして見た感じになりますが、ワークアラウンドでした。

ところで

他のClient/Server構成を取れる製品だと、どうなのでしょう?

Infinispanでは、MUtableConfiguration#setTypesで独自の型を指定しても、Serverへのデプロイなどなしのままで問題なく動きました。

Apache Igniteは…Client/Serverで動かしたことがありません(誰かお願いします)。

まとめ

というわけで、Hazelcast ClientをJCache実装に採用した場合に、シリアライズでエラーになるケースのひとつ(他は知りませんが)の回避策っぽいものを書いてみました。

が、個人的にはちょっと微妙です。MutableConfiguration#setTypesでハマるとは、たぶん使う側は考えないでしょうし、ここは本来指定すべきだと思いますからねぇ…。