|
|
|
|
@@ -20,6 +20,12 @@ import java.util.Map;
|
|
|
|
|
import java.util.Set;
|
|
|
|
|
import java.util.concurrent.TimeUnit;
|
|
|
|
|
|
|
|
|
|
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.data.redis.connection.RedisConnectionFactory;
|
|
|
|
|
import org.springframework.data.redis.core.BoundHashOperations;
|
|
|
|
|
import org.springframework.data.redis.core.RedisOperations;
|
|
|
|
|
@@ -42,7 +48,7 @@ import org.springframework.util.Assert;
|
|
|
|
|
* {@link org.springframework.data.redis.core.RedisOperations}. In a web
|
|
|
|
|
* environment, this is typically used in combination with
|
|
|
|
|
* {@link SessionRepositoryFilter}. This implementation supports
|
|
|
|
|
* {@link SessionDeletedEvent} and {@link SessionExpiredEvent} through {@link SessionMessageListener}.
|
|
|
|
|
* {@link SessionDeletedEvent} and {@link SessionExpiredEvent} by implementing {@link MessageListener}.
|
|
|
|
|
* </p>
|
|
|
|
|
*
|
|
|
|
|
* <h2>Creating a new instance</h2>
|
|
|
|
|
@@ -52,36 +58,61 @@ import org.springframework.util.Assert;
|
|
|
|
|
* <pre>
|
|
|
|
|
* JedisConnectionFactory factory = new JedisConnectionFactory();
|
|
|
|
|
*
|
|
|
|
|
* RedisOperationsSessionRepository redisSessionRepository = new RedisOperationsSessionRepository(
|
|
|
|
|
* factory);
|
|
|
|
|
* RedisOperationsSessionRepository redisSessionRepository = new RedisOperationsSessionRepository(factory);
|
|
|
|
|
* </pre>
|
|
|
|
|
*
|
|
|
|
|
* <p>
|
|
|
|
|
* For additional information on how to create a RedisTemplate, refer to the <a
|
|
|
|
|
* href =
|
|
|
|
|
* "http://docs.spring.io/spring-data/data-redis/docs/current/reference/html/"
|
|
|
|
|
* >Spring Data Redis Reference</a>.
|
|
|
|
|
* For additional information on how to create a RedisTemplate, refer to the
|
|
|
|
|
* <a href =
|
|
|
|
|
* "http://docs.spring.io/spring-data/data-redis/docs/current/reference/html/" >
|
|
|
|
|
* Spring Data Redis Reference</a>.
|
|
|
|
|
* </p>
|
|
|
|
|
*
|
|
|
|
|
* <h2>Storage Details</h2>
|
|
|
|
|
*
|
|
|
|
|
* The sections below outline how Redis is updated for each operation. An
|
|
|
|
|
* example of creating a new session can be found below. The subsequent sections
|
|
|
|
|
* describe the details.
|
|
|
|
|
*
|
|
|
|
|
* <pre>
|
|
|
|
|
* HMSET spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe creationTime 1404360000000 maxInactiveInterval 1800 lastAccessedTime 1404360000000 sessionAttr:attrName someAttrValue sessionAttr2:attrName someAttrValue2
|
|
|
|
|
* EXPIRE spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe 2100
|
|
|
|
|
* APPEND spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe ""
|
|
|
|
|
* EXPIRE spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe 1800
|
|
|
|
|
* SADD spring:session:expirations:1439245080000 expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe
|
|
|
|
|
* EXPIRE spring:session:expirations1439245080000 2100
|
|
|
|
|
* </pre>
|
|
|
|
|
*
|
|
|
|
|
* <h3>Saving a Session</h3>
|
|
|
|
|
*
|
|
|
|
|
* <p>
|
|
|
|
|
* Each session is stored in Redis as a <a
|
|
|
|
|
* href="http://redis.io/topics/data-types#hashes">Hash</a>. Each session is set
|
|
|
|
|
* and updated using the <a href="http://redis.io/commands/hmset">HMSET
|
|
|
|
|
* Each session is stored in Redis as a
|
|
|
|
|
* <a href="http://redis.io/topics/data-types#hashes">Hash</a>. Each session is
|
|
|
|
|
* set and updated using the <a href="http://redis.io/commands/hmset">HMSET
|
|
|
|
|
* command</a>. An example of how each session is stored can be seen below.
|
|
|
|
|
* </p>
|
|
|
|
|
*
|
|
|
|
|
* <pre>HMSET spring:session:sessions:<session-id> creationTime 1404360000000 maxInactiveInterval 1800 lastAccessedTime 1404360000000 sessionAttr:<attrName> someAttrValue sessionAttr2:<attrName> someAttrValue2</pre>
|
|
|
|
|
* <pre>
|
|
|
|
|
* HMSET spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe creationTime 1404360000000 maxInactiveInterval 1800 lastAccessedTime 1404360000000 sessionAttr:attrName someAttrValue sessionAttr:attrName2 someAttrValue2
|
|
|
|
|
* </pre>
|
|
|
|
|
*
|
|
|
|
|
* <p>
|
|
|
|
|
* An expiration is associated to each session using the <a
|
|
|
|
|
* href="http://redis.io/commands/expire">EXPIRE command</a> based upon the
|
|
|
|
|
* {@link org.springframework.session.data.redis.RedisOperationsSessionRepository.RedisSession#getMaxInactiveIntervalInSeconds()}
|
|
|
|
|
* . For example:
|
|
|
|
|
* In this example, the session following statements are true about the session:
|
|
|
|
|
* </p>
|
|
|
|
|
* <ul>
|
|
|
|
|
* <li>The session id is 33fdd1b6-b496-4b33-9f7d-df96679d32fe</li>
|
|
|
|
|
* <li>The session was created at 1404360000000 in milliseconds since midnight
|
|
|
|
|
* of 1/1/1970 GMT.</li>
|
|
|
|
|
* <li>The session expires in 1800 seconds (30 minutes).</li>
|
|
|
|
|
* <li>The session was last accessed at 1404360000000 in milliseconds since
|
|
|
|
|
* midnight of 1/1/1970 GMT.</li>
|
|
|
|
|
* <li>The session has two attributes. The first is "attrName" with the value of
|
|
|
|
|
* "someAttrValue". The second session attribute is named "attrName2" with the
|
|
|
|
|
* value of "someAttrValue2".</li>
|
|
|
|
|
* </ul>
|
|
|
|
|
*
|
|
|
|
|
* <pre>EXPIRE spring:session:sessions:<session-id> 1800</pre>
|
|
|
|
|
*
|
|
|
|
|
* <h3>Optimized Writes</h3>
|
|
|
|
|
*
|
|
|
|
|
* <p>
|
|
|
|
|
* The {@link RedisSession} keeps track of the properties that have changed and
|
|
|
|
|
@@ -92,54 +123,118 @@ import org.springframework.util.Assert;
|
|
|
|
|
* </p>
|
|
|
|
|
*
|
|
|
|
|
* <pre>
|
|
|
|
|
* HMSET spring:session:sessions:<session-id> sessionAttr2:<attrName> newValue
|
|
|
|
|
* EXPIRE spring:session:sessions:<session-id> 1800
|
|
|
|
|
* HMSET spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe sessionAttr:attrName2 newValue
|
|
|
|
|
* </pre>
|
|
|
|
|
*
|
|
|
|
|
* <p>
|
|
|
|
|
* Spring Session relies on the expired and delete <a href="http://redis.io/topics/notifications">keyspace notifications</a> from Redis to fire a <<SessionDestroyedEvent>>.
|
|
|
|
|
* It is the `SessionDestroyedEvent` that ensures resources associated with the Session are cleaned up.
|
|
|
|
|
* For example, when using Spring Session's WebSocket support the Redis expired or delete event is what triggers any
|
|
|
|
|
* WebSocket connections associated with the session to be closed.
|
|
|
|
|
* </p>
|
|
|
|
|
* <h3>Expiration</h3>
|
|
|
|
|
*
|
|
|
|
|
* <p>
|
|
|
|
|
* One problem with this approach is that Redis makes no guarantee of when the expired event will be fired if they key has not been accessed.
|
|
|
|
|
* Specifically the background task that Redis uses to clean up expired keys is a low priority task and may not trigger the key expiration.
|
|
|
|
|
* For additional details see <a href="http://redis.io/topics/notifications">Timing of expired events</a> section in the Redis documentation.
|
|
|
|
|
* </p>
|
|
|
|
|
*
|
|
|
|
|
* <p>
|
|
|
|
|
* To circumvent the fact that expired events are not guaranteed to happen we can ensure that each key is accessed when it is expected to expire.
|
|
|
|
|
* This means that if the TTL is expired on the key, Redis will remove the key and fire the expired event when we try to access they key.
|
|
|
|
|
* </p>
|
|
|
|
|
*
|
|
|
|
|
* <p>
|
|
|
|
|
* For this reason, each session expiration is also tracked to the nearest minute.
|
|
|
|
|
* This allows a background task to access the potentially expired sessions to ensure that Redis expired events are fired in a more deterministic fashion.
|
|
|
|
|
* For example:
|
|
|
|
|
* An expiration is associated to each session using the
|
|
|
|
|
* <a href="http://redis.io/commands/expire">EXPIRE command</a> based upon the
|
|
|
|
|
* {@link org.springframework.session.data.redis.RedisOperationsSessionRepository.RedisSession#getMaxInactiveIntervalInSeconds()}
|
|
|
|
|
* . For example:
|
|
|
|
|
* </p>
|
|
|
|
|
*
|
|
|
|
|
* <pre>
|
|
|
|
|
* SADD spring:session:expirations:<expire-rounded-up-to-nearest-minute> <session-id>
|
|
|
|
|
* EXPIRE spring:session:expirations:<expire-rounded-up-to-nearest-minute> 1800
|
|
|
|
|
* EXPIRE spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe 2100
|
|
|
|
|
* </pre>
|
|
|
|
|
*
|
|
|
|
|
* <p>
|
|
|
|
|
* The background task will then use these mappings to explicitly request each key.
|
|
|
|
|
* By accessing they key, rather than deleting it, we ensure that Redis deletes the key for us only if the TTL is expired.
|
|
|
|
|
* You will note that the expiration that is set is 5 minutes after the session
|
|
|
|
|
* actually expires. This is necessary so that the value of the session can be
|
|
|
|
|
* accessed when the session expires. An expiration is set on the session itself
|
|
|
|
|
* five minutes after it actually expires to ensure it is cleaned up, but only
|
|
|
|
|
* after we perform any necessary processing.
|
|
|
|
|
* </p>
|
|
|
|
|
*
|
|
|
|
|
* <p>
|
|
|
|
|
* <b>NOTE:</b> The {@link #getSession(String)} method ensures that no expired
|
|
|
|
|
* sessions will be returned. This means there is no need to check the
|
|
|
|
|
* expiration before using a session
|
|
|
|
|
* </p>
|
|
|
|
|
*
|
|
|
|
|
* <p>
|
|
|
|
|
* Spring Session relies on the expired and delete
|
|
|
|
|
* <a href="http://redis.io/topics/notifications">keyspace notifications</a>
|
|
|
|
|
* from Redis to fire a SessionDestroyedEvent. It is the
|
|
|
|
|
* SessionDestroyedEvent that ensures resources associated with the Session
|
|
|
|
|
* are cleaned up. For example, when using Spring Session's WebSocket support
|
|
|
|
|
* the Redis expired or delete event is what triggers any WebSocket connections
|
|
|
|
|
* associated with the session to be closed.
|
|
|
|
|
* </p>
|
|
|
|
|
*
|
|
|
|
|
* <p>
|
|
|
|
|
* Expiration is not tracked directly on the session key itself since this would
|
|
|
|
|
* mean the session data would no longer be available. Instead a special session
|
|
|
|
|
* expires key is used. In our example the expires key is:
|
|
|
|
|
* </p>
|
|
|
|
|
*
|
|
|
|
|
* <pre>
|
|
|
|
|
* APPEND spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe ""
|
|
|
|
|
* EXPIRE spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe 1800
|
|
|
|
|
* </pre>
|
|
|
|
|
*
|
|
|
|
|
* <p>
|
|
|
|
|
* When a session expires key is deleted or expires, the keyspace notification
|
|
|
|
|
* triggers a lookup of the actual session and a {@link SessionDestroyedEvent}
|
|
|
|
|
* is fired.
|
|
|
|
|
* </p>
|
|
|
|
|
*
|
|
|
|
|
* <p>
|
|
|
|
|
* One problem with relying on Redis expiration exclusively is that Redis makes
|
|
|
|
|
* no guarantee of when the expired event will be fired if they key has not been
|
|
|
|
|
* accessed. Specifically the background task that Redis uses to clean up
|
|
|
|
|
* expired keys is a low priority task and may not trigger the key expiration.
|
|
|
|
|
* For additional details see
|
|
|
|
|
* <a href="http://redis.io/topics/notifications">Timing of expired events</a>
|
|
|
|
|
* section in the Redis documentation.
|
|
|
|
|
* </p>
|
|
|
|
|
*
|
|
|
|
|
* <p>
|
|
|
|
|
* To circumvent the fact that expired events are not guaranteed to happen we
|
|
|
|
|
* can ensure that each key is accessed when it is expected to expire. This
|
|
|
|
|
* means that if the TTL is expired on the key, Redis will remove the key and
|
|
|
|
|
* fire the expired event when we try to access they key.
|
|
|
|
|
* </p>
|
|
|
|
|
*
|
|
|
|
|
* <p>
|
|
|
|
|
* For this reason, each session expiration is also tracked to the nearest
|
|
|
|
|
* minute. This allows a background task to access the potentially expired
|
|
|
|
|
* sessions to ensure that Redis expired events are fired in a more
|
|
|
|
|
* deterministic fashion. For example:
|
|
|
|
|
* </p>
|
|
|
|
|
*
|
|
|
|
|
* <pre>
|
|
|
|
|
* SADD spring:session:expirations:1439245080000 expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe
|
|
|
|
|
* EXPIRE spring:session:expirations1439245080000 2100
|
|
|
|
|
* </pre>
|
|
|
|
|
*
|
|
|
|
|
* <p>
|
|
|
|
|
* The background task will then use these mappings to explicitly request each
|
|
|
|
|
* session expires key. By accessing the key, rather than deleting it, we ensure
|
|
|
|
|
* that Redis deletes the key for us only if the TTL is expired.
|
|
|
|
|
* </p>
|
|
|
|
|
* <p>
|
|
|
|
|
* <b>NOTE</b>: We do not explicitly delete the keys since in some instances there may be a race condition that incorrectly identifies a key as expired when it is not.
|
|
|
|
|
* Short of using distributed locks (which would kill our performance) there is no way to ensure the consistency of the expiration mapping.
|
|
|
|
|
* By simply accessing the key, we ensure that the key is only removed if the TTL on that key is expired.
|
|
|
|
|
* <b>NOTE</b>: We do not explicitly delete the keys since in some instances
|
|
|
|
|
* there may be a race condition that incorrectly identifies a key as expired
|
|
|
|
|
* when it is not. Short of using distributed locks (which would kill our
|
|
|
|
|
* performance) there is no way to ensure the consistency of the expiration
|
|
|
|
|
* mapping. By simply accessing the key, we ensure that the key is only removed
|
|
|
|
|
* if the TTL on that key is expired.
|
|
|
|
|
* </p>
|
|
|
|
|
*
|
|
|
|
|
* @since 1.0
|
|
|
|
|
*
|
|
|
|
|
* @author Rob Winch
|
|
|
|
|
*/
|
|
|
|
|
public class RedisOperationsSessionRepository implements SessionRepository<RedisOperationsSessionRepository.RedisSession> {
|
|
|
|
|
public class RedisOperationsSessionRepository implements SessionRepository<RedisOperationsSessionRepository.RedisSession>, MessageListener {
|
|
|
|
|
private static final Log logger = LogFactory.getLog(SessionMessageListener.class);
|
|
|
|
|
|
|
|
|
|
private ApplicationEventPublisher eventPublisher = new ApplicationEventPublisher() {
|
|
|
|
|
public void publishEvent(ApplicationEvent event) {
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The prefix for each key of the Redis Hash representing a single session. The suffix is the unique session id.
|
|
|
|
|
*/
|
|
|
|
|
@@ -196,6 +291,20 @@ public class RedisOperationsSessionRepository implements SessionRepository<Redis
|
|
|
|
|
this.expirationPolicy = new RedisSessionExpirationPolicy(sessionRedisOperations);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Sets the {@link ApplicationEventPublisher} that is used to publish
|
|
|
|
|
* {@link SessionDestroyedEvent}. The default is to not publish a
|
|
|
|
|
* {@link SessionDestroyedEvent}.
|
|
|
|
|
*
|
|
|
|
|
* @param applicationEventPublisher
|
|
|
|
|
* the {@link ApplicationEventPublisher} that is used to publish
|
|
|
|
|
* {@link SessionDestroyedEvent}. Cannot be null.
|
|
|
|
|
*/
|
|
|
|
|
public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
|
|
|
|
|
Assert.notNull(applicationEventPublisher, "applicationEventPublisher cannot be null");
|
|
|
|
|
this.eventPublisher = applicationEventPublisher;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Sets the maximum inactive interval in seconds between requests before newly created sessions will be
|
|
|
|
|
* invalidated. A negative time indicates that the session will never timeout. The default is 1800 (30 minutes).
|
|
|
|
|
@@ -258,16 +367,22 @@ public class RedisOperationsSessionRepository implements SessionRepository<Redis
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void delete(String sessionId) {
|
|
|
|
|
ExpiringSession session = getSession(sessionId, true);
|
|
|
|
|
RedisSession session = getSession(sessionId, true);
|
|
|
|
|
if(session == null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String key = getKey(sessionId);
|
|
|
|
|
expirationPolicy.onDelete(session);
|
|
|
|
|
|
|
|
|
|
// always delete they key since session may be null if just expired
|
|
|
|
|
this.sessionRedisOperations.delete(key);
|
|
|
|
|
String expireKey = getExpiredKey(session.getId());
|
|
|
|
|
this.sessionRedisOperations.delete(expireKey);
|
|
|
|
|
|
|
|
|
|
session.setMaxInactiveIntervalInSeconds(0);
|
|
|
|
|
save(session);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private String getExpiredKey(String sessionId) {
|
|
|
|
|
return getKey("expires:" + sessionId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public RedisSession createSession() {
|
|
|
|
|
@@ -278,6 +393,68 @@ public class RedisOperationsSessionRepository implements SessionRepository<Redis
|
|
|
|
|
return redisSession;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
public void onMessage(Message message, byte[] pattern) {
|
|
|
|
|
byte[] messageChannel = message.getChannel();
|
|
|
|
|
byte[] messageBody = message.getBody();
|
|
|
|
|
if(messageChannel == null || messageBody == null) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
String channel = new String(messageChannel);
|
|
|
|
|
|
|
|
|
|
String body = new String(messageBody);
|
|
|
|
|
if(!body.startsWith("spring:session:sessions:expires")) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
boolean isDeleted = channel.endsWith(":del");
|
|
|
|
|
if(isDeleted || channel.endsWith(":expired")) {
|
|
|
|
|
int beginIndex = body.lastIndexOf(":") + 1;
|
|
|
|
|
int endIndex = body.length();
|
|
|
|
|
String sessionId = body.substring(beginIndex, endIndex);
|
|
|
|
|
|
|
|
|
|
RedisSession session = getSession(sessionId, true);
|
|
|
|
|
|
|
|
|
|
if(logger.isDebugEnabled()) {
|
|
|
|
|
logger.debug("Publishing SessionDestroyedEvent for session " + sessionId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if(isDeleted) {
|
|
|
|
|
handleDeleted(sessionId, session);
|
|
|
|
|
} else {
|
|
|
|
|
handleExpired(sessionId, session);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void handleDeleted(String sessionId, RedisSession session) {
|
|
|
|
|
if(session == null) {
|
|
|
|
|
publishEvent(new SessionDeletedEvent(this, sessionId));
|
|
|
|
|
} else {
|
|
|
|
|
publishEvent(new SessionDeletedEvent(this, session));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void handleExpired(String sessionId, RedisSession session) {
|
|
|
|
|
if(session == null) {
|
|
|
|
|
publishEvent(new SessionExpiredEvent(this, sessionId));
|
|
|
|
|
} else {
|
|
|
|
|
publishEvent(new SessionExpiredEvent(this, session));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void publishEvent(ApplicationEvent event) {
|
|
|
|
|
try {
|
|
|
|
|
this.eventPublisher.publishEvent(event);
|
|
|
|
|
}
|
|
|
|
|
catch (Throwable ex) {
|
|
|
|
|
logger.error("Error publishing " + event + ".", ex);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Gets the Hash key for this session by prefixing it appropriately.
|
|
|
|
|
*
|
|
|
|
|
@@ -342,6 +519,8 @@ public class RedisOperationsSessionRepository implements SessionRepository<Redis
|
|
|
|
|
delta.put(LAST_ACCESSED_ATTR, getLastAccessedTime());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Creates a new instance from the provided {@link MapSession}
|
|
|
|
|
*
|
|
|
|
|
|