From 495d16b1f84bf19c7373d1284aadcee40f3269e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Nicoll?= Date: Wed, 8 Apr 2026 14:51:06 +0200 Subject: [PATCH] Add auto-configuration for Redis annotation-driven listeners This commit adds support for `@RedisListener` by providing the infrastructure to register those endpoints using a default `RedisMessageListenerContainer`. Users wishing to configure additional containers can benefit from `RedisMessageListenerContainerConfigurer`. The default container can be tuned using new properties in the `spring.data.redis.listener` namespace. Closes gh-49858 --- .../modules/reference/pages/data/nosql.adoc | 24 ++++ .../data/nosql/redis/receiving/MyBean.java | 30 +++++ .../nosql/redis/receiving/custom/MyBean.java | 30 +++++ .../custom/MyRedisConfiguration.java | 37 ++++++ .../docs/data/nosql/redis/receiving/MyBean.kt | 30 +++++ .../nosql/redis/receiving/custom/MyBean.kt | 30 +++++ .../receiving/custom/MyRedisConfiguration.kt | 39 ++++++ module/spring-boot-data-redis/build.gradle | 1 + ...onDrivenConfigurationIntegrationTests.java | 73 +++++++++++ ...ataRedisAnnotationDrivenConfiguration.java | 70 ++++++++++ .../DataRedisAutoConfiguration.java | 3 +- .../autoconfigure/DataRedisProperties.java | 123 ++++++++++++++++++ ...disMessageListenerContainerConfigurer.java | 76 +++++++++++ ...disAnnotationDrivenConfigurationTests.java | 122 +++++++++++++++++ .../build.gradle | 1 + 15 files changed, 688 insertions(+), 1 deletion(-) create mode 100644 documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/data/nosql/redis/receiving/MyBean.java create mode 100644 documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/data/nosql/redis/receiving/custom/MyBean.java create mode 100644 documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/data/nosql/redis/receiving/custom/MyRedisConfiguration.java create mode 100644 documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/data/nosql/redis/receiving/MyBean.kt create mode 100644 documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/data/nosql/redis/receiving/custom/MyBean.kt create mode 100644 documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/data/nosql/redis/receiving/custom/MyRedisConfiguration.kt create mode 100644 module/spring-boot-data-redis/src/dockerTest/java/org/springframework/boot/data/redis/autoconfigure/DataRedisAnnotationDrivenConfigurationIntegrationTests.java create mode 100644 module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/DataRedisAnnotationDrivenConfiguration.java create mode 100644 module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/RedisMessageListenerContainerConfigurer.java create mode 100644 module/spring-boot-data-redis/src/test/java/org/springframework/boot/data/redis/autoconfigure/DataRedisAnnotationDrivenConfigurationTests.java diff --git a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/data/nosql.adoc b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/data/nosql.adoc index 8bb18267aef..1761565c521 100644 --- a/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/data/nosql.adoc +++ b/documentation/spring-boot-docs/src/docs/antora/modules/reference/pages/data/nosql.adoc @@ -108,6 +108,30 @@ spring: +[[data.nosql.redis.receiving]] +=== Receiving a Message + +When the Redis infrastructure is present, any bean can be annotated with javadoc:org.springframework.data.redis.annotation.RedisListener[format=annotation] to create a listener endpoint. +If no javadoc:org.springframework.data.redis.listener.RedisMessageListenerContainer[] has been defined, a default one is configured automatically. + +The following component creates a listener endpoint on the `someChannel` channel: + +include-code::MyBean[] + +TIP: See the javadoc:org.springframework.data.redis.annotation.EnableRedisListeners[format=annotation] API documentation for more details. + +If you need to create more javadoc:org.springframework.data.redis.listener.RedisMessageListenerContainer[] instances or if you want to override the default, Spring Boot provides a javadoc:org.springframework.boot.data.redis.autoconfigure.RedisMessageListenerContainerConfigurer[] that you can use to initialize a javadoc:org.springframework.data.redis.listener.RedisMessageListenerContainer[] with the same settings as the one that is auto-configured. + +For instance, the following example exposes another container that uses a specific javadoc:org.springframework.data.redis.connection.RedisConnectionFactory[]: + +include-code::custom/MyRedisConfiguration[] + +Then you can use the container in any javadoc:org.springframework.data.redis.annotation.RedisListener[format=annotation]-annotated method as follows: + +include-code::custom/MyBean[] + + + [[data.nosql.mongodb]] == MongoDB diff --git a/documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/data/nosql/redis/receiving/MyBean.java b/documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/data/nosql/redis/receiving/MyBean.java new file mode 100644 index 00000000000..c8010cd955a --- /dev/null +++ b/documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/data/nosql/redis/receiving/MyBean.java @@ -0,0 +1,30 @@ +/* + * Copyright 2012-present 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 + * + * https://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.boot.docs.data.nosql.redis.receiving; + +import org.springframework.data.redis.annotation.RedisListener; +import org.springframework.stereotype.Component; + +@Component +public class MyBean { + + @RedisListener("someChannel") + public void processMessage(String content) { + // ... + } + +} diff --git a/documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/data/nosql/redis/receiving/custom/MyBean.java b/documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/data/nosql/redis/receiving/custom/MyBean.java new file mode 100644 index 00000000000..542b4ce37fd --- /dev/null +++ b/documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/data/nosql/redis/receiving/custom/MyBean.java @@ -0,0 +1,30 @@ +/* + * Copyright 2012-present 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 + * + * https://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.boot.docs.data.nosql.redis.receiving.custom; + +import org.springframework.data.redis.annotation.RedisListener; +import org.springframework.stereotype.Component; + +@Component +public class MyBean { + + @RedisListener(topic = "someChannel", container = "myRedisMessageListenerContainer") + public void processMessage(String content) { + // ... + } + +} diff --git a/documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/data/nosql/redis/receiving/custom/MyRedisConfiguration.java b/documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/data/nosql/redis/receiving/custom/MyRedisConfiguration.java new file mode 100644 index 00000000000..13d975ad559 --- /dev/null +++ b/documentation/spring-boot-docs/src/main/java/org/springframework/boot/docs/data/nosql/redis/receiving/custom/MyRedisConfiguration.java @@ -0,0 +1,37 @@ +/* + * Copyright 2012-present 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 + * + * https://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.boot.docs.data.nosql.redis.receiving.custom; + +import org.springframework.boot.data.redis.autoconfigure.RedisMessageListenerContainerConfigurer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; + +@Configuration(proxyBeanMethods = false) +public class MyRedisConfiguration { + + @Bean + public RedisMessageListenerContainer myRedisMessageListenerContainer( + RedisMessageListenerContainerConfigurer configurer, RedisConnectionFactory connectionFactory) { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + configurer.configure(container, connectionFactory); + // ... custom configuration + return container; + } + +} diff --git a/documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/data/nosql/redis/receiving/MyBean.kt b/documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/data/nosql/redis/receiving/MyBean.kt new file mode 100644 index 00000000000..fc834fba26c --- /dev/null +++ b/documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/data/nosql/redis/receiving/MyBean.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2012-present 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 + * + * https://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.boot.docs.data.nosql.redis.receiving + +import org.springframework.data.redis.annotation.RedisListener +import org.springframework.stereotype.Component + +@Component +class MyBean { + + @RedisListener("someChannel") + fun processMessage(content: String) { + // ... + } + +} diff --git a/documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/data/nosql/redis/receiving/custom/MyBean.kt b/documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/data/nosql/redis/receiving/custom/MyBean.kt new file mode 100644 index 00000000000..7e40eaad8ef --- /dev/null +++ b/documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/data/nosql/redis/receiving/custom/MyBean.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2012-present 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 + * + * https://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.boot.docs.data.nosql.redis.receiving.custom + +import org.springframework.data.redis.annotation.RedisListener +import org.springframework.stereotype.Component + +@Component +class MyBean { + + @RedisListener(topic = "someChannel", container = "myRedisMessageListenerContainer") + fun processMessage(content: String) { + // ... + } + +} diff --git a/documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/data/nosql/redis/receiving/custom/MyRedisConfiguration.kt b/documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/data/nosql/redis/receiving/custom/MyRedisConfiguration.kt new file mode 100644 index 00000000000..e53648f3b2e --- /dev/null +++ b/documentation/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/data/nosql/redis/receiving/custom/MyRedisConfiguration.kt @@ -0,0 +1,39 @@ +/* + * Copyright 2012-present 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 + * + * https://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.boot.docs.data.nosql.redis.receiving.custom + +import org.springframework.boot.data.redis.autoconfigure.RedisMessageListenerContainerConfigurer +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.data.redis.connection.RedisConnectionFactory +import org.springframework.data.redis.listener.RedisMessageListenerContainer + +@Configuration(proxyBeanMethods = false) +class MyRedisConfiguration { + + @Bean + fun myRedisMessageListenerContainer( + configurer: RedisMessageListenerContainerConfigurer, + connectionFactory: RedisConnectionFactory + ): RedisMessageListenerContainer { + val container = RedisMessageListenerContainer() + configurer.configure(container, connectionFactory) + // ... custom configuration + return container + } + +} diff --git a/module/spring-boot-data-redis/build.gradle b/module/spring-boot-data-redis/build.gradle index 4f9843b7c54..95345e6009f 100644 --- a/module/spring-boot-data-redis/build.gradle +++ b/module/spring-boot-data-redis/build.gradle @@ -42,6 +42,7 @@ dependencies { optional(project(":module:spring-boot-health")) optional(project(":module:spring-boot-micrometer-metrics")) optional("com.redis:testcontainers-redis") + optional("org.springframework:spring-messaging") optional("redis.clients:jedis") dockerTestImplementation(project(":core:spring-boot-test")) diff --git a/module/spring-boot-data-redis/src/dockerTest/java/org/springframework/boot/data/redis/autoconfigure/DataRedisAnnotationDrivenConfigurationIntegrationTests.java b/module/spring-boot-data-redis/src/dockerTest/java/org/springframework/boot/data/redis/autoconfigure/DataRedisAnnotationDrivenConfigurationIntegrationTests.java new file mode 100644 index 00000000000..fd5ca978bc5 --- /dev/null +++ b/module/spring-boot-data-redis/src/dockerTest/java/org/springframework/boot/data/redis/autoconfigure/DataRedisAnnotationDrivenConfigurationIntegrationTests.java @@ -0,0 +1,73 @@ +/* + * Copyright 2012-present 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 + * + * https://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.boot.data.redis.autoconfigure; + +import java.util.ArrayList; +import java.util.List; + +import com.redis.testcontainers.RedisContainer; +import org.junit.jupiter.api.Test; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.testsupport.container.TestImage; +import org.springframework.data.redis.annotation.RedisListener; +import org.springframework.data.redis.core.StringRedisTemplate; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +/** + * Integration tests for {@link DataRedisAnnotationDrivenConfiguration}. + * + * @author Stephane Nicoll + */ +@Testcontainers(disabledWithoutDocker = true) +class DataRedisAnnotationDrivenConfigurationIntegrationTests { + + @Container + static final RedisContainer redis = TestImage.container(RedisContainer.class); + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(DataRedisAutoConfiguration.class)) + .withPropertyValues("spring.data.redis.host=" + redis.getHost(), + "spring.data.redis.port=" + redis.getFirstMappedPort()); + + @Test + void annotatedListenerShouldReceiveMessages() { + this.contextRunner.withUserConfiguration(TestListener.class).run((context) -> { + StringRedisTemplate redisTemplate = context.getBean(StringRedisTemplate.class); + TestListener testListener = context.getBean(TestListener.class); + redisTemplate.convertAndSend("test-channel", "hello-world"); + await().untilAsserted(() -> assertThat(testListener.messages).contains("hello-world")); + }); + } + + static class TestListener { + + private final List messages = new ArrayList<>(); + + @RedisListener("test-channel") + void process(String message) { + this.messages.add(message); + } + + } + +} diff --git a/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/DataRedisAnnotationDrivenConfiguration.java b/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/DataRedisAnnotationDrivenConfiguration.java new file mode 100644 index 00000000000..bfff28513ab --- /dev/null +++ b/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/DataRedisAnnotationDrivenConfiguration.java @@ -0,0 +1,70 @@ +/* + * Copyright 2012-present 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 + * + * https://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.boot.data.redis.autoconfigure; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.annotation.EnableRedisListeners; +import org.springframework.data.redis.config.RedisListenerConfigUtils; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.messaging.Message; + +/** + * Configuration for Redis annotation-driven listeners. + * + * @author Stephane Nicoll + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnClass({ EnableRedisListeners.class, Message.class }) +class DataRedisAnnotationDrivenConfiguration { + + private static final String DEFAULT_MESSAGE_LISTENER_BEAN_NAME = RedisListenerConfigUtils.REDIS_MESSAGE_LISTENER_BEAN_NAME; + + private final DataRedisProperties properties; + + DataRedisAnnotationDrivenConfiguration(DataRedisProperties properties) { + this.properties = properties; + } + + @Bean + @ConditionalOnMissingBean + RedisMessageListenerContainerConfigurer redisMessageListenerContainerConfigurer() { + return new RedisMessageListenerContainerConfigurer(this.properties); + } + + @Bean(name = DEFAULT_MESSAGE_LISTENER_BEAN_NAME) + @ConditionalOnSingleCandidate(RedisConnectionFactory.class) + @ConditionalOnMissingBean(name = DEFAULT_MESSAGE_LISTENER_BEAN_NAME) + RedisMessageListenerContainer redisMessageListenerContainer(RedisMessageListenerContainerConfigurer configurer, + RedisConnectionFactory redisConnectionFactory) { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + configurer.configure(container, redisConnectionFactory); + return container; + } + + @Configuration(proxyBeanMethods = false) + @EnableRedisListeners + @ConditionalOnMissingBean(name = RedisListenerConfigUtils.REDIS_LISTENER_ANNOTATION_PROCESSOR_BEAN_NAME) + static class EnableRedisListenersConfiguration { + + } + +} diff --git a/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/DataRedisAutoConfiguration.java b/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/DataRedisAutoConfiguration.java index c0f1d007367..8a88b9a2708 100644 --- a/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/DataRedisAutoConfiguration.java +++ b/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/DataRedisAutoConfiguration.java @@ -48,7 +48,8 @@ import org.springframework.data.redis.core.StringRedisTemplate; @AutoConfiguration @ConditionalOnClass(RedisOperations.class) @EnableConfigurationProperties(DataRedisProperties.class) -@Import({ LettuceConnectionConfiguration.class, JedisConnectionConfiguration.class }) +@Import({ LettuceConnectionConfiguration.class, JedisConnectionConfiguration.class, + DataRedisAnnotationDrivenConfiguration.class }) public final class DataRedisAutoConfiguration { @Bean 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 88045e38536..b830197093b 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 @@ -102,6 +102,8 @@ public class DataRedisProperties { private final Lettuce lettuce = new Lettuce(); + private final Listener listener = new Listener(); + public int getDatabase() { return this.database; } @@ -218,6 +220,10 @@ public class DataRedisProperties { return this.lettuce; } + public Listener getListener() { + return this.listener; + } + /** * Type of Redis client to use. */ @@ -580,4 +586,121 @@ public class DataRedisProperties { } + /** + * Listener properties. + */ + public static class Listener { + + /** + * Whether to start the container automatically on startup. + */ + private boolean autoStartup = true; + + /** + * Maximum amount of time to wait for the subscription to become active. + */ + private Duration subscriptionRegistrationTimeout = Duration.ofSeconds(2); + + private final Recovery recovery = new Recovery(); + + public boolean isAutoStartup() { + return this.autoStartup; + } + + public void setAutoStartup(boolean autoStartup) { + this.autoStartup = autoStartup; + } + + public Duration getSubscriptionRegistrationTimeout() { + return this.subscriptionRegistrationTimeout; + } + + public void setSubscriptionRegistrationTimeout(Duration subscriptionRegistrationTimeout) { + this.subscriptionRegistrationTimeout = subscriptionRegistrationTimeout; + } + + public Recovery getRecovery() { + return this.recovery; + } + + } + + /** + * Recovery properties. + */ + public static class Recovery { + + /** + * Maximum number of recovery attempts. + */ + private long maxRetries = Long.MAX_VALUE; + + /** + * Base delay for a recovery attempt. Can be combined with a "multiplier" to use + * an exponential back off strategy. + */ + private Duration delay = Duration.ofSeconds(5); + + /** + * Multiplier for a delay for the next retry attempt, applied to the previous + * delay, starting with the initial delay as well as to the applicable jitter for + * each attempt. Fixed delay by default. + */ + private double multiplier = 1.0; + + /** + * Maximum delay for any retry attempt, limiting how far jitter and the multiplier + * can increase the delay. + */ + private Duration maxDelay = Duration.ofSeconds(30); + + /** + * Jitter value for the base retry attempt, randomly subtracted or added to the + * calculated delay, resulting in a value between 'delay - jitter' and 'delay + + * jitter' but never below the base delay or above the max delay. + */ + private Duration jitter = Duration.ZERO; + + public long getMaxRetries() { + return this.maxRetries; + } + + public void setMaxRetries(long maxRetries) { + this.maxRetries = maxRetries; + } + + public Duration getDelay() { + return this.delay; + } + + public void setDelay(Duration delay) { + this.delay = delay; + } + + public double getMultiplier() { + return this.multiplier; + } + + public void setMultiplier(double multiplier) { + this.multiplier = multiplier; + } + + public Duration getMaxDelay() { + return this.maxDelay; + } + + public void setMaxDelay(Duration maxDelay) { + this.maxDelay = maxDelay; + } + + public Duration getJitter() { + return this.jitter; + } + + public void setJitter(Duration jitter) { + this.jitter = jitter; + } + + } + } diff --git a/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/RedisMessageListenerContainerConfigurer.java b/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/RedisMessageListenerContainerConfigurer.java new file mode 100644 index 00000000000..9983b052993 --- /dev/null +++ b/module/spring-boot-data-redis/src/main/java/org/springframework/boot/data/redis/autoconfigure/RedisMessageListenerContainerConfigurer.java @@ -0,0 +1,76 @@ +/* + * Copyright 2012-present 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 + * + * https://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.boot.data.redis.autoconfigure; + +import java.time.Duration; +import java.util.function.Predicate; + +import org.springframework.boot.context.properties.PropertyMapper; +import org.springframework.boot.data.redis.autoconfigure.DataRedisProperties.Listener; +import org.springframework.boot.data.redis.autoconfigure.DataRedisProperties.Recovery; +import org.springframework.core.retry.RetryPolicy; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.util.backoff.BackOff; + +/** + * Configure {@link RedisMessageListenerContainer} with sensible defaults tuned using + * configuration properties. + *

+ * Can be injected into application code and used to define a custom + * {@code RedisMessageListenerContainer} whose configuration is based upon that produced + * by auto-configuration. + * + * @author Stephane Nicoll + * @since 4.1.0 + */ +public class RedisMessageListenerContainerConfigurer { + + private final DataRedisProperties properties; + + public RedisMessageListenerContainerConfigurer(DataRedisProperties properties) { + this.properties = properties; + } + + /** + * Configure the specified Redis message listener container. The container can be + * further tuned and default settings can be overridden. + * @param container the {@link RedisMessageListenerContainer} instance to configure + * @param connectionFactory the {@link RedisConnectionFactory} to use + */ + public void configure(RedisMessageListenerContainer container, RedisConnectionFactory connectionFactory) { + container.setConnectionFactory(connectionFactory); + PropertyMapper map = PropertyMapper.get(); + Listener listenerProperties = this.properties.getListener(); + map.from(listenerProperties::isAutoStartup).to(container::setAutoStartup); + map.from(listenerProperties::getSubscriptionRegistrationTimeout) + .as(Duration::toMillis) + .to(container::setMaxSubscriptionRegistrationWaitingTime); + map.from(getRecoveryBackOff(listenerProperties.getRecovery())).to(container::setRecoveryBackoff); + } + + static BackOff getRecoveryBackOff(Recovery recovery) { + PropertyMapper map = PropertyMapper.get(); + RetryPolicy.Builder builder = RetryPolicy.builder().maxRetries(recovery.getMaxRetries()); + map.from(recovery.getDelay()).to(builder::delay); + map.from(recovery.getMaxDelay()).when(Predicate.not(Duration::isZero)).to(builder::maxDelay); + map.from(recovery.getMultiplier()).to(builder::multiplier); + map.from(recovery.getJitter()).when((Predicate.not(Duration::isZero))).to(builder::jitter); + return builder.build().getBackOff(); + } + +} diff --git a/module/spring-boot-data-redis/src/test/java/org/springframework/boot/data/redis/autoconfigure/DataRedisAnnotationDrivenConfigurationTests.java b/module/spring-boot-data-redis/src/test/java/org/springframework/boot/data/redis/autoconfigure/DataRedisAnnotationDrivenConfigurationTests.java new file mode 100644 index 00000000000..91fbb074855 --- /dev/null +++ b/module/spring-boot-data-redis/src/test/java/org/springframework/boot/data/redis/autoconfigure/DataRedisAnnotationDrivenConfigurationTests.java @@ -0,0 +1,122 @@ +/* + * Copyright 2012-present 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 + * + * https://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.boot.data.redis.autoconfigure; + +import java.time.Duration; + +import org.assertj.core.api.InstanceOfAssertFactories; +import org.junit.jupiter.api.Test; + +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.data.redis.autoconfigure.DataRedisProperties.Listener; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.config.RedisListenerConfigUtils; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.util.backoff.ExponentialBackOff; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link DataRedisAnnotationDrivenConfiguration}. + * + * @author Stephane Nicoll + */ +class DataRedisAnnotationDrivenConfigurationTests { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(DataRedisAnnotationDrivenConfiguration.class)) + .withUserConfiguration(TestConfiguration.class); + + @Test + void registersContainerAndAnnotationProcessor() { + this.contextRunner.run((context) -> { + assertThat(context).hasSingleBean(RedisMessageListenerContainer.class); + assertThat(context).hasBean(RedisListenerConfigUtils.REDIS_LISTENER_ANNOTATION_PROCESSOR_BEAN_NAME); + }); + } + + @Test + void backsOffWhenContainerWithDefaultNameIsDefined() { + RedisMessageListenerContainer container = mock(RedisMessageListenerContainer.class); + this.contextRunner + .withBean("redisMessageListenerContainer", RedisMessageListenerContainer.class, () -> container) + .run((context) -> assertThat(context).hasSingleBean(RedisMessageListenerContainer.class) + .getBean(RedisMessageListenerContainer.class) + .isSameAs(container)); + } + + @Test + void registerContainerWhenContainerWithCustomNameIsDefined() { + RedisMessageListenerContainer container = mock(RedisMessageListenerContainer.class); + this.contextRunner + .withBean("customRedisMessageListenerContainer", RedisMessageListenerContainer.class, () -> container) + .run((context) -> assertThat(context).getBeans(RedisMessageListenerContainer.class) + .hasSize(2) + .containsKey("customRedisMessageListenerContainer")); + } + + @Test + void containerConfigurationMatchesDefaults() { + this.contextRunner.run((context) -> { + RedisMessageListenerContainer container = context.getBean(RedisMessageListenerContainer.class); + Listener listener = new DataRedisProperties().getListener(); + assertThat(container.isAutoStartup()).isEqualTo(listener.isAutoStartup()); + assertThat(container.getMaxSubscriptionRegistrationWaitingTime()) + .isEqualTo(listener.getSubscriptionRegistrationTimeout().toMillis()); + }); + } + + @Test + void containerCanBeConfigured() { + this.contextRunner.withPropertyValues("spring.data.redis.listener.auto-startup=false", + "spring.data.redis.listener.max-subscription-registration-waiting-time=2s", + "spring.data.redis.listener.recovery.max-retries=6", "spring.data.redis.listener.recovery.delay=4s", + "spring.data.redis.listener.recovery.multiplier=1.5", + "spring.data.redis.listener.recovery.max-delay=2m", "spring.data.redis.listener.recovery.jitter=500ms") + .run((context) -> { + RedisMessageListenerContainer container = context.getBean(RedisMessageListenerContainer.class); + assertThat(container.isAutoStartup()).isFalse(); + assertThat(container.getMaxSubscriptionRegistrationWaitingTime()).isEqualTo(2000); + assertThat(container).extracting("backOff") + .asInstanceOf(InstanceOfAssertFactories.type(ExponentialBackOff.class)) + .satisfies((backOff) -> { + assertThat(backOff.getMaxAttempts()).isEqualTo(6); + assertThat(backOff.getInitialInterval()).isEqualTo(4000); + assertThat(backOff.getMultiplier()).isEqualTo(1.5); + assertThat(backOff.getMaxInterval()).isEqualTo(Duration.ofMinutes(2).toMillis()); + assertThat(backOff.getJitter()).isEqualTo(500); + }); + }); + } + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(DataRedisProperties.class) + static class TestConfiguration { + + @Bean + RedisConnectionFactory redisConnectionFactory() { + return mock(RedisConnectionFactory.class); + } + + } + +} diff --git a/starter/spring-boot-starter-data-redis/build.gradle b/starter/spring-boot-starter-data-redis/build.gradle index 06f6436c4a8..cddc089aea2 100644 --- a/starter/spring-boot-starter-data-redis/build.gradle +++ b/starter/spring-boot-starter-data-redis/build.gradle @@ -24,4 +24,5 @@ dependencies { api(project(":starter:spring-boot-starter")) api(project(":module:spring-boot-data-redis")) + api("org.springframework:spring-messaging") }