在使用spring-session-data-redis时只需要在@Configuration类上加上@EnableSpringHttpSession即可实现使用Redis集中存储Session。将此批注添加到@Configuration类,以将SessionRepositoryFilter公开为名为“springSessionRepositoryFilter”的bean,并由用户提供的SessionRepository实现提供支持。 为了利用注释,必须提供单个SessionRepository bean。 例如:
@Configuration
@EnableSpringHttpSession
public class SpringHttpSessionConfig {
@Bean
public SessionRepository sessionRepository() {
return new MapSessionRepository();
}
}
@Import(SpringHttpSessionConfiguration.class)
@Configuration
public @interface EnableSpringHttpSession {
}
@EnableSpringHttpSession的目的就是为了导入一个Configuration Class,这个配置类定义了一个核心过滤器SessionRepositoryFilter。
@Bean
public SessionRepositoryFilter extends ExpiringSession> springSessionRepositoryFilter(
SessionRepository sessionRepository) {
SessionRepositoryFilter sessionRepositoryFilter = new SessionRepositoryFilter(
sessionRepository);
sessionRepositoryFilter.setServletContext(this.servletContext);
if (this.httpSessionStrategy instanceof MultiHttpSessionStrategy) {
sessionRepositoryFilter.setHttpSessionStrategy(
(MultiHttpSessionStrategy) this.httpSessionStrategy);
}
else {
sessionRepositoryFilter.setHttpSessionStrategy(this.httpSessionStrategy);
}
return sessionRepositoryFilter;
}
这个SessionRepositoryFilter的构造方法需要一个SessionRepository,所以需要我们配置一个SessionRepository实例注册到Spring容器中。SessionRepository是管理Session的接口,Spring为我们提供了许多默认的实现稍后讲解。
SessionRepositoryFilter默认会拦截所有的Http请求,然后使用SessionRepositoryRequestWrapper和SessionRepositoryResponseWrapper分别包装原始HttpServletRequest对象和HttpServletResponse对象,重写与Session相关的方法。SessionRepositoryFilter有一个成员变量httpSessionStrategy默认是CookieHttpSessionStrategy,SessionRepositoryRequestWrapper有关session的方法会使用到它,因为它的session id会以cookie的形式存储到客户端。
一个HttpSessionStrategy实现类,它使用cookie从中获取会话。具体来说,此实现将允许使用setCookieName(String)指定cookie名称。默认为“SESSION”。创建会话时,HTTP响应将具有包含指定cookie名称和会话ID值的cookie。 cookie将被标记为会话cookie,使用cookie路径的上下文路径,标记为HTTPOnly,如果HttpServletRequest.isSecure()返回true,则cookie将被标记为Secure。例如:
HTTP/1.1 200 OK
Set-Cookie: SESSION=f81d4fae-7dec-11d0-a765-00a0c91e6bf6; Path=/context-root; Secure; HttpOnly
客户端现在应该通过在请求中指定相同的cookie来在每个请求中包含会话。例如:
GET /messages/ HTTP/1.1
Host: example.com
Cookie: SESSION=f81d4fae-7dec-11d0-a765-00a0c91e6bf6
当会话失效时,服务器将发送一个使cookie过期的HTTP响应。例如:
HTTP/1.1 200 OK
Set-Cookie: SESSION=f81d4fae-7dec-11d0-a765-00a0c91e6bf6; Expires=Thur, 1 Jan 1970 00:00:00 GMT; Secure; HttpOnly
支持多个同时会话
默认情况下,还支持多个会话。与浏览器建立会话后,可以通过为setSessionAliasParamName(String)指定唯一值来启动另一个会话。例如,请求:
GET /messages/?_s=1416195761178 HTTP/1.1
Host: example.com
Cookie: SESSION=f81d4fae-7dec-11d0-a765-00a0c91e6bf6
将导致以下响应:
HTTP/1.1 200 OK
Set-Cookie: SESSION="0 f81d4fae-7dec-11d0-a765-00a0c91e6bf6 1416195761178 8a929cde-2218-4557-8d4e-82a79a37876d"; Expires=Thur, 1 Jan 1970 00:00:00 GMT; Secure; HttpOnly
要使用原始会话,可以进行不带HTTP参数的请求。要使用新会话,可以使用HTTP参数_s=1416195761178的请求。默认情况下,URL将被重写以包含当前选定的会话。
getSessionIds方法是HttpSessionManager接口定义的用于获取会话别名到会话ID的映射,CookieHttpSessionStrategy是支持多回话的,每个会话都对应一个别名。
public Map getSessionIds(HttpServletRequest request) {
//读取同名的cookieName(默认是SESSION)的所有cookie value值
List cookieValues = this.cookieSerializer.readCookieValues(request);
String sessionCookieValue = cookieValues.isEmpty() ? ""
: cookieValues.iterator().next();
Map result = new LinkedHashMap();
//以空格符分割session 格式为 alias1 sessionId1 alias2 sessionId2 ...
StringTokenizer tokens = new StringTokenizer(sessionCookieValue,
this.deserializationDelimiter);
if (tokens.countTokens() == 1) {
result.put(DEFAULT_ALIAS, tokens.nextToken());
return result;
}
while (tokens.hasMoreTokens()) {
String alias = tokens.nextToken();
if (!tokens.hasMoreTokens()) {
break;
}
String id = tokens.nextToken();
result.put(alias, id);
}
return result;
}
HttpSessionManager接口方法,从HttpServletRequest取得当前Session的别名,CookieHttpSessionStrategy实现是从request.getParameter("_s")中获取。
public String getCurrentSessionAlias(HttpServletRequest request) {
//sessionParam默认值是"_s"
if (this.sessionParam == null) {
return DEFAULT_ALIAS;
}
String u = request.getParameter(this.sessionParam);
if (u == null) {
//"0"
return DEFAULT_ALIAS;
}
if (!ALIAS_PATTERN.matcher(u).matches()) {
return DEFAULT_ALIAS;
}
return u;
}
HttpSessionStrategy接口方法,从HttpServletRequest获取请求的会话ID。 例如,会话ID可能来自cookie或请求标头,CookieHttpSessionStrategy实现是从Cookie中获取。
public String getRequestedSessionId(HttpServletRequest request) {
Map sessionIds = getSessionIds(request);
String sessionAlias = getCurrentSessionAlias(request);
return sessionIds.get(sessionAlias);
}
HttpSessionStrategy接口方法,在创建新会话时调用此方法,并且应该通知客户端新会话ID是什么。
public void onNewSession(Session session, HttpServletRequest request,
HttpServletResponse response) {
//这个集合包含所有已经写入的seesionID
Set sessionIdsWritten = getSessionIdsWritten(request);
if (sessionIdsWritten.contains(session.getId())) {
return;
}
//不包含说明是个新Session 加入集合
sessionIdsWritten.add(session.getId());
//更新别名与sessionId的映射
Map sessionIds = getSessionIds(request);
String sessionAlias = getCurrentSessionAlias(request);
sessionIds.put(sessionAlias, session.getId());
//alias1 value1 alias2 value2 ..的格式
String cookieValue = createSessionCookieValue(sessionIds);
//写入Cookie
this.cookieSerializer
.writeCookieValue(new CookieValue(request, response, cookieValue));
}
@SuppressWarnings("unchecked")
private Set getSessionIdsWritten(HttpServletRequest request) {
Set sessionsWritten = (Set) request
.getAttribute(SESSION_IDS_WRITTEN_ATTR);
if (sessionsWritten == null) {
sessionsWritten = new HashSet();
request.setAttribute(SESSION_IDS_WRITTEN_ATTR, sessionsWritten);
}
return sessionsWritten;
}
HttpSessionStrategy接口方法,当会话无效时调用此方法,并且应该通知客户端会话ID不再有效。 例如,它可能会删除其中包含会话ID的cookie,或者设置一个带有空值的HTTP响应标头,指示客户端不再提交该会话ID。
public void onInvalidateSession(HttpServletRequest request,
HttpServletResponse response) {
Map sessionIds = getSessionIds(request);
String requestedAlias = getCurrentSessionAlias(request);
sessionIds.remove(requestedAlias);
//Session失效删它后更新Cookie
String cookieValue = createSessionCookieValue(sessionIds);
this.cookieSerializer
.writeCookieValue(new CookieValue(request, response, cookieValue));
}
此类主要重写了HttpServletRequest的几个方法:
重写后再调用HttpServletRequest的这些方法后将不再调用我们的Servlet容器的HttpServletRequest是实现了,下面看看重写后的行为。
@Override
public String getRequestedSessionId() {
return SessionRepositoryFilter.this.httpSessionStrategy
.getRequestedSessionId(this);
}
默认委托给CookieHttpSessionStrategy.getRequestedSessionId(this)实现,从Cookie读取SessionID。
@Override
public boolean isRequestedSessionIdValid() {
if (this.requestedSessionIdValid == null) {
String sessionId = getRequestedSessionId();
S session = sessionId == null ? null : getSession(sessionId);
return isRequestedSessionIdValid(session);
}
return this.requestedSessionIdValid;
}
private boolean isRequestedSessionIdValid(S session) {
if (this.requestedSessionIdValid == null) {
this.requestedSessionIdValid = session != null;
}
return this.requestedSessionIdValid;
}
@Override
public HttpSessionWrapper getSession(boolean create) {
//获取当前Request作用域中代表Session的属性,缓存作用避免每次都从sessionRepository获取
HttpSessionWrapper currentSession = getCurrentSession();
if (currentSession != null) {
return currentSession;
}
String requestedSessionId = getRequestedSessionId();
//客户端存在sessionId 并且未过期
if (requestedSessionId != null
&& getAttribute(INVALID_SESSION_ID_ATTR) == null) {
//使用sessionRepository.getSession(sessionId)获取Session
S session = getSession(requestedSessionId);
if (session != null) {
this.requestedSessionIdValid = true;
currentSession = new HttpSessionWrapper(session, getServletContext());
currentSession.setNew(false);
setCurrentSession(currentSession);
return currentSession;
}
else {
// This is an invalid session id. No need to ask again if
// request.getSession is invoked for the duration of this request
if (SESSION_LOGGER.isDebugEnabled()) {
SESSION_LOGGER.debug(
"No session found by id: Caching result for getSession(false) for this HttpServletRequest.");
}
setAttribute(INVALID_SESSION_ID_ATTR, "true");
}
}
if (!create) {
return null;
}
if (SESSION_LOGGER.isDebugEnabled()) {
SESSION_LOGGER.debug(
"A new session was created. To help you troubleshoot where the session was created we provided a StackTrace (this is not an error). You can prevent this from appearing by disabling DEBUG logging for "
+ SESSION_LOGGER_NAME,
new RuntimeException(
"For debugging purposes only (not an error)"));
}
//执行到这了说明需要创建新的Session
S session = SessionRepositoryFilter.this.sessionRepository.createSession();
session.setLastAccessedTime(System.currentTimeMillis());
currentSession = new HttpSessionWrapper(session, getServletContext());
setCurrentSession(currentSession);
return currentSession;
}
上面有一点需要注意就是将Sesison对象包装成了HttpSessionWrapper,目的是当Session失效时可以从sessionRepository删除。
private final class HttpSessionWrapper extends ExpiringSessionHttpSession {
HttpSessionWrapper(S session, ServletContext servletContext) {
super(session, servletContext);
}
@Override
public void invalidate() {
super.invalidate();
SessionRepositoryRequestWrapper.this.requestedSessionInvalidated = true;
setCurrentSession(null);
SessionRepositoryFilter.this.sessionRepository.delete(getId());
}
}
@Override
public HttpSessionWrapper getSession() {
//不存在直接创建
return getSession(true);
}
SessionRepositoryResponseWrapper继承于OnCommittedResponseWrapper实现了抽象方法onResponseCommitted()
private final class SessionRepositoryResponseWrapper
extends OnCommittedResponseWrapper {
private final SessionRepositoryRequestWrapper request;
SessionRepositoryResponseWrapper(SessionRepositoryRequestWrapper request,
HttpServletResponse response) {
super(response);
if (request == null) {
throw new IllegalArgumentException("request cannot be null");
}
this.request = request;
}
@Override
protected void onResponseCommitted() {
this.request.commitSession();
}
}
那么这个方法何时会被调用呢?
当调用SessionRepositoryResponseWrapper的以上方法就会触发onResponseCommitted方法,然后紧接着调用原始父类的相应方法。而onResponseCommitted方法内部是调用this.request.commitSession();它的作用是什么呢?
private void commitSession() {
HttpSessionWrapper wrappedSession = getCurrentSession();
if (wrappedSession == null) {
if (isInvalidateClientSession()) {
SessionRepositoryFilter.this.httpSessionStrategy
.onInvalidateSession(this, this.response);
}
}
else {
S session = wrappedSession.getSession();
SessionRepositoryFilter.this.sessionRepository.save(session);
if (!isRequestedSessionIdValid()
|| !session.getId().equals(getRequestedSessionId())) {
SessionRepositoryFilter.this.httpSessionStrategy.onNewSession(session,
this, this.response);
}
}
}
这个方法的作用就是当前Session存在则使用sessionRepository保存(可能是新Session)或更新(老Session则更新一下避免过期)Session。如果Session不存在并且isInvalidateClientSession()为true说明Session已过期调用httpSessionStrategy .onInvalidateSession(this, this.response);更新Cookie。
commitSession()方法还会在过滤器结束后调用,用来更新Session。
通过分析SessionRepositoryFilter的源码知道了对于Session增删改查都是通过SessionRepository实现的,下面就来看一看spring-data提供了哪些实现类。
public interface SessionRepository {
S createSession();
void save(S session);
S getSession(String id);
void delete(String id);
}
由Map支持并使用MapSession的SessionRepository。 默认情况下使用ConcurrentHashMap,但可以注入自定义Map以使用Redis和Hazelcast等NoSQL厂商提供的分布式Map。该实现不支持触发SessionDeletedEvent或SessionExpiredEvent。
public class MapSessionRepository implements SessionRepository {
private Integer defaultMaxInactiveInterval;
private final Map sessions;
public MapSessionRepository() {
this(new ConcurrentHashMap());
}
public MapSessionRepository(Map sessions) {
if (sessions == null) {
throw new IllegalArgumentException("sessions cannot be null");
}
this.sessions = sessions;
}
public void setDefaultMaxInactiveInterval(int defaultMaxInactiveInterval) {
this.defaultMaxInactiveInterval = Integer.valueOf(defaultMaxInactiveInterval);
}
public void save(ExpiringSession session) {
this.sessions.put(session.getId(), new MapSession(session));
}
public ExpiringSession getSession(String id) {
ExpiringSession saved = this.sessions.get(id);
if (saved == null) {
return null;
}
if (saved.isExpired()) {
delete(saved.getId());
return null;
}
return new MapSession(saved);
}
public void delete(String id) {
this.sessions.remove(id);
}
public ExpiringSession createSession() {
ExpiringSession result = new MapSession();
if (this.defaultMaxInactiveInterval != null) {
result.setMaxInactiveIntervalInSeconds(this.defaultMaxInactiveInterval);
}
return result;
}
}
扩展基本SessionRepository以允许通过主体名称查找会话ID。 主体名称由Session属性定义,名称为PRINCIPAL_NAME_INDEX_NAME=FindByIndexNameSessionRepository.class.getName() .concat(".PRINCIPAL_NAME_INDEX_NAME")
public interface FindByIndexNameSessionRepository
extends SessionRepository {
//包含当前主体名称(即用户名)的公共会话属性。开发人员有责任确保填充属性,
//因为Spring Session不知道正在使用的身份验证机制。
String PRINCIPAL_NAME_INDEX_NAME = FindByIndexNameSessionRepository.class.getName()
.concat(".PRINCIPAL_NAME_INDEX_NAME");
//查找会话ID的映射到包含名称为PRINCIPAL_NAME_INDEX_NAME的会话属性的所有会话的会话以及指定主体名称的值
Map findByIndexNameAndIndexValue(String indexName, String indexValue);
}
使用Spring Data的RedisOperations实现的SessionRepository。在Web环境中,这通常与SessionRepositoryFilter结合使用。此实现通过实现MessageListener来支持SessionDeletedEvent和SessionExpiredEvent。
创建一个新实例,下面是一个如何创建新实例的典型示例:
JedisConnectionFactory factory = new JedisConnectionFactory();
RedisOperationsSessionRepository redisSessionRepository = new RedisOperationsSessionRepository(factory);
存储细节
以下部分概述了如何针对每个操作更新Redis。可以在下面找到创建新会话的示例。后续部分描述了详细信息。
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
保存会话
每个会话都作为哈希存储在Redis中。使用HMSET命令设置和更新每个会话。下面将介绍如何存储每个会话的示例。
HMSET spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe creationTime 1404360000000 maxInactiveInterval 1800 lastAccessedTime 1404360000000 sessionAttr:attrName someAttrValue sessionAttr:attrName2 someAttrValue2
在此示例中,会话后续语句对于会话是真实的:
Session ID为33fdd1b6-b496-4b33-9f7d-df96679d32fe
Session创建自自格林威治标准时间1970年1月1日午夜起,1404360000000毫秒。
Session将在1800秒(30分钟)后到期。
Session最后一次访问时间为自1970年1月1日格林威治标准时间午夜起1404360000000毫秒。
该Session有两个属性。第一个是“attrName”,其值为“someAttrValue”。第二个会话属性名为“attrName2”,其值为“someAttrValue2”。
优化的写入
RedisOperationsSessionRepository.RedisSession会跟踪已更改的属性,并仅更新这些属性。这意味着如果一个属性被写入一次并且多次读取,我们只需要写一次该属性。例如,假设更新了之前的会话属性“sessionAttr2”。保存后将执行以下操作:
HMSET spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe sessionAttr:attrName2 newValue
SessionCreatedEvent
创建会话时,会向Redis发送一个事件,其key为"spring:session:channel:created:33fdd1b6-b496-4b33-9f7d-df96679d32fe",以“33fdd1b6-b496-4b33-9f7d-df96679d32fe”为sesion ID。事件的主体将是创建的会话。
如果注册了MessageListener,则RedisOperationsSessionRepository将Redis消息转换为SessionCreatedEvent。
过期
根据RedisOperationsSessionRepository.RedisSession.getMaxInactiveIntervalInSeconds(),使用EXPIRE命令将到期与每个会话相关联。例如:
EXPIRE spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe 2100
您将注意到,设置的到期时间是会话实际到期后的5分钟。这是必要的,以便在会话到期时可以访问会话的值。会话本身在实际到期后五分钟设置到期,以确保它被清理,但仅在我们执行任何必要的处理之后。
注意:getSession(String)方法确保不返回过期的会话。这意味着在使用会话之前无需检查过期时间Spring Session依赖于Redis的过期和删除键空间通知来触发SessionDestroyedEvent。 SessionDestroyedEvent确保清除与Session关联的资源。例如,当使用Spring Session的WebSocket支持时,Redis过期或删除事件会触发与要关闭的会话关联的任何WebSocket连接。
不会直接在会话密钥本身上跟踪到期,因为这意味着会话数据将不再可用。而是使用特殊会话到期密钥。在我们的示例中,expires键是:
APPEND spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe ""
EXPIRE spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe 1800
当会话到期密钥被删除或过期时,密钥空间通知会触发查找实际会话并触发SessionDestroyedEvent。
依赖于Redis过期的一个问题是,如果没有访问密钥,Redis不保证何时将触发过期事件。具体而言,Redis用于清除过期密钥的后台任务是低优先级任务,可能不会触发密钥过期。有关其他详细信息,请参阅Redis文档中的过期事件的时间部分。
为了避免过期事件无法保证发生这一事实,我们可以确保在预期到期时访问每个密钥。这意味着如果密钥上的TTL过期,当我们尝试访问密钥时,Redis将删除密钥并触发过期事件。
因此,每个会话到期也会跟踪到最近的分钟。这允许后台任务访问可能已过期的会话,以确保以更确定的方式触发Redis过期事件。例如:
SADD spring:session:expirations:1439245080000 expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe
EXPIRE spring:session:expirations1439245080000 2100
然后,后台任务将使用这些映射来显式请求每个会话到期密钥。通过访问密钥而不是删除密钥,我们确保只有在TTL过期时Redis才会删除密钥。
注意:我们没有明确删除密钥,因为在某些情况下,可能存在竞争条件错误地将密钥标识为过期而未过期。如果没有使用分布式锁(这会破坏我们的性能),就无法确保到期映射的一致性。通过简单地访问密钥,我们确保仅在该密钥上的TTL过期时才删除密钥。
本文开头使用@EnableSpringHttpSession还需要配置一个SessionRepository来决定底层使用什么存储,而使用@EnableRedisHttpSession将此批注添加到@Configuration类,以将SessionRepositoryFilter公开为名为“springSessionRepositoryFilter”的bean,并由Redis提供支持。 为了利用注释,必须提供单个RedisConnectionFactory。 例如:
org.springframework.session
spring-session-data-redis
@Configuration
@EnableRedisHttpSession
public class RedisHttpSessionConfig {
@Bean
public JedisConnectionFactory connectionFactory() throws Exception {
return new JedisConnectionFactory();
}
}
@Retention(java.lang.annotation.RetentionPolicy.RUNTIME)
@Target({ java.lang.annotation.ElementType.TYPE })
@Documented
@Import(RedisHttpSessionConfiguration.class)
@Configuration
public @interface EnableRedisHttpSession {
int maxInactiveIntervalInSeconds() default 1800;
//为键定义唯一的命名空间。
//该值用于通过将前缀从"spring:session:"更改为"spring:session::"来隔离会话。
//默认值为"",以便所有Redis键以"spring:session"开头。
//例如,如果您有一个名为“Application A”的应用程序需要将会话与“Application B”隔离,则可以为应用程序设置两个不同的值,它们可以在同一个Redis实例中运行。
String redisNamespace() default "";
//设置Redis会话的刷新模式。
//默认值为ON_SAVE,仅在调用SessionRepository.save(Session)时更新后备Redis。
//在Web环境中,这发生在提交HTTP响应之前。
//将值设置为IMMEDIATE将确保会话的任何更新立即写入Redis实例。
RedisFlushMode redisFlushMode() default RedisFlushMode.ON_SAVE;
}
@EnableRedisHttpSession的主要作用就是导入一个Configuration类RedisHttpSessionConfiguration。
RedisHttpSessionConfiguration继承于SpringHttpSessionConfiguration所以就已经配置了SessionRepositoryFilter,而此类本身提供SessionRepository的bean配置。
@Bean
public RedisOperationsSessionRepository sessionRepository(
@Qualifier("sessionRedisTemplate") RedisOperations
这样一来就不需要开发人员主动配置一个RedisOperationsSessionRepository,但是这个配置需要一个RedisOperations,而这个RedisOperations也是定义在这个类中的。
@Bean
public RedisTemplate
而这个RedisTemplate依赖一个RedisConnectionFactory是需要开发人员配置的。如果我们使用spring-boot,只需要指定application.properties的spring.redis.cluster.nodes即可为我配置一个redis集群JedisConnectionFactory。具体请参考org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration.RedisConnectionConfiguration