diff --git a/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/JedisConnectionConfiguration.java b/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/JedisConnectionConfiguration.java index 8ff0da512d5d..5c7eaf3b1d05 100644 --- a/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/JedisConnectionConfiguration.java +++ b/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/JedisConnectionConfiguration.java @@ -103,6 +103,7 @@ private JedisConnectionFactory createJedisConnectionFactory( Assert.state(sentinelConfig != null, "'sentinelConfig' must not be null"); yield new JedisConnectionFactory(sentinelConfig, clientConfiguration); } + case STATIC_MASTER_REPLICA -> throw new IllegalStateException("Static master replica is not supported for Jedis"); }; } diff --git a/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/LettuceConnectionConfiguration.java b/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/LettuceConnectionConfiguration.java index b840cd6db3e4..944d01357659 100644 --- a/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/LettuceConnectionConfiguration.java +++ b/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/LettuceConnectionConfiguration.java @@ -17,6 +17,8 @@ package org.springframework.boot.data.redis.autoconfigure; import java.time.Duration; +import java.util.Collections; +import java.util.List; import io.lettuce.core.ClientOptions; import io.lettuce.core.ReadFrom; @@ -37,6 +39,8 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnThreading; +import org.springframework.boot.data.redis.autoconfigure.RedisConnectionDetails.Node; +import org.springframework.boot.data.redis.autoconfigure.RedisProperties.Lettuce; import org.springframework.boot.data.redis.autoconfigure.RedisProperties.Lettuce.Cluster.Refresh; import org.springframework.boot.data.redis.autoconfigure.RedisProperties.Pool; import org.springframework.boot.ssl.SslBundle; @@ -49,11 +53,13 @@ import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.RedisSentinelConfiguration; import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.RedisStaticMasterReplicaConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration.LettuceClientConfigurationBuilder; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.connection.lettuce.LettucePoolingClientConfiguration; import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; /** @@ -64,6 +70,7 @@ * @author Moritz Halbritter * @author Phillip Webb * @author Scott Frederick + * @author Yong-Hyun Kim */ @Configuration(proxyBeanMethods = false) @ConditionalOnClass(RedisClient.class) @@ -120,6 +127,12 @@ private LettuceConnectionFactory createConnectionFactory( LettuceClientConfiguration clientConfiguration = getLettuceClientConfiguration( clientConfigurationBuilderCustomizers, clientOptionsBuilderCustomizers, clientResources, getProperties().getLettuce().getPool()); + + RedisStaticMasterReplicaConfiguration staticMasterReplicaConfiguration = getStaticMasterReplicaConfiguration(); + if (staticMasterReplicaConfiguration != null) { + return new LettuceConnectionFactory(staticMasterReplicaConfiguration, clientConfiguration); + } + return switch (this.mode) { case STANDALONE -> new LettuceConnectionFactory(getStandaloneConfig(), clientConfiguration); case CLUSTER -> { @@ -132,9 +145,34 @@ private LettuceConnectionFactory createConnectionFactory( Assert.state(sentinelConfig != null, "'sentinelConfig' must not be null"); yield new LettuceConnectionFactory(sentinelConfig, clientConfiguration); } + case STATIC_MASTER_REPLICA -> { + RedisStaticMasterReplicaConfiguration configuration = getStaticMasterReplicaConfiguration(); + Assert.state(configuration != null, "'staticMasterReplicaConfiguration' must not be null"); + yield new LettuceConnectionFactory(configuration, clientConfiguration); + } }; } + private @Nullable RedisStaticMasterReplicaConfiguration getStaticMasterReplicaConfiguration() { + RedisProperties.Lettuce lettuce = getProperties().getLettuce(); + + if (!CollectionUtils.isEmpty(lettuce.getNodes())) { + List nodes = asNodes(lettuce.getNodes()); + RedisStaticMasterReplicaConfiguration configuration = new RedisStaticMasterReplicaConfiguration( + nodes.get(0).host(), nodes.get(0).port()); + configuration.setUsername(getProperties().getUsername()); + if (StringUtils.hasText(getProperties().getPassword())) { + configuration.setPassword(getProperties().getPassword()); + } + configuration.setDatabase(getProperties().getDatabase()); + nodes.stream().skip(1).forEach((node) -> configuration.addNode(node.host(), node.port())); + + return configuration; + } + + return null; + } + private LettuceClientConfiguration getLettuceClientConfiguration( ObjectProvider clientConfigurationBuilderCustomizers, ObjectProvider clientOptionsBuilderCustomizers, @@ -250,6 +288,20 @@ private void customizeConfigurationFromUrl(LettuceClientConfiguration.LettuceCli } } + private List asNodes(@Nullable List nodes) { + if (nodes == null) { + return Collections.emptyList(); + } + return nodes.stream().map(this::asNode).toList(); + } + + private Node asNode(String node) { + int portSeparatorIndex = node.lastIndexOf(':'); + String host = node.substring(0, portSeparatorIndex); + int port = Integer.parseInt(node.substring(portSeparatorIndex + 1)); + return new Node(host, port); + } + /** * Inner class to allow optional commons-pool2 dependency. */ diff --git a/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/RedisConnectionConfiguration.java b/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/RedisConnectionConfiguration.java index 024d7519b249..5e3b1ba09a93 100644 --- a/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/RedisConnectionConfiguration.java +++ b/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/RedisConnectionConfiguration.java @@ -35,6 +35,7 @@ import org.springframework.data.redis.connection.RedisStandaloneConfiguration; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; +import org.springframework.util.CollectionUtils; /** * Base Redis connection configuration. @@ -48,6 +49,7 @@ * @author Andy Wilkinson * @author Phillip Webb * @author Yanming Zhou + * @author Yong-Hyun Kim */ abstract class RedisConnectionConfiguration { @@ -156,7 +158,7 @@ protected final RedisProperties getProperties() { protected @Nullable SslBundle getSslBundle() { return switch (this.mode) { - case STANDALONE -> (this.connectionDetails.getStandalone() != null) + case STANDALONE, STATIC_MASTER_REPLICA -> (this.connectionDetails.getStandalone() != null) ? this.connectionDetails.getStandalone().getSslBundle() : null; case CLUSTER -> (this.connectionDetails.getCluster() != null) ? this.connectionDetails.getCluster().getSslBundle() : null; @@ -197,12 +199,15 @@ private Mode determineMode() { if (getClusterConfiguration() != null) { return Mode.CLUSTER; } + if (!CollectionUtils.isEmpty(this.properties.getLettuce().getNodes())) { + return Mode.STATIC_MASTER_REPLICA; + } return Mode.STANDALONE; } enum Mode { - STANDALONE, CLUSTER, SENTINEL + STANDALONE, CLUSTER, SENTINEL, STATIC_MASTER_REPLICA } diff --git a/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/RedisProperties.java b/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/RedisProperties.java index 344a3387850f..572b17601ed9 100644 --- a/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/RedisProperties.java +++ b/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/RedisProperties.java @@ -34,6 +34,7 @@ * @author Stephane Nicoll * @author Scott Frederick * @author Yanming Zhou + * @author Yong-Hyun Kim * @since 4.0.0 */ @ConfigurationProperties("spring.data.redis") @@ -482,6 +483,20 @@ public static class Lettuce { private final Cluster cluster = new Cluster(); + /** + * List of static master-replica "host:port" pairs regardless of role + * as the actual roles are determined by querying each node's ROLE command. + */ + private @Nullable List nodes; + + public @Nullable List getNodes() { + return this.nodes; + } + + public void setNodes(@Nullable List nodes) { + this.nodes = nodes; + } + public Duration getShutdownTimeout() { return this.shutdownTimeout; } diff --git a/module/spring-boot-data-redis/src/test/java/org/springframework/boot/data/redis/autoconfigure/RedisAutoConfigurationTests.java b/module/spring-boot-data-redis/src/test/java/org/springframework/boot/data/redis/autoconfigure/RedisAutoConfigurationTests.java index fc1b8dba6f55..e974ad2259db 100644 --- a/module/spring-boot-data-redis/src/test/java/org/springframework/boot/data/redis/autoconfigure/RedisAutoConfigurationTests.java +++ b/module/spring-boot-data-redis/src/test/java/org/springframework/boot/data/redis/autoconfigure/RedisAutoConfigurationTests.java @@ -91,6 +91,7 @@ * @author Moritz Halbritter * @author Andy Wilkinson * @author Phillip Webb + * @author Yong-Hyun Kim */ class RedisAutoConfigurationTests { @@ -501,6 +502,38 @@ void testRedisConfigurationWithClusterAndAuthentication() { ); } + @Test + void testRedisConfigurationWithStaticMasterReplica() { + List staticMasterReplicaNodes = Arrays.asList("127.0.0.1:28319", "127.0.0.1:28320", "[::1]:28321"); + this.contextRunner + .withPropertyValues( + "spring.data.redis.lettuce.static-master-replica.nodes[0]:" + staticMasterReplicaNodes.get(0), + "spring.data.redis.lettuce.static-master-replica.nodes[1]:" + staticMasterReplicaNodes.get(1), + "spring.data.redis.lettuce.static-master-replica.nodes[2]:" + staticMasterReplicaNodes.get(2)) + .run((context) -> { + LettuceConnectionFactory connectionFactory = context.getBean(LettuceConnectionFactory.class); + assertThat(connectionFactory.getSentinelConfiguration()).isNull(); + assertThat(connectionFactory.getClusterConfiguration()).isNull(); + assertThat(isStaticMasterReplicaAware(connectionFactory)).isTrue(); + }); + } + + @Test + void testRedisConfigurationWithStaticMasterReplicaAndAuthenticationAndDatabase() { + List staticMasterReplicaNodes = Arrays.asList("127.0.0.1:28319", "127.0.0.1:28320"); + this.contextRunner + .withPropertyValues("spring.data.redis.username=user", "spring.data.redis.password=password", + "spring.data.redis.database=1", + "spring.data.redis.lettuce.static-master-replica.nodes[0]:" + staticMasterReplicaNodes.get(0), + "spring.data.redis.lettuce.static-master-replica.nodes[1]:" + staticMasterReplicaNodes.get(1)) + .run((context) -> { + LettuceConnectionFactory connectionFactory = context.getBean(LettuceConnectionFactory.class); + assertThat(getUserName(connectionFactory)).isEqualTo("user"); + assertThat(connectionFactory.getPassword()).isEqualTo("password"); + assertThat(connectionFactory.getDatabase()).isOne(); + }); + } + @Test void testRedisConfigurationCreateClientOptionsByDefault() { this.contextRunner.run(assertClientOptions(ClientOptions.class, (options) -> { @@ -704,6 +737,10 @@ private RedisClusterNode createRedisNode(String host) { return node; } + private boolean isStaticMasterReplicaAware(LettuceConnectionFactory factory) { + return ReflectionTestUtils.invokeMethod(factory, "isStaticMasterReplicaAware"); + } + private static final class RedisNodes implements Nodes { private final List descriptions;