Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions spring/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
<module>spring-hibernate-2ndlevel-cache</module>
<module>springaware-annotation</module>
<module>spring-boot-caching-jcache</module>
<module>spring-boot-session-replication-spring-session-hazelcast</module>
</modules>

<dependencies>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
See the link:https://docs.hazelcast.com/tutorials/spring-session-hazelcast[tutorial].
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.0</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>

<groupId>com.hazelcast.samples</groupId>
<artifactId>spring-boot-session-replication-spring-session-hazelcast</artifactId>
<version>0.1-SNAPSHOT</version>
<name>Spring Boot Session Replication with Spring Session Hazelcast</name>

<properties>
<hazelcast.version>5.5.0</hazelcast.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-hazelcast</artifactId>
</dependency>

<dependency>
<groupId>com.hazelcast</groupId>
<artifactId>hazelcast</artifactId>
<version>${hazelcast.version}</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>
</plugin>
</plugins>
</build>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.hazelcast.guide;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class HazelcastSpringSessionApplication {

public static void main(String[] args) {
SpringApplication.run(HazelcastSpringSessionApplication.class, args);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.hazelcast.guide.config;

import com.hazelcast.config.AttributeConfig;
import com.hazelcast.config.Config;
import com.hazelcast.config.IndexConfig;
import com.hazelcast.config.IndexType;
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.FlushMode;
import org.springframework.session.MapSession;
import org.springframework.session.SaveMode;
import org.springframework.session.config.SessionRepositoryCustomizer;
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.SpringSessionHazelcastInstance;
import org.springframework.session.hazelcast.config.annotation.web.http.EnableHazelcastHttpSession;

import java.time.Duration;

@Configuration
@EnableHazelcastHttpSession
class SessionConfiguration {

private final String SESSIONS_MAP_NAME = "spring-session-map-name";

@Bean
public SessionRepositoryCustomizer<HazelcastIndexedSessionRepository> customize() {
return (repository) -> {
repository.setFlushMode(FlushMode.IMMEDIATE);
repository.setSaveMode(SaveMode.ALWAYS);
repository.setSessionMapName(SESSIONS_MAP_NAME);
repository.setDefaultMaxInactiveInterval(Duration.ofSeconds(900));
};
}

@Bean
@SpringSessionHazelcastInstance
public HazelcastInstance hazelcastInstance() {
Config config = new Config();
config.setClusterName("spring-session-cluster");

config.getNetworkConfig().getJoin().getMulticastConfig().setEnabled(true);

// Add this attribute to be able to query sessions by their PRINCIPAL_NAME_ATTRIBUTE's
AttributeConfig attributeConfig = new AttributeConfig()
.setName(HazelcastIndexedSessionRepository.PRINCIPAL_NAME_ATTRIBUTE)
.setExtractorClassName(PrincipalNameExtractor.class.getName());

// Configure the sessions map
config.getMapConfig(SESSIONS_MAP_NAME)
.addAttributeConfig(attributeConfig).addIndexConfig(
new IndexConfig(IndexType.HASH, HazelcastIndexedSessionRepository.PRINCIPAL_NAME_ATTRIBUTE));

// Use custom serializer to de/serialize sessions faster. This is optional.
// Note that, all members in a cluster and connected clients need to use the
// same serializer for sessions. For instance, clients cannot use this serializer
// where members are not configured to do so.
SerializerConfig serializerConfig = new SerializerConfig();
serializerConfig.setImplementation(new HazelcastSessionSerializer()).setTypeClass(MapSession.class);
config.getSerializationConfig().addSerializerConfig(serializerConfig);

return Hazelcast.newHazelcastInstance(config);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.hazelcast.guide.controller;

import com.hazelcast.core.HazelcastInstance;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

/**
* This controller is optional; it's used only to check cluster size to check if cluster already have all nodes connected
* in the tests.
*/
@RestController
public class ClusterController {

private final HazelcastInstance hazelcastInstance;

public ClusterController(HazelcastInstance hazelcastInstance) {
this.hazelcastInstance = hazelcastInstance;
}

@GetMapping("/clusterSize")
public int getClusterSize() {
return hazelcastInstance.getCluster().getMembers().size();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package com.hazelcast.guide.controller;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.springframework.http.MediaType;
import org.springframework.session.FindByIndexNameSessionRepository;
import org.springframework.session.Session;
import org.springframework.session.hazelcast.HazelcastIndexedSessionRepository;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Collectors;

@RestController
public class SessionController {

private static final String principalIndexName = HazelcastIndexedSessionRepository.PRINCIPAL_NAME_INDEX_NAME;
private static final DateFormat formatter = new SimpleDateFormat("HH:mm:ss");

/**
* Alternatively you can use {@link HazelcastIndexedSessionRepository} directly, then you need to define
* a bean of this type in {@code com.hazelcast.guide.config.SessionConfiguration}.
*/
final FindByIndexNameSessionRepository<?> sessionRepository;

public SessionController(FindByIndexNameSessionRepository<?> sessionRepository) {
this.sessionRepository = sessionRepository;
}

/**
* Creates a session for the request if there is no session of the request.
*
* @param principal Principal value of the session to be created
* @return Message indicating the session creation or abortion result.
*
*/
@GetMapping("/create")
public String createSession(@RequestParam("principal") String principal, HttpServletRequest request, HttpServletResponse response) {
HttpSession session = request.getSession(false);
if (session == null) {
session = request.getSession();
session.setAttribute(principalIndexName, principal);
return "Session created: " + session.getId();
} else {
return "Session already exists: " + session.getId();
}
}

/**
* Lists all the sessions with the same {@link #principalIndexName} of the request's session.
*
* @return All sessions associated with this session's {@link #principalIndexName}.
*/
@GetMapping(value = "/list", produces = MediaType.TEXT_HTML_VALUE)
public String listSessionsByPrincipal(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return "<html>No session found.</html>";
}
String principal = (String) session.getAttribute(principalIndexName);
Map<String, ? extends Session> sessions = sessionRepository.findByPrincipalName(principal);
return toHtmlTable(sessions.entrySet().stream().collect(Collectors.toMap(
e -> e.getKey().substring(0, 8),
e -> "Principal: " + session.getAttribute(principalIndexName))
));
}

/**
* Returns the current session's details if the request has a session.
*
* @return Session details
*/
@GetMapping(value = "/info", produces = MediaType.TEXT_HTML_VALUE)
public String getSessionInfo(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return "<html>No session found.</html>";
}
Map<String, Object> attributes = new LinkedHashMap<>();
attributes.put("sessionId", session.getId());
attributes.put("principal", session.getAttribute(principalIndexName));
attributes.put("created", formatter.format(new Date(session.getCreationTime())));
attributes.put("last accessed", formatter.format(new Date(session.getLastAccessedTime())));
return toHtmlTable(attributes);
}

private String toHtmlTable(Map<String, Object> attributes) {
StringBuilder html = new StringBuilder("<html>");
html.append("<table border=\"1\" cellpadding=\"5\" cellspacing=\"5\">");
attributes.forEach((k, v) -> addHtmlTableRow(html, k, v));
html.append("</table></html>");
return html.toString();
}

private void addHtmlTableRow(StringBuilder content, String key, Object value) {
content.append("<tr>")
.append("<th>").append(key).append("</th>")
.append("<td>").append(value).append("</td>")
.append("</tr>");
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package com.hazelcast.guide;

import org.junit.jupiter.api.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseEntity;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;

import java.time.Duration;
import java.util.Base64;
import java.util.Collections;
import java.util.Map;

import static java.nio.charset.StandardCharsets.UTF_8;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.springframework.http.HttpMethod.GET;

@SuppressWarnings("DataFlowIssue")
class HazelcastSpringSessionApplicationTests {

private static final Logger logger = LoggerFactory.getLogger(HazelcastSpringSessionApplicationTests.class);

static final String COOKIE_NAME = "SESSION";

@Test
void contextLoads() {
// given
String port1 = startApplication();
String port2 = startApplication();
Map<String, String> principalMap = Collections.singletonMap("principal", "hazelcast2020");

waitForCluster(port1);

// when
ResponseEntity<?> response1 = makeRequest(port1, "create", null, principalMap);
String sessionCookie1 = extractSessionCookie(response1);
logger.info("First request headers: {}", response1.getHeaders());
logger.info("First request body: {}", response1.getBody().toString());
logger.info("First session cookie: {}", sessionCookie1);

// then
ResponseEntity<?> response2 = makeRequest(port2, "create", sessionCookie1, principalMap);
String body = response2.getBody().toString();
logger.info("Body contains: {}", body);

assertThat(body).contains("Session already exists");
}

private static String startApplication() {
return new SpringApplicationBuilder(HazelcastSpringSessionApplication.class)
.properties("server.port=0")
.run()
.getEnvironment()
.getProperty("local.server.port");
}

private String extractSessionCookie(ResponseEntity<?> response) {
return response.getHeaders().get("Set-Cookie").stream()
.filter(s -> s.contains(COOKIE_NAME))
.map(s -> s.substring(COOKIE_NAME.length() + 1))
.map(s -> s.contains(";") ? s.substring(0, s.indexOf(";")) : s)
.map(s -> new String(Base64.getDecoder().decode(s)))
.findFirst().orElse(null);
}

@SuppressWarnings("SameParameterValue")
private static ResponseEntity<?> makeRequest(String port, String endpoint, String sessionCookie, Map<String, String> parameters) {
String url = "http://localhost:" + port + "/" + endpoint;

// Header
HttpHeaders headers = new HttpHeaders();
if (sessionCookie != null) {
headers.add("Cookie", COOKIE_NAME + "=" +
Base64.getEncoder().encodeToString(sessionCookie.getBytes(UTF_8)));
}

// Query parameters
UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(url);
parameters.forEach(builder::queryParam);

// Request
RestTemplate restTemplate = new RestTemplate();
return restTemplate.exchange(builder.toUriString(), GET, new HttpEntity<>(headers), String.class);
}

public static void waitForCluster(String port) {
String url = "http://localhost:" + port + "/clusterSize";

var restTemplate = new RestTemplate();
var requestEntity = new HttpEntity<>(new HttpHeaders());

await()
.atMost(Duration.ofMinutes(5))
.pollInterval(Duration.ofSeconds(1))
.logging(logger::info)
.until(() -> {
ResponseEntity<Integer> clusterSize = restTemplate.exchange(url, GET, requestEntity, Integer.class);
return clusterSize.getBody() == 2;
});
}

}
Loading