diff --git a/src/main/java/org/springframework/data/redis/connection/RedisElastiCacheConfiguration.java b/src/main/java/org/springframework/data/redis/connection/RedisElastiCacheConfiguration.java new file mode 100644 index 000000000..2af0b8a62 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/RedisElastiCacheConfiguration.java @@ -0,0 +1,149 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.connection; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.springframework.util.Assert; + +/** + * Configuration class used for setting up {@link RedisConnection} via {@link RedisConnectionFactory} using connecting + * to AWS ElastiCache with Read Replicas . + * + * @author Mark Paluch + * @since 2.1 + */ +public class RedisElastiCacheConfiguration { + + private static final int DEFAULT_PORT = 6379; + + private List nodes = new ArrayList<>(); + private int database; + private RedisPassword password = RedisPassword.none(); + + /** + * Create a new {@link RedisElastiCacheConfiguration} given {@code hostName}. + * + * @param hostName must not be {@literal null} or empty. + */ + public RedisElastiCacheConfiguration(String hostName) { + this(hostName, DEFAULT_PORT); + } + + /** + * Create a new {@link RedisElastiCacheConfiguration} given {@code hostName} and {@code port}. + * + * @param hostName must not be {@literal null} or empty. + * @param port a valid TCP port (1-65535). + */ + public RedisElastiCacheConfiguration(String hostName, int port) { + addNode(hostName, port); + } + + /** + * Add a {@link RedisStandaloneConfiguration node} to the list of nodes given {@code hostName}. + * + * @param hostName must not be {@literal null} or empty. + * @param port a valid TCP port (1-65535). + */ + public void addNode(String hostName, int port) { + addNode(new RedisStandaloneConfiguration(hostName, port)); + } + + /** + * Add a {@link RedisStandaloneConfiguration node} to the list of nodes. + * + * @param node must not be {@literal null}. + */ + private void addNode(RedisStandaloneConfiguration node) { + + Assert.notNull(node, "RedisStandaloneConfiguration must not be null!"); + + node.setPassword(password); + node.setDatabase(database); + nodes.add(node); + } + + /** + * Add a {@link RedisStandaloneConfiguration node} to the list of nodes given {@code hostName}. + * + * @param hostName must not be {@literal null} or empty. + * @return {@code this} {@link RedisElastiCacheConfiguration}. + */ + public RedisElastiCacheConfiguration node(String hostName) { + return node(hostName, DEFAULT_PORT); + } + + /** + * Add a {@link RedisStandaloneConfiguration node} to the list of nodes given {@code hostName} and {@code port}. + * + * @param hostName must not be {@literal null} or empty. + * @param port a valid TCP port (1-65535). + * @return {@code this} {@link RedisElastiCacheConfiguration}. + */ + public RedisElastiCacheConfiguration node(String hostName, int port) { + + addNode(hostName, port); + return this; + } + + /** + * @return the db index. + */ + public int getDatabase() { + return database; + } + + /** + * Sets the index of the database used by this connection factory. Default is 0. + * + * @param index database index. + */ + public void setDatabase(int index) { + + Assert.isTrue(index >= 0, () -> String.format("Invalid DB index '%s' (a positive index required)", index)); + + this.database = index; + this.nodes.forEach(it -> it.setDatabase(database)); + } + + /** + * @return never {@literal null}. + */ + public RedisPassword getPassword() { + return password; + } + + /** + * @param password must not be {@literal null}. + */ + public void setPassword(RedisPassword password) { + + Assert.notNull(password, "RedisPassword must not be null!"); + + this.password = password; + this.nodes.forEach(it -> it.setPassword(password)); + } + + /** + * @return list of {@link RedisStandaloneConfiguration nodes}. + */ + public List getNodes() { + return Collections.unmodifiableList(new ArrayList<>(nodes)); + } +} diff --git a/src/main/java/org/springframework/data/redis/connection/lettuce/ElastiCacheConnectionProvider.java b/src/main/java/org/springframework/data/redis/connection/lettuce/ElastiCacheConnectionProvider.java new file mode 100644 index 000000000..6167d130f --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/lettuce/ElastiCacheConnectionProvider.java @@ -0,0 +1,79 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.connection.lettuce; + +import io.lettuce.core.ReadFrom; +import io.lettuce.core.RedisClient; +import io.lettuce.core.RedisURI; +import io.lettuce.core.api.StatefulConnection; +import io.lettuce.core.codec.RedisCodec; +import io.lettuce.core.masterslave.MasterSlave; +import io.lettuce.core.masterslave.StatefulRedisMasterSlaveConnection; + +import java.util.Collection; +import java.util.Optional; + +import org.springframework.lang.Nullable; + +/** + * {@link LettuceConnectionProvider} implementation for a AWS ElastiCache with replicas setup.
+ * Lettuce auto-discovers node roles from the static {@link RedisURI} collection. + * + * @author Mark Paluch + * @since 2.1 + */ +class ElastiCacheConnectionProvider implements LettuceConnectionProvider { + + private final RedisClient client; + private final RedisCodec codec; + private final Optional readFrom; + private final Collection nodes; + + /** + * Create new {@link ElastiCacheConnectionProvider}. + * + * @param client must not be {@literal null}. + * @param codec must not be {@literal null}. + * @param nodes must not be {@literal null}. + * @param readFrom can be {@literal null}. + */ + ElastiCacheConnectionProvider(RedisClient client, RedisCodec codec, Collection nodes, + @Nullable ReadFrom readFrom) { + + this.client = client; + this.codec = codec; + this.readFrom = Optional.ofNullable(readFrom); + this.nodes = nodes; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.redis.connection.lettuce.LettuceConnectionProvider#getConnection(java.lang.Class) + */ + @Override + public > T getConnection(Class connectionType) { + + if (StatefulConnection.class.isAssignableFrom(connectionType)) { + + StatefulRedisMasterSlaveConnection connection = MasterSlave.connect(client, codec, nodes); + readFrom.ifPresent(connection::setReadFrom); + + return connectionType.cast(connection); + } + + throw new UnsupportedOperationException("Connection type " + connectionType + " not supported!"); + } +} diff --git a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceConnectionFactory.java b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceConnectionFactory.java index a2b2fca1a..147101931 100644 --- a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceConnectionFactory.java +++ b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceConnectionFactory.java @@ -36,6 +36,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; @@ -68,6 +69,7 @@ import org.springframework.util.ClassUtils; * {@link LettuceConnectionFactory client configuration}. Lettuce supports the following environmental configurations: *
    *
  • {@link RedisStandaloneConfiguration}
  • + *
  • {@link RedisElastiCacheConfiguration}
  • *
  • {@link RedisSocketConfiguration}
  • *
  • {@link RedisSentinelConfiguration}
  • *
  • {@link RedisClusterConfiguration}
  • @@ -101,6 +103,7 @@ public class LettuceConnectionFactory private final Object connectionMonitor = new Object(); private boolean convertPipelineAndTxResults = true; private RedisStandaloneConfiguration standaloneConfig = new RedisStandaloneConfiguration("localhost", 6379); + private @Nullable RedisElastiCacheConfiguration elastiCacheConfiguration; private @Nullable RedisSocketConfiguration socketConfiguration; private @Nullable RedisSentinelConfiguration sentinelConfiguration; private @Nullable RedisClusterConfiguration clusterConfiguration; @@ -199,6 +202,24 @@ public class LettuceConnectionFactory this.standaloneConfig = standaloneConfig; } + /** + * Constructs a new {@link LettuceConnectionFactory} instance using the given {@link RedisElastiCacheConfiguration} + * and {@link LettuceClientConfiguration}. + * + * @param elastiCacheConfiguration must not be {@literal null}. + * @param clientConfig must not be {@literal null}. + * @since 2.1 + */ + public LettuceConnectionFactory(RedisElastiCacheConfiguration elastiCacheConfiguration, + LettuceClientConfiguration clientConfig) { + + this(clientConfig); + + Assert.notNull(elastiCacheConfiguration, "RedisElastiCacheConfiguration must not be null!"); + + this.elastiCacheConfiguration = elastiCacheConfiguration; + } + /** * Constructs a new {@link LettuceConnectionFactory} instance using the given {@link RedisSocketConfiguration} and * {@link LettuceClientConfiguration}. @@ -625,6 +646,10 @@ public class LettuceConnectionFactory */ public int getDatabase() { + if (isElastiCacheAware()) { + return elastiCacheConfiguration.getDatabase(); + } + if (isDomainSocketAware()) { return socketConfiguration.getDatabase(); } @@ -645,6 +670,11 @@ public class LettuceConnectionFactory Assert.isTrue(index >= 0, "invalid DB index (a positive index required)"); + if (isElastiCacheAware()) { + elastiCacheConfiguration.setDatabase(index); + return; + } + if (isDomainSocketAware()) { socketConfiguration.setDatabase(index); return; @@ -694,6 +724,10 @@ public class LettuceConnectionFactory private RedisPassword getRedisPassword() { + if (isElastiCacheAware()) { + return elastiCacheConfiguration.getPassword(); + } + if (isDomainSocketAware()) { return socketConfiguration.getPassword(); } @@ -724,6 +758,11 @@ public class LettuceConnectionFactory return; } + if (isElastiCacheAware()) { + elastiCacheConfiguration.setPassword(RedisPassword.of(password)); + return; + } + if (isRedisSentinelAware()) { sentinelConfiguration.setPassword(RedisPassword.of(password)); return; @@ -849,6 +888,14 @@ public class LettuceConnectionFactory this.convertPipelineAndTxResults = convertPipelineAndTxResults; } + /** + * @return true when {@link RedisElastiCacheConfiguration} is present. + * @since 2.1 + */ + private boolean isElastiCacheAware() { + return elastiCacheConfiguration != null; + } + /** * @return true when {@link RedisSentinelConfiguration} is present. * @since 1.5 @@ -920,6 +967,16 @@ public class LettuceConnectionFactory ReadFrom readFrom = getClientConfiguration().getReadFrom().orElse(null); + if (isElastiCacheAware()) { + + List nodes = this.elastiCacheConfiguration.getNodes().stream() // + .map(it -> createRedisURIAndApplySettings(it.getHostName(), it.getPort())) // + .peek(it -> it.setDatabase(getDatabase())) // + .collect(Collectors.toList()); + + return new ElastiCacheConnectionProvider((RedisClient) client, codec, nodes, readFrom); + } + if (isClusterAware()) { return new ClusterConnectionProvider((RedisClusterClient) client, codec, readFrom); } @@ -929,6 +986,17 @@ public class LettuceConnectionFactory protected AbstractRedisClient createClient() { + if (isElastiCacheAware()) { + + RedisClient redisClient = clientConfiguration.getClientResources() // + .map(RedisClient::create) // + .orElseGet(RedisClient::create); + + clientConfiguration.getClientOptions().ifPresent(redisClient::setOptions); + + return redisClient; + } + if (isRedisSentinelAware()) { RedisURI redisURI = getSentinelRedisURI(); diff --git a/src/test/java/org/springframework/data/redis/connection/RedisElastiCacheConfigurationUnitTests.java b/src/test/java/org/springframework/data/redis/connection/RedisElastiCacheConfigurationUnitTests.java new file mode 100644 index 000000000..6601f99e9 --- /dev/null +++ b/src/test/java/org/springframework/data/redis/connection/RedisElastiCacheConfigurationUnitTests.java @@ -0,0 +1,83 @@ +/* + * Copyright 2018 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.connection; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.Test; + +/** + * Unit tests for {@link RedisElastiCacheConfiguration}. + * + * @author Mark Paluch + */ +public class RedisElastiCacheConfigurationUnitTests { + + @Test // DATAREDIS-762 + public void shouldCreateSingleHostConfiguration() { + + RedisElastiCacheConfiguration singleHost = new RedisElastiCacheConfiguration("localhost"); + + assertThat(singleHost.getNodes()).hasSize(1); + + RedisStandaloneConfiguration node = singleHost.getNodes().get(0); + + assertThat(node.getHostName()).isEqualToIgnoringCase("localhost"); + assertThat(node.getPort()).isEqualTo(6379); + } + + @Test // DATAREDIS-762 + public void shouldCreateMultiHostConfiguration() { + + RedisElastiCacheConfiguration multiHost = new RedisElastiCacheConfiguration("localhost"); + multiHost.node("other-host", 6479); + + assertThat(multiHost.getNodes()).hasSize(2); + + RedisStandaloneConfiguration firstNode = multiHost.getNodes().get(0); + + assertThat(firstNode.getHostName()).isEqualToIgnoringCase("localhost"); + assertThat(firstNode.getPort()).isEqualTo(6379); + + RedisStandaloneConfiguration secondNode = multiHost.getNodes().get(1); + + assertThat(secondNode.getHostName()).isEqualToIgnoringCase("other-host"); + assertThat(secondNode.getPort()).isEqualTo(6479); + } + + @Test // DATAREDIS-762 + public void shouldApplyPasswordToNodes() { + + RedisElastiCacheConfiguration multiHost = new RedisElastiCacheConfiguration("localhost").node("other-host", 6479); + + multiHost.setPassword(RedisPassword.of("foobar")); + multiHost.node("third", 1234); + + assertThat(multiHost.getNodes()).extracting("password").containsExactly(RedisPassword.of("foobar"), + RedisPassword.of("foobar"), RedisPassword.of("foobar")); + } + + @Test // DATAREDIS-762 + public void shouldApplyDatabaseToNodes() { + + RedisElastiCacheConfiguration multiHost = new RedisElastiCacheConfiguration("localhost").node("other-host", 6479); + + multiHost.setDatabase(4); + multiHost.node("third", 1234); + + assertThat(multiHost.getNodes()).extracting("database").containsExactly(4, 4, 4); + } +} diff --git a/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceConnectionFactoryTests.java b/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceConnectionFactoryTests.java index 665d25a3f..8ad948059 100644 --- a/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceConnectionFactoryTests.java +++ b/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceConnectionFactoryTests.java @@ -15,9 +15,7 @@ */ package org.springframework.data.redis.connection.lettuce; -import static org.hamcrest.core.Is.*; -import static org.hamcrest.core.IsEqual.*; -import static org.hamcrest.core.IsNull.*; +import static org.hamcrest.Matchers.*; import static org.junit.Assert.*; import static org.junit.Assume.*; @@ -43,6 +41,7 @@ import org.springframework.data.redis.RedisSystemException; import org.springframework.data.redis.SettingsUtils; import org.springframework.data.redis.connection.DefaultStringRedisConnection; import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisElastiCacheConfiguration; import org.springframework.data.redis.connection.RedisStandaloneConfiguration; import org.springframework.data.redis.connection.StringRedisConnection; @@ -386,6 +385,65 @@ public class LettuceConnectionFactoryTests { factory.destroy(); } + @Test // DATAREDIS-762 + public void factoryUsesElastiCacheMasterSlaveConnections() { + + assumeThat(String.format("No slaves connected to %s:%s.", SettingsUtils.getHost(), SettingsUtils.getPort()), + connection.info("replication").getProperty("connected_slaves", "0").compareTo("0") > 0, is(true)); + + LettuceClientConfiguration configuration = LettuceTestClientConfiguration.builder().readFrom(ReadFrom.SLAVE) + .build(); + + RedisElastiCacheConfiguration elastiCache = new RedisElastiCacheConfiguration(SettingsUtils.getHost()) + .node(SettingsUtils.getHost(), SettingsUtils.getPort() + 1); + + LettuceConnectionFactory factory = new LettuceConnectionFactory(elastiCache, + configuration); + factory.afterPropertiesSet(); + + RedisConnection connection = factory.getConnection(); + + try { + assertThat(connection.ping(), is(equalTo("PONG"))); + assertThat(connection.info().getProperty("role"), is(equalTo("slave"))); + } finally { + connection.close(); + } + + factory.destroy(); + } + + @Test // DATAREDIS-762 + public void factoryUsesElastiCacheMasterWithoutMaster() { + + assumeThat(String.format("No slaves connected to %s:%s.", SettingsUtils.getHost(), SettingsUtils.getPort()), + connection.info("replication").getProperty("connected_slaves", "0").compareTo("0") > 0, is(true)); + + LettuceClientConfiguration configuration = LettuceTestClientConfiguration.builder().readFrom(ReadFrom.MASTER) + .build(); + + RedisElastiCacheConfiguration elastiCache = new RedisElastiCacheConfiguration(SettingsUtils.getHost(), + SettingsUtils.getPort() + 1); + + LettuceConnectionFactory factory = new LettuceConnectionFactory(elastiCache, configuration); + factory.afterPropertiesSet(); + + RedisConnection connection = factory.getConnection(); + + try { + connection.ping(); + fail("Expected RedisException: Master is currently unknown"); + } catch (RedisSystemException e) { + + assertThat(e.getCause(), is(instanceOf(RedisException.class))); + assertThat(e.getCause().getMessage(), containsString("Master is currently unknown")); + } finally { + connection.close(); + } + + factory.destroy(); + } + @Test // DATAREDIS-580 public void factoryUsesMasterSlaveConnections() {