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:
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user