diff --git a/spring/pom.xml b/spring/pom.xml index 4bdd709bd..d634003ac 100644 --- a/spring/pom.xml +++ b/spring/pom.xml @@ -43,6 +43,7 @@ spring-hibernate-2ndlevel-cache springaware-annotation spring-boot-caching-jcache + spring-boot-session-replication-spring-session-hazelcast diff --git a/spring/spring-boot-session-replication-spring-session-hazelcast/README.adoc b/spring/spring-boot-session-replication-spring-session-hazelcast/README.adoc new file mode 100644 index 000000000..7b4b47474 --- /dev/null +++ b/spring/spring-boot-session-replication-spring-session-hazelcast/README.adoc @@ -0,0 +1 @@ +See the link:https://docs.hazelcast.com/tutorials/spring-session-hazelcast[tutorial]. diff --git a/spring/spring-boot-session-replication-spring-session-hazelcast/pom.xml b/spring/spring-boot-session-replication-spring-session-hazelcast/pom.xml new file mode 100644 index 000000000..71b44f091 --- /dev/null +++ b/spring/spring-boot-session-replication-spring-session-hazelcast/pom.xml @@ -0,0 +1,52 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.4.0 + + + + com.hazelcast.samples + spring-boot-session-replication-spring-session-hazelcast + 0.1-SNAPSHOT + Spring Boot Session Replication with Spring Session Hazelcast + + + 5.5.0 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.session + spring-session-hazelcast + + + + com.hazelcast + hazelcast + ${hazelcast.version} + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/spring/spring-boot-session-replication-spring-session-hazelcast/src/main/java/com/hazelcast/guide/HazelcastSpringSessionApplication.java b/spring/spring-boot-session-replication-spring-session-hazelcast/src/main/java/com/hazelcast/guide/HazelcastSpringSessionApplication.java new file mode 100644 index 000000000..e11e503c4 --- /dev/null +++ b/spring/spring-boot-session-replication-spring-session-hazelcast/src/main/java/com/hazelcast/guide/HazelcastSpringSessionApplication.java @@ -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); + } + +} diff --git a/spring/spring-boot-session-replication-spring-session-hazelcast/src/main/java/com/hazelcast/guide/config/SessionConfiguration.java b/spring/spring-boot-session-replication-spring-session-hazelcast/src/main/java/com/hazelcast/guide/config/SessionConfiguration.java new file mode 100644 index 000000000..ce234aa46 --- /dev/null +++ b/spring/spring-boot-session-replication-spring-session-hazelcast/src/main/java/com/hazelcast/guide/config/SessionConfiguration.java @@ -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 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); + } + +} diff --git a/spring/spring-boot-session-replication-spring-session-hazelcast/src/main/java/com/hazelcast/guide/controller/ClusterController.java b/spring/spring-boot-session-replication-spring-session-hazelcast/src/main/java/com/hazelcast/guide/controller/ClusterController.java new file mode 100644 index 000000000..dd879691e --- /dev/null +++ b/spring/spring-boot-session-replication-spring-session-hazelcast/src/main/java/com/hazelcast/guide/controller/ClusterController.java @@ -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(); + } +} diff --git a/spring/spring-boot-session-replication-spring-session-hazelcast/src/main/java/com/hazelcast/guide/controller/SessionController.java b/spring/spring-boot-session-replication-spring-session-hazelcast/src/main/java/com/hazelcast/guide/controller/SessionController.java new file mode 100644 index 000000000..0420fca66 --- /dev/null +++ b/spring/spring-boot-session-replication-spring-session-hazelcast/src/main/java/com/hazelcast/guide/controller/SessionController.java @@ -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 "No session found."; + } + String principal = (String) session.getAttribute(principalIndexName); + Map 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 "No session found."; + } + Map 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 attributes) { + StringBuilder html = new StringBuilder(""); + html.append(""); + attributes.forEach((k, v) -> addHtmlTableRow(html, k, v)); + html.append("
"); + return html.toString(); + } + + private void addHtmlTableRow(StringBuilder content, String key, Object value) { + content.append("") + .append("").append(key).append("") + .append("").append(value).append("") + .append(""); + } + +} diff --git a/spring/spring-boot-session-replication-spring-session-hazelcast/src/main/resources/application.properties b/spring/spring-boot-session-replication-spring-session-hazelcast/src/main/resources/application.properties new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/spring/spring-boot-session-replication-spring-session-hazelcast/src/main/resources/application.properties @@ -0,0 +1 @@ + diff --git a/spring/spring-boot-session-replication-spring-session-hazelcast/src/test/java/com/hazelcast/guide/HazelcastSpringSessionApplicationTests.java b/spring/spring-boot-session-replication-spring-session-hazelcast/src/test/java/com/hazelcast/guide/HazelcastSpringSessionApplicationTests.java new file mode 100644 index 000000000..60e452e21 --- /dev/null +++ b/spring/spring-boot-session-replication-spring-session-hazelcast/src/test/java/com/hazelcast/guide/HazelcastSpringSessionApplicationTests.java @@ -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 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 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 clusterSize = restTemplate.exchange(url, GET, requestEntity, Integer.class); + return clusterSize.getBody() == 2; + }); + } + +}