diff --git a/spring-session/src/main/java/org/springframework/session/MapSessionRepository.java b/spring-session/src/main/java/org/springframework/session/MapSessionRepository.java index 6c3d982..19bde00 100644 --- a/spring-session/src/main/java/org/springframework/session/MapSessionRepository.java +++ b/spring-session/src/main/java/org/springframework/session/MapSessionRepository.java @@ -15,6 +15,7 @@ */ package org.springframework.session; +import org.springframework.session.events.SessionDestroyedEvent; import org.springframework.util.Assert; import java.util.Map; @@ -25,6 +26,10 @@ import java.util.concurrent.ConcurrentHashMap; * {@link java.util.concurrent.ConcurrentHashMap} is used, but a custom {@link java.util.Map} can be injected to use * distributed maps provided by NoSQL stores like Redis and Hazelcast. * + *

+ * The implementation does NOT support firing {@link SessionDestroyedEvent}. + *

+ * * @author Rob Winch * @since 1.0 */ diff --git a/spring-session/src/main/java/org/springframework/session/data/redis/RedisOperationsSessionRepository.java b/spring-session/src/main/java/org/springframework/session/data/redis/RedisOperationsSessionRepository.java index 33b20c0..902975b 100644 --- a/spring-session/src/main/java/org/springframework/session/data/redis/RedisOperationsSessionRepository.java +++ b/spring-session/src/main/java/org/springframework/session/data/redis/RedisOperationsSessionRepository.java @@ -30,6 +30,7 @@ import org.springframework.session.ExpiringSession; import org.springframework.session.MapSession; import org.springframework.session.Session; import org.springframework.session.SessionRepository; +import org.springframework.session.events.SessionDestroyedEvent; import org.springframework.session.web.http.SessionRepositoryFilter; import org.springframework.util.Assert; @@ -39,7 +40,8 @@ import org.springframework.util.Assert; * using Spring Data's * {@link org.springframework.data.redis.core.RedisOperations}. In a web * environment, this is typically used in combination with - * {@link SessionRepositoryFilter}. + * {@link SessionRepositoryFilter}. This implementation supports + * {@link SessionDestroyedEvent} through {@link SessionMessageListener}. *

* *

Creating a new instance

diff --git a/spring-session/src/main/java/org/springframework/session/data/redis/SessionMessageListener.java b/spring-session/src/main/java/org/springframework/session/data/redis/SessionMessageListener.java new file mode 100644 index 0000000..05877ff --- /dev/null +++ b/spring-session/src/main/java/org/springframework/session/data/redis/SessionMessageListener.java @@ -0,0 +1,76 @@ +/* + * Copyright 2002-2013 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.session.data.redis; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.connection.MessageListener; +import org.springframework.session.events.SessionDestroyedEvent; +import org.springframework.util.Assert; + +/** + * Listen for Redis {@link Message} notifications. If it is a "del" or "expired" + * translate into a {@link SessionDestroyedEvent}. + * + * @author Rob Winch + * @since 1.0 + */ +public class SessionMessageListener implements MessageListener { + private static final Log logger = LogFactory.getLog(SessionMessageListener.class); + + private final ApplicationEventPublisher eventPublisher; + + /** + * Creates a new instance + * + * @param eventPublisher the {@link ApplicationEventPublisher} to use. Cannot be null. + */ + public SessionMessageListener(ApplicationEventPublisher eventPublisher) { + Assert.notNull(eventPublisher, "eventPublisher cannot be null"); + this.eventPublisher = eventPublisher; + } + + @Override + public void onMessage(Message message, byte[] pattern) { + byte[] messageBody = message.getBody(); + if(messageBody == null) { + return; + } + String body = new String(messageBody); + if(!("del".equals(body) || "expired".equals(body))) { + return; + } + String channel = new String(message.getChannel()); + int beginIndex = channel.lastIndexOf(":") + 1; + int endIndex = channel.length(); + String sessionId = channel.substring(beginIndex, endIndex); + + publishEvent(new SessionDestroyedEvent(this, sessionId)); + } + + private void publishEvent(ApplicationEvent event) { + try { + this.eventPublisher.publishEvent(event); + } + catch (Throwable ex) { + logger.error("Error publishing " + event + ".", ex); + } + } + +} diff --git a/spring-session/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfiguration.java b/spring-session/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfiguration.java index cb2de28..5775f9b 100644 --- a/spring-session/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfiguration.java +++ b/spring-session/src/main/java/org/springframework/session/data/redis/config/annotation/web/http/RedisHttpSessionConfiguration.java @@ -20,6 +20,7 @@ import java.util.Map; import org.springframework.beans.factory.BeanClassLoaderAware; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.ImportAware; @@ -28,10 +29,13 @@ import org.springframework.core.annotation.AnnotationUtils; import org.springframework.core.type.AnnotationMetadata; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.listener.PatternTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; import org.springframework.data.redis.serializer.StringRedisSerializer; import org.springframework.session.ExpiringSession; import org.springframework.session.SessionRepository; import org.springframework.session.data.redis.RedisOperationsSessionRepository; +import org.springframework.session.data.redis.SessionMessageListener; import org.springframework.session.web.http.HttpSessionStrategy; import org.springframework.session.web.http.SessionRepositoryFilter; import org.springframework.util.ClassUtils; @@ -55,6 +59,24 @@ public class RedisHttpSessionConfiguration implements ImportAware, BeanClassLoad private HttpSessionStrategy httpSessionStrategy; + @Autowired + private ApplicationEventPublisher eventPublisher; + + @Bean + public RedisMessageListenerContainer redisMessageListenerContainer( + RedisConnectionFactory connectionFactory) { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(connectionFactory); + container.addMessageListener(redisSessionMessageListener(), + new PatternTopic("__keyspace@0__:spring:session:sessions:*")); + return container; + } + + @Bean + public SessionMessageListener redisSessionMessageListener() { + return new SessionMessageListener(eventPublisher); + } + @Bean public RedisTemplate sessionRedisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplate template = new RedisTemplate(); diff --git a/spring-session/src/main/java/org/springframework/session/events/SessionDestroyedEvent.java b/spring-session/src/main/java/org/springframework/session/events/SessionDestroyedEvent.java new file mode 100644 index 0000000..e56411b --- /dev/null +++ b/spring-session/src/main/java/org/springframework/session/events/SessionDestroyedEvent.java @@ -0,0 +1,43 @@ +/* + * Copyright 2002-2013 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.session.events; + +import org.springframework.context.ApplicationEvent; +import org.springframework.session.Session; +import org.springframework.session.SessionRepository; + +/** + * For {@link SessionRepository} implementations that support it, this event is + * fired when a {@link Session} is destroyed either explicitly or via + * expiration. + * + * @author Rob Winch + * @since 1.0 + * + */ +@SuppressWarnings("serial") +public class SessionDestroyedEvent extends ApplicationEvent { + private final String sessionId; + + public SessionDestroyedEvent(Object source, String sessionId) { + super(source); + this.sessionId = sessionId; + } + + public String getSessionId() { + return sessionId; + } +} \ No newline at end of file diff --git a/spring-session/src/test/java/org/springframework/session/data/redis/SessionMessageListenerTests.java b/spring-session/src/test/java/org/springframework/session/data/redis/SessionMessageListenerTests.java new file mode 100644 index 0000000..a02f7ff --- /dev/null +++ b/spring-session/src/test/java/org/springframework/session/data/redis/SessionMessageListenerTests.java @@ -0,0 +1,139 @@ +/* + * Copyright 2002-2013 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.session.data.redis; + +import static org.fest.assertions.Assertions.assertThat; +import static org.mockito.Mockito.*; + +import java.io.UnsupportedEncodingException; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; +import org.springframework.context.ApplicationEvent; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.redis.connection.Message; +import org.springframework.session.events.SessionDestroyedEvent; + + +/** + * + * @author Rob Winch + * + */ +@RunWith(MockitoJUnitRunner.class) +public class SessionMessageListenerTests { + @Mock + ApplicationEventPublisher eventPublisher; + + @Mock + Message message; + + @Captor + ArgumentCaptor event; + + byte[] pattern; + + SessionMessageListener listener; + + @Before + public void setup() { + listener = new SessionMessageListener(eventPublisher); + } + + @Test(expected = IllegalArgumentException.class) + public void constructorNullEventPublisher() { + new SessionMessageListener(null); + } + + @Test + public void onMessageNullBody() throws Exception { + listener.onMessage(message, pattern); + + verifyZeroInteractions(eventPublisher); + } + + @Test + public void onMessageDel() throws Exception { + mockMessage("del","spring:sessions:session:123"); + + listener.onMessage(message, pattern); + + verify(eventPublisher).publishEvent(event.capture()); + assertThat(event.getValue().getSessionId()).isEqualTo("123"); + } + + @Test + public void onMessageSource() throws Exception { + mockMessage("del","spring:sessions:session:123"); + + listener.onMessage(message, pattern); + + verify(eventPublisher).publishEvent(event.capture()); + assertThat(event.getValue().getSource()).isEqualTo(listener); + } + + @Test + public void onMessageExpired() throws Exception { + mockMessage("expired","spring:sessions:session:543"); + + listener.onMessage(message, pattern); + + verify(eventPublisher).publishEvent(event.capture()); + assertThat(event.getValue().getSessionId()).isEqualTo("543"); + } + + @Test + public void onMessageHset() throws Exception { + mockMessage("hset","spring:sessions:session:123"); + + listener.onMessage(message, pattern); + + verifyZeroInteractions(eventPublisher); + } + + @Test + public void onMessageRename() throws Exception { + mockMessage("rename","spring:sessions:session:123"); + + listener.onMessage(message, pattern); + + verifyZeroInteractions(eventPublisher); + } + + @Test + public void onMessageEventPublisherErrorCaught() throws Exception { + mockMessage("del","spring:sessions:session:123"); + doThrow(new IllegalStateException("Test Exceptions are caught")).when(eventPublisher).publishEvent(any(ApplicationEvent.class)); + + listener.onMessage(message, pattern); + + verify(eventPublisher).publishEvent(any(ApplicationEvent.class)); + } + + private void mockMessage(String body, String channel) throws UnsupportedEncodingException { + when(message.getBody()).thenReturn(bytes(body)); + when(message.getChannel()).thenReturn(bytes(channel)); + } + + private static byte[] bytes(String s) throws UnsupportedEncodingException { + return s.getBytes("UTF-8"); + } +}