Skip to content

Commit 3624f4e

Browse files
Move session replication with Spring Session Hazelcast back [DEX-300] (#699)
Some code samples were previously moved out into separate repositories. This makes them hard to maintain. Changes: - minor cleanups - TODO add profile for client instead of commenting code --------- Co-authored-by: Tomasz Gawęda <tomasz.gaweda@outlook.com>
1 parent b089de2 commit 3624f4e

File tree

9 files changed

+377
-0
lines changed

9 files changed

+377
-0
lines changed

spring/pom.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
<module>spring-hibernate-2ndlevel-cache</module>
4545
<module>springaware-annotation</module>
4646
<module>spring-boot-caching-jcache</module>
47+
<module>spring-boot-session-replication-spring-session-hazelcast</module>
4748
</modules>
4849

4950
<dependencies>
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
See the link:https://docs.hazelcast.com/tutorials/spring-session-hazelcast[tutorial].
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
4+
<modelVersion>4.0.0</modelVersion>
5+
<parent>
6+
<groupId>org.springframework.boot</groupId>
7+
<artifactId>spring-boot-starter-parent</artifactId>
8+
<version>3.4.0</version>
9+
<relativePath/> <!-- lookup parent from repository -->
10+
</parent>
11+
12+
<groupId>com.hazelcast.samples</groupId>
13+
<artifactId>spring-boot-session-replication-spring-session-hazelcast</artifactId>
14+
<version>0.1-SNAPSHOT</version>
15+
<name>Spring Boot Session Replication with Spring Session Hazelcast</name>
16+
17+
<properties>
18+
<hazelcast.version>5.5.0</hazelcast.version>
19+
</properties>
20+
21+
<dependencies>
22+
<dependency>
23+
<groupId>org.springframework.boot</groupId>
24+
<artifactId>spring-boot-starter-web</artifactId>
25+
</dependency>
26+
<dependency>
27+
<groupId>org.springframework.session</groupId>
28+
<artifactId>spring-session-hazelcast</artifactId>
29+
</dependency>
30+
31+
<dependency>
32+
<groupId>com.hazelcast</groupId>
33+
<artifactId>hazelcast</artifactId>
34+
<version>${hazelcast.version}</version>
35+
</dependency>
36+
37+
<dependency>
38+
<groupId>org.springframework.boot</groupId>
39+
<artifactId>spring-boot-starter-test</artifactId>
40+
<scope>test</scope>
41+
</dependency>
42+
</dependencies>
43+
44+
<build>
45+
<plugins>
46+
<plugin>
47+
<groupId>org.springframework.boot</groupId>
48+
<artifactId>spring-boot-maven-plugin</artifactId>
49+
</plugin>
50+
</plugins>
51+
</build>
52+
</project>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.hazelcast.guide;
2+
3+
import org.springframework.boot.SpringApplication;
4+
import org.springframework.boot.autoconfigure.SpringBootApplication;
5+
6+
@SpringBootApplication
7+
public class HazelcastSpringSessionApplication {
8+
9+
public static void main(String[] args) {
10+
SpringApplication.run(HazelcastSpringSessionApplication.class, args);
11+
}
12+
13+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package com.hazelcast.guide.config;
2+
3+
import com.hazelcast.config.AttributeConfig;
4+
import com.hazelcast.config.Config;
5+
import com.hazelcast.config.IndexConfig;
6+
import com.hazelcast.config.IndexType;
7+
import com.hazelcast.config.SerializerConfig;
8+
import com.hazelcast.core.Hazelcast;
9+
import com.hazelcast.core.HazelcastInstance;
10+
import org.springframework.context.annotation.Bean;
11+
import org.springframework.context.annotation.Configuration;
12+
import org.springframework.session.FlushMode;
13+
import org.springframework.session.MapSession;
14+
import org.springframework.session.SaveMode;
15+
import org.springframework.session.config.SessionRepositoryCustomizer;
16+
import org.springframework.session.hazelcast.HazelcastIndexedSessionRepository;
17+
import org.springframework.session.hazelcast.HazelcastSessionSerializer;
18+
import org.springframework.session.hazelcast.PrincipalNameExtractor;
19+
import org.springframework.session.hazelcast.config.annotation.SpringSessionHazelcastInstance;
20+
import org.springframework.session.hazelcast.config.annotation.web.http.EnableHazelcastHttpSession;
21+
22+
import java.time.Duration;
23+
24+
@Configuration
25+
@EnableHazelcastHttpSession
26+
class SessionConfiguration {
27+
28+
private final String SESSIONS_MAP_NAME = "spring-session-map-name";
29+
30+
@Bean
31+
public SessionRepositoryCustomizer<HazelcastIndexedSessionRepository> customize() {
32+
return (repository) -> {
33+
repository.setFlushMode(FlushMode.IMMEDIATE);
34+
repository.setSaveMode(SaveMode.ALWAYS);
35+
repository.setSessionMapName(SESSIONS_MAP_NAME);
36+
repository.setDefaultMaxInactiveInterval(Duration.ofSeconds(900));
37+
};
38+
}
39+
40+
@Bean
41+
@SpringSessionHazelcastInstance
42+
public HazelcastInstance hazelcastInstance() {
43+
Config config = new Config();
44+
config.setClusterName("spring-session-cluster");
45+
46+
config.getNetworkConfig().getJoin().getMulticastConfig().setEnabled(true);
47+
48+
// Add this attribute to be able to query sessions by their PRINCIPAL_NAME_ATTRIBUTE's
49+
AttributeConfig attributeConfig = new AttributeConfig()
50+
.setName(HazelcastIndexedSessionRepository.PRINCIPAL_NAME_ATTRIBUTE)
51+
.setExtractorClassName(PrincipalNameExtractor.class.getName());
52+
53+
// Configure the sessions map
54+
config.getMapConfig(SESSIONS_MAP_NAME)
55+
.addAttributeConfig(attributeConfig).addIndexConfig(
56+
new IndexConfig(IndexType.HASH, HazelcastIndexedSessionRepository.PRINCIPAL_NAME_ATTRIBUTE));
57+
58+
// Use custom serializer to de/serialize sessions faster. This is optional.
59+
// Note that, all members in a cluster and connected clients need to use the
60+
// same serializer for sessions. For instance, clients cannot use this serializer
61+
// where members are not configured to do so.
62+
SerializerConfig serializerConfig = new SerializerConfig();
63+
serializerConfig.setImplementation(new HazelcastSessionSerializer()).setTypeClass(MapSession.class);
64+
config.getSerializationConfig().addSerializerConfig(serializerConfig);
65+
66+
return Hazelcast.newHazelcastInstance(config);
67+
}
68+
69+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.hazelcast.guide.controller;
2+
3+
import com.hazelcast.core.HazelcastInstance;
4+
import org.springframework.web.bind.annotation.GetMapping;
5+
import org.springframework.web.bind.annotation.RestController;
6+
7+
/**
8+
* This controller is optional; it's used only to check cluster size to check if cluster already have all nodes connected
9+
* in the tests.
10+
*/
11+
@RestController
12+
public class ClusterController {
13+
14+
private final HazelcastInstance hazelcastInstance;
15+
16+
public ClusterController(HazelcastInstance hazelcastInstance) {
17+
this.hazelcastInstance = hazelcastInstance;
18+
}
19+
20+
@GetMapping("/clusterSize")
21+
public int getClusterSize() {
22+
return hazelcastInstance.getCluster().getMembers().size();
23+
}
24+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package com.hazelcast.guide.controller;
2+
3+
import jakarta.servlet.http.HttpServletRequest;
4+
import jakarta.servlet.http.HttpServletResponse;
5+
import jakarta.servlet.http.HttpSession;
6+
import org.springframework.http.MediaType;
7+
import org.springframework.session.FindByIndexNameSessionRepository;
8+
import org.springframework.session.Session;
9+
import org.springframework.session.hazelcast.HazelcastIndexedSessionRepository;
10+
import org.springframework.web.bind.annotation.GetMapping;
11+
import org.springframework.web.bind.annotation.RequestParam;
12+
import org.springframework.web.bind.annotation.RestController;
13+
14+
import java.text.DateFormat;
15+
import java.text.SimpleDateFormat;
16+
import java.util.Date;
17+
import java.util.LinkedHashMap;
18+
import java.util.Map;
19+
import java.util.stream.Collectors;
20+
21+
@RestController
22+
public class SessionController {
23+
24+
private static final String principalIndexName = HazelcastIndexedSessionRepository.PRINCIPAL_NAME_INDEX_NAME;
25+
private static final DateFormat formatter = new SimpleDateFormat("HH:mm:ss");
26+
27+
/**
28+
* Alternatively you can use {@link HazelcastIndexedSessionRepository} directly, then you need to define
29+
* a bean of this type in {@code com.hazelcast.guide.config.SessionConfiguration}.
30+
*/
31+
final FindByIndexNameSessionRepository<?> sessionRepository;
32+
33+
public SessionController(FindByIndexNameSessionRepository<?> sessionRepository) {
34+
this.sessionRepository = sessionRepository;
35+
}
36+
37+
/**
38+
* Creates a session for the request if there is no session of the request.
39+
*
40+
* @param principal Principal value of the session to be created
41+
* @return Message indicating the session creation or abortion result.
42+
*
43+
*/
44+
@GetMapping("/create")
45+
public String createSession(@RequestParam("principal") String principal, HttpServletRequest request, HttpServletResponse response) {
46+
HttpSession session = request.getSession(false);
47+
if (session == null) {
48+
session = request.getSession();
49+
session.setAttribute(principalIndexName, principal);
50+
return "Session created: " + session.getId();
51+
} else {
52+
return "Session already exists: " + session.getId();
53+
}
54+
}
55+
56+
/**
57+
* Lists all the sessions with the same {@link #principalIndexName} of the request's session.
58+
*
59+
* @return All sessions associated with this session's {@link #principalIndexName}.
60+
*/
61+
@GetMapping(value = "/list", produces = MediaType.TEXT_HTML_VALUE)
62+
public String listSessionsByPrincipal(HttpServletRequest request) {
63+
HttpSession session = request.getSession(false);
64+
if (session == null) {
65+
return "<html>No session found.</html>";
66+
}
67+
String principal = (String) session.getAttribute(principalIndexName);
68+
Map<String, ? extends Session> sessions = sessionRepository.findByPrincipalName(principal);
69+
return toHtmlTable(sessions.entrySet().stream().collect(Collectors.toMap(
70+
e -> e.getKey().substring(0, 8),
71+
e -> "Principal: " + session.getAttribute(principalIndexName))
72+
));
73+
}
74+
75+
/**
76+
* Returns the current session's details if the request has a session.
77+
*
78+
* @return Session details
79+
*/
80+
@GetMapping(value = "/info", produces = MediaType.TEXT_HTML_VALUE)
81+
public String getSessionInfo(HttpServletRequest request) {
82+
HttpSession session = request.getSession(false);
83+
if (session == null) {
84+
return "<html>No session found.</html>";
85+
}
86+
Map<String, Object> attributes = new LinkedHashMap<>();
87+
attributes.put("sessionId", session.getId());
88+
attributes.put("principal", session.getAttribute(principalIndexName));
89+
attributes.put("created", formatter.format(new Date(session.getCreationTime())));
90+
attributes.put("last accessed", formatter.format(new Date(session.getLastAccessedTime())));
91+
return toHtmlTable(attributes);
92+
}
93+
94+
private String toHtmlTable(Map<String, Object> attributes) {
95+
StringBuilder html = new StringBuilder("<html>");
96+
html.append("<table border=\"1\" cellpadding=\"5\" cellspacing=\"5\">");
97+
attributes.forEach((k, v) -> addHtmlTableRow(html, k, v));
98+
html.append("</table></html>");
99+
return html.toString();
100+
}
101+
102+
private void addHtmlTableRow(StringBuilder content, String key, Object value) {
103+
content.append("<tr>")
104+
.append("<th>").append(key).append("</th>")
105+
.append("<td>").append(value).append("</td>")
106+
.append("</tr>");
107+
}
108+
109+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package com.hazelcast.guide;
2+
3+
import org.junit.jupiter.api.Test;
4+
import org.slf4j.Logger;
5+
import org.slf4j.LoggerFactory;
6+
import org.springframework.boot.builder.SpringApplicationBuilder;
7+
import org.springframework.http.HttpEntity;
8+
import org.springframework.http.HttpHeaders;
9+
import org.springframework.http.ResponseEntity;
10+
import org.springframework.web.client.RestTemplate;
11+
import org.springframework.web.util.UriComponentsBuilder;
12+
13+
import java.time.Duration;
14+
import java.util.Base64;
15+
import java.util.Collections;
16+
import java.util.Map;
17+
18+
import static java.nio.charset.StandardCharsets.UTF_8;
19+
import static org.assertj.core.api.Assertions.assertThat;
20+
import static org.awaitility.Awaitility.await;
21+
import static org.springframework.http.HttpMethod.GET;
22+
23+
@SuppressWarnings("DataFlowIssue")
24+
class HazelcastSpringSessionApplicationTests {
25+
26+
private static final Logger logger = LoggerFactory.getLogger(HazelcastSpringSessionApplicationTests.class);
27+
28+
static final String COOKIE_NAME = "SESSION";
29+
30+
@Test
31+
void contextLoads() {
32+
// given
33+
String port1 = startApplication();
34+
String port2 = startApplication();
35+
Map<String, String> principalMap = Collections.singletonMap("principal", "hazelcast2020");
36+
37+
waitForCluster(port1);
38+
39+
// when
40+
ResponseEntity<?> response1 = makeRequest(port1, "create", null, principalMap);
41+
String sessionCookie1 = extractSessionCookie(response1);
42+
logger.info("First request headers: {}", response1.getHeaders());
43+
logger.info("First request body: {}", response1.getBody().toString());
44+
logger.info("First session cookie: {}", sessionCookie1);
45+
46+
// then
47+
ResponseEntity<?> response2 = makeRequest(port2, "create", sessionCookie1, principalMap);
48+
String body = response2.getBody().toString();
49+
logger.info("Body contains: {}", body);
50+
51+
assertThat(body).contains("Session already exists");
52+
}
53+
54+
private static String startApplication() {
55+
return new SpringApplicationBuilder(HazelcastSpringSessionApplication.class)
56+
.properties("server.port=0")
57+
.run()
58+
.getEnvironment()
59+
.getProperty("local.server.port");
60+
}
61+
62+
private String extractSessionCookie(ResponseEntity<?> response) {
63+
return response.getHeaders().get("Set-Cookie").stream()
64+
.filter(s -> s.contains(COOKIE_NAME))
65+
.map(s -> s.substring(COOKIE_NAME.length() + 1))
66+
.map(s -> s.contains(";") ? s.substring(0, s.indexOf(";")) : s)
67+
.map(s -> new String(Base64.getDecoder().decode(s)))
68+
.findFirst().orElse(null);
69+
}
70+
71+
@SuppressWarnings("SameParameterValue")
72+
private static ResponseEntity<?> makeRequest(String port, String endpoint, String sessionCookie, Map<String, String> parameters) {
73+
String url = "http://localhost:" + port + "/" + endpoint;
74+
75+
// Header
76+
HttpHeaders headers = new HttpHeaders();
77+
if (sessionCookie != null) {
78+
headers.add("Cookie", COOKIE_NAME + "=" +
79+
Base64.getEncoder().encodeToString(sessionCookie.getBytes(UTF_8)));
80+
}
81+
82+
// Query parameters
83+
UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(url);
84+
parameters.forEach(builder::queryParam);
85+
86+
// Request
87+
RestTemplate restTemplate = new RestTemplate();
88+
return restTemplate.exchange(builder.toUriString(), GET, new HttpEntity<>(headers), String.class);
89+
}
90+
91+
public static void waitForCluster(String port) {
92+
String url = "http://localhost:" + port + "/clusterSize";
93+
94+
var restTemplate = new RestTemplate();
95+
var requestEntity = new HttpEntity<>(new HttpHeaders());
96+
97+
await()
98+
.atMost(Duration.ofMinutes(5))
99+
.pollInterval(Duration.ofSeconds(1))
100+
.logging(logger::info)
101+
.until(() -> {
102+
ResponseEntity<Integer> clusterSize = restTemplate.exchange(url, GET, requestEntity, Integer.class);
103+
return clusterSize.getBody() == 2;
104+
});
105+
}
106+
107+
}

0 commit comments

Comments
 (0)