diff --git a/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/DataRedisConnectionConfiguration.java b/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/DataRedisConnectionConfiguration.java index 9aba8363d9c..c6513fe6b5f 100644 --- a/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/DataRedisConnectionConfiguration.java +++ b/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/DataRedisConnectionConfiguration.java @@ -35,6 +35,7 @@ import org.springframework.data.redis.connection.RedisSentinelConfiguration; 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 @@ import org.springframework.util.ClassUtils; * @author Andy Wilkinson * @author Phillip Webb * @author Yanming Zhou + * @author Yong-Hyun Kim */ abstract class DataRedisConnectionConfiguration { @@ -191,12 +193,15 @@ abstract class DataRedisConnectionConfiguration { 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/DataRedisProperties.java b/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/DataRedisProperties.java index 4c3761bf229..76ef7aa51ae 100644 --- a/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/DataRedisProperties.java +++ b/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/DataRedisProperties.java @@ -34,6 +34,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties; * @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 class DataRedisProperties { 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/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 956e5434188..22d948154d3 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 @@ -104,6 +104,7 @@ class JedisConnectionConfiguration extends DataRedisConnectionConfiguration { 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 cba87781dfa..88dbfcbf8c1 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.ConditionalOnClass; 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.DataRedisProperties.Lettuce.Cluster.Refresh; import org.springframework.boot.data.redis.autoconfigure.DataRedisProperties.Pool; import org.springframework.boot.ssl.SslBundle; @@ -49,11 +53,13 @@ import org.springframework.data.redis.connection.RedisClusterConfiguration; 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 @@ import org.springframework.util.StringUtils; * @author Moritz Halbritter * @author Phillip Webb * @author Scott Frederick + * @author Yong-Hyun Kim */ @Configuration(proxyBeanMethods = false) @ConditionalOnClass(RedisClient.class) @@ -120,6 +127,12 @@ class LettuceConnectionConfiguration extends DataRedisConnectionConfiguration { 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 @@ class LettuceConnectionConfiguration extends DataRedisConnectionConfiguration { 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 @@ class LettuceConnectionConfiguration extends DataRedisConnectionConfiguration { } } + 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/test/java/org/springframework/boot/data/redis/autoconfigure/DataRedisAutoConfigurationTests.java b/module/spring-boot-data-redis/src/test/java/org/springframework/boot/data/redis/autoconfigure/DataRedisAutoConfigurationTests.java index d7556a4814e..49641174cff 100644 --- a/module/spring-boot-data-redis/src/test/java/org/springframework/boot/data/redis/autoconfigure/DataRedisAutoConfigurationTests.java +++ b/module/spring-boot-data-redis/src/test/java/org/springframework/boot/data/redis/autoconfigure/DataRedisAutoConfigurationTests.java @@ -91,6 +91,7 @@ import static org.mockito.Mockito.mock; * @author Moritz Halbritter * @author Andy Wilkinson * @author Phillip Webb + * @author Yong-Hyun Kim */ class DataRedisAutoConfigurationTests { @@ -501,6 +502,38 @@ class DataRedisAutoConfigurationTests { ); } + @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) -> { @@ -705,6 +738,10 @@ class DataRedisAutoConfigurationTests { return node; } + private boolean isStaticMasterReplicaAware(LettuceConnectionFactory factory) { + return ReflectionTestUtils.invokeMethod(factory, "isStaticMasterReplicaAware"); + } + private static final class RedisNodes implements Nodes { private final List descriptions;