diff --git a/spring-session-docs/src/docs/asciidoc/guides/java-hazelcast.adoc b/spring-session-docs/src/docs/asciidoc/guides/java-hazelcast.adoc index 03727f497..b0fd7d201 100644 --- a/spring-session-docs/src/docs/asciidoc/guides/java-hazelcast.adoc +++ b/spring-session-docs/src/docs/asciidoc/guides/java-hazelcast.adoc @@ -97,11 +97,18 @@ The filter is in charge of replacing the `HttpSession` implementation to be back In this instance, Spring Session is backed by Hazelcast. <2> In order to support retrieval of sessions by principal name index, an appropriate `ValueExtractor` needs to be registered. Spring Session provides `PrincipalNameExtractor` for this purpose. -<3> We create a `HazelcastInstance` that connects Spring Session to Hazelcast. +<3> In order to serialize `MapSession` objects efficiently, `HazelcastSessionSerializer` needs to be registered. If this +is not set, Hazelcast will serialize sessions using native Java serialization. +<4> We create a `HazelcastInstance` that connects Spring Session to Hazelcast. By default, the application starts and connects to an embedded instance of Hazelcast. For more information on configuring Hazelcast, see the https://docs.hazelcast.org/docs/{hazelcast-version}/manual/html-single/index.html#hazelcast-configuration[reference documentation]. ==== +NOTE: If `HazelcastSessionSerializer` is preferred, it needs to be configured for all Hazelcast cluster members before they start. +In a Hazelcast cluster, all members should use the same serialization method for sessions. Also, if Hazelcast Client/Server topology +is used, then both members and clients must use the same serialization method. The serializer can be registered via `ClientConfig` +with the same `SerializerConfiguration` of members. + == Servlet Container Initialization Our <> created a Spring bean named `springSessionRepositoryFilter` that implements `Filter`. diff --git a/spring-session-docs/src/test/java/docs/http/HazelcastHttpSessionConfig.java b/spring-session-docs/src/test/java/docs/http/HazelcastHttpSessionConfig.java index 3aad03149..c77bc1d32 100644 --- a/spring-session-docs/src/test/java/docs/http/HazelcastHttpSessionConfig.java +++ b/spring-session-docs/src/test/java/docs/http/HazelcastHttpSessionConfig.java @@ -19,12 +19,15 @@ import com.hazelcast.config.Config; import com.hazelcast.config.MapAttributeConfig; import com.hazelcast.config.MapIndexConfig; +import com.hazelcast.config.SerializerConfig; 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.session.MapSession; import org.springframework.session.hazelcast.HazelcastIndexedSessionRepository; +import org.springframework.session.hazelcast.HazelcastSessionSerializer; import org.springframework.session.hazelcast.PrincipalNameExtractor; import org.springframework.session.hazelcast.config.annotation.web.http.EnableHazelcastHttpSession; @@ -42,7 +45,10 @@ public HazelcastInstance hazelcastInstance() { config.getMapConfig(HazelcastIndexedSessionRepository.DEFAULT_SESSION_MAP_NAME) // <2> .addMapAttributeConfig(attributeConfig).addMapIndexConfig( new MapIndexConfig(HazelcastIndexedSessionRepository.PRINCIPAL_NAME_ATTRIBUTE, false)); - return Hazelcast.newHazelcastInstance(config); // <3> + SerializerConfig serializerConfig = new SerializerConfig(); + serializerConfig.setImplementation(new HazelcastSessionSerializer()).setTypeClass(MapSession.class); + config.getSerializationConfig().addSerializerConfig(serializerConfig); // <3> + return Hazelcast.newHazelcastInstance(config); // <4> } } diff --git a/spring-session-hazelcast/src/integration-test/java/org/springframework/session/hazelcast/HazelcastITestUtils.java b/spring-session-hazelcast/src/integration-test/java/org/springframework/session/hazelcast/HazelcastITestUtils.java index 00eec67b2..76158a0d5 100644 --- a/spring-session-hazelcast/src/integration-test/java/org/springframework/session/hazelcast/HazelcastITestUtils.java +++ b/spring-session-hazelcast/src/integration-test/java/org/springframework/session/hazelcast/HazelcastITestUtils.java @@ -20,9 +20,12 @@ import com.hazelcast.config.MapAttributeConfig; import com.hazelcast.config.MapIndexConfig; import com.hazelcast.config.NetworkConfig; +import com.hazelcast.config.SerializerConfig; import com.hazelcast.core.Hazelcast; import com.hazelcast.core.HazelcastInstance; +import org.springframework.session.MapSession; + /** * Utility class for Hazelcast integration tests. * @@ -48,6 +51,9 @@ static HazelcastInstance embeddedHazelcastServer() { config.getMapConfig(HazelcastIndexedSessionRepository.DEFAULT_SESSION_MAP_NAME) .addMapAttributeConfig(attributeConfig).addMapIndexConfig( new MapIndexConfig(HazelcastIndexedSessionRepository.PRINCIPAL_NAME_ATTRIBUTE, false)); + SerializerConfig serializerConfig = new SerializerConfig(); + serializerConfig.setImplementation(new HazelcastSessionSerializer()).setTypeClass(MapSession.class); + config.getSerializationConfig().addSerializerConfig(serializerConfig); return Hazelcast.newHazelcastInstance(config); } diff --git a/spring-session-hazelcast/src/main/java/org/springframework/session/hazelcast/HazelcastSessionSerializer.java b/spring-session-hazelcast/src/main/java/org/springframework/session/hazelcast/HazelcastSessionSerializer.java new file mode 100644 index 000000000..d88df92b7 --- /dev/null +++ b/spring-session-hazelcast/src/main/java/org/springframework/session/hazelcast/HazelcastSessionSerializer.java @@ -0,0 +1,159 @@ +/* + * Copyright 2014-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.session.hazelcast; + +import java.io.EOFException; +import java.io.IOException; +import java.time.Duration; +import java.time.Instant; + +import com.hazelcast.nio.ObjectDataInput; +import com.hazelcast.nio.ObjectDataOutput; +import com.hazelcast.nio.serialization.StreamSerializer; + +import org.springframework.session.MapSession; + +/** + * A {@link com.hazelcast.nio.serialization.Serializer} implementation that handles the + * (de)serialization of {@link MapSession} stored on {@link com.hazelcast.core.IMap}. + * + *

+ * The use of this serializer is optional and provides faster serialization of sessions. + * If not configured to be used, Hazelcast will serialize sessions via + * {@link java.io.Serializable} by default. + * + *

+ * If multiple instances of a Spring application is run, then all of them need to use the + * same serialization method. If this serializer is registered on one instance and not + * another one, then it will end up with HazelcastSerializationException. The same applies + * when clients are configured to use this serializer but not the members, and vice versa. + * Also note that, if a new instance is created with this serialization but the existing + * Hazelcast cluster contains the values not serialized by this but instead the default + * one, this will result in incompatibility again. + * + *

+ * An example of how to register the serializer on embedded instance can be seen below: + * + *

+ * Config config = new Config();
+ *
+ * // ... other configurations for Hazelcast ...
+ *
+ * SerializerConfig serializerConfig = new SerializerConfig();
+ * serializerConfig.setImplementation(new HazelcastSessionSerializer()).setTypeClass(MapSession.class);
+ * config.getSerializationConfig().addSerializerConfig(serializerConfig);
+ *
+ * HazelcastInstance hazelcastInstance = Hazelcast.newHazelcastInstance(config);
+ * 
+ * + * Below is the example of how to register the serializer on client instance. Note that, + * to use the serializer in client/server mode, the serializer - and hence + * {@link MapSession}, must exist on the server's classpath and must be registered via + * {@link com.hazelcast.config.SerializerConfig} with the configuration above for each + * server. + * + *
+ * ClientConfig clientConfig = new ClientConfig();
+ *
+ * // ... other configurations for Hazelcast Client ...
+ *
+ * SerializerConfig serializerConfig = new SerializerConfig();
+ * serializerConfig.setImplementation(new HazelcastSessionSerializer()).setTypeClass(MapSession.class);
+ * clientConfig.getSerializationConfig().addSerializerConfig(serializerConfig);
+ *
+ * HazelcastInstance hazelcastClient = HazelcastClient.newHazelcastClient(clientConfig);
+ * 
+ * + * @author Enes Ozcan + * @since 2.4.0 + */ +public class HazelcastSessionSerializer implements StreamSerializer { + + private static final int SERIALIZER_TYPE_ID = 1453; + + @Override + public void write(ObjectDataOutput out, MapSession session) throws IOException { + out.writeUTF(session.getOriginalId()); + out.writeUTF(session.getId()); + writeInstant(out, session.getCreationTime()); + writeInstant(out, session.getLastAccessedTime()); + writeDuration(out, session.getMaxInactiveInterval()); + for (String attrName : session.getAttributeNames()) { + Object attrValue = session.getAttribute(attrName); + if (attrValue != null) { + out.writeUTF(attrName); + out.writeObject(attrValue); + } + } + } + + private void writeInstant(ObjectDataOutput out, Instant instant) throws IOException { + out.writeLong(instant.getEpochSecond()); + out.writeInt(instant.getNano()); + } + + private void writeDuration(ObjectDataOutput out, Duration duration) throws IOException { + out.writeLong(duration.getSeconds()); + out.writeInt(duration.getNano()); + } + + @Override + public MapSession read(ObjectDataInput in) throws IOException { + String originalId = in.readUTF(); + MapSession cached = new MapSession(originalId); + cached.setId(in.readUTF()); + cached.setCreationTime(readInstant(in)); + cached.setLastAccessedTime(readInstant(in)); + cached.setMaxInactiveInterval(readDuration(in)); + try { + while (true) { + // During write, it's not possible to write + // number of non-null attributes without an extra + // iteration. Hence the attributes are read until + // EOF here. + String attrName = in.readUTF(); + Object attrValue = in.readObject(); + cached.setAttribute(attrName, attrValue); + } + } + catch (EOFException ignored) { + } + return cached; + } + + private Instant readInstant(ObjectDataInput in) throws IOException { + long seconds = in.readLong(); + int nanos = in.readInt(); + return Instant.ofEpochSecond(seconds, nanos); + } + + private Duration readDuration(ObjectDataInput in) throws IOException { + long seconds = in.readLong(); + int nanos = in.readInt(); + return Duration.ofSeconds(seconds, nanos); + } + + @Override + public int getTypeId() { + return SERIALIZER_TYPE_ID; + } + + @Override + public void destroy() { + } + +} diff --git a/spring-session-hazelcast/src/test/java/org/springframework/session/hazelcast/HazelcastSessionSerializerTests.java b/spring-session-hazelcast/src/test/java/org/springframework/session/hazelcast/HazelcastSessionSerializerTests.java new file mode 100644 index 000000000..b79a3bf1e --- /dev/null +++ b/spring-session-hazelcast/src/test/java/org/springframework/session/hazelcast/HazelcastSessionSerializerTests.java @@ -0,0 +1,99 @@ +/* + * Copyright 2014-2020 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.session.hazelcast; + +import java.io.Serializable; +import java.time.Duration; +import java.time.Instant; + +import com.hazelcast.config.SerializationConfig; +import com.hazelcast.config.SerializerConfig; +import com.hazelcast.internal.serialization.impl.DefaultSerializationServiceBuilder; +import com.hazelcast.nio.serialization.Data; +import com.hazelcast.spi.serialization.SerializationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.session.MapSession; + +import static org.assertj.core.api.Assertions.assertThat; + +class HazelcastSessionSerializerTests { + + private SerializationService serializationService; + + @BeforeEach + void setUp() { + SerializationConfig serializationConfig = new SerializationConfig(); + SerializerConfig serializerConfig = new SerializerConfig().setImplementation(new HazelcastSessionSerializer()) + .setTypeClass(MapSession.class); + serializationConfig.addSerializerConfig(serializerConfig); + this.serializationService = new DefaultSerializationServiceBuilder().setConfig(serializationConfig).build(); + } + + @Test + void serializeSessionWithStreamSerializer() { + MapSession originalSession = new MapSession(); + originalSession.setAttribute("attr1", "value1"); + originalSession.setAttribute("attr2", "value2"); + originalSession.setAttribute("attr3", new SerializableTestAttribute(3)); + originalSession.setMaxInactiveInterval(Duration.ofDays(5)); + originalSession.setLastAccessedTime(Instant.now()); + originalSession.setId("custom-id"); + + Data serialized = this.serializationService.toData(originalSession); + MapSession cached = this.serializationService.toObject(serialized); + + assertThat(originalSession.getCreationTime()).isEqualTo(cached.getCreationTime()); + assertThat(originalSession.getMaxInactiveInterval()).isEqualTo(cached.getMaxInactiveInterval()); + assertThat(originalSession.getId()).isEqualTo(cached.getId()); + assertThat(originalSession.getOriginalId()).isEqualTo(cached.getOriginalId()); + assertThat(originalSession.getAttributeNames().size()).isEqualTo(cached.getAttributeNames().size()); + assertThat(originalSession.getAttribute("attr1")).isEqualTo(cached.getAttribute("attr1")); + assertThat(originalSession.getAttribute("attr2")).isEqualTo(cached.getAttribute("attr2")); + assertThat(originalSession.getAttribute("attr3")) + .isEqualTo(cached.getAttribute("attr3")); + } + + static class SerializableTestAttribute implements Serializable { + + private int id; + + SerializableTestAttribute(int id) { + this.id = id; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof SerializableTestAttribute)) { + return false; + } + SerializableTestAttribute that = (SerializableTestAttribute) o; + return this.id == that.id; + } + + @Override + public int hashCode() { + return this.id; + } + + } + +}