Add SessionDestroyedEvent

Add support for SessionDestroyedEvent so that applications can detect
when a Spring Session has expired or explicitly deleted. This will
ensure that we can cleanup resources (i.e. WebSockets) can be cleaned up
when a Session ends.

Fixes gh-60
This commit is contained in:
Rob Winch
2014-11-12 17:37:30 -06:00
parent 6430e43f0e
commit 46173f6486
6 changed files with 288 additions and 1 deletions

View File

@@ -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.
*
* <p>
* The implementation does NOT support firing {@link SessionDestroyedEvent}.
* </p>
*
* @author Rob Winch
* @since 1.0
*/

View File

@@ -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}.
* </p>
*
* <h2>Creating a new instance</h2>

View File

@@ -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);
}
}
}

View File

@@ -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<String,ExpiringSession> sessionRedisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, ExpiringSession> template = new RedisTemplate<String, ExpiringSession>();

View File

@@ -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;
}
}

View File

@@ -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<SessionDestroyedEvent> 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");
}
}