【新年好】为什么要 spring-session?

前言

在开始 spring-session 揭秘之前,先做下热脑(活动活动脑子)运动。主要从以下三个方面进行热脑:

  1. 为什么要 spring-session

  2. 比较 traditional-session 方案和 spring-session 方案

  3. JSR340 规范与 spring-session 的透明继承

一. 为什么要 spring-session

在传统单机 web 应用中,一般使用 tomcat/jetty 等 web 容器时,用户的 session 都是由容器管理。浏览器使用 cookie 中记录 sessionId,容器根据 sessionId 判断用户是否存在会话 session。这里的限制是,session 存储在 web 容器中,被单台服务器容器管理。

但是网站主键演变,分布式应用和集群是趋势(提高性能)。此时用户的请求可能被负载分发至不同的服务器,此时传统的 web 容器管理用户会话 session 的方式即行不通。除非集群或者分布式 web 应用能够共享 session,尽管 tomcat 等支持这样做。但是这样存在以下两点问题:

  • 需要侵入 web 容器,提高问题的复杂

  • web 容器之间共享 session,集群机器之间势必要交互耦合

基于这些,必须提供新的可靠的集群分布式 / 集群 session 的解决方案,突破 traditional-session 单机限制(即 web 容器 session 方式,下面简称 traditional-session),spring-session 应用而生。

二. 比较 traditional-session 方案和 spring-session 方案

下图展示了 traditional-session 和 spring-session 的区别

传统模式中,当 request 进入 web 容器,根据 reqest 获取 session 时,如果 web 容器中存在 session 则返回,如果不存在,web 容器则创建一个 session。然后返回 response 时,将 sessonId 作为 response 的 head 一并返回给客户端或者浏览器。

但是上节中说明了 traditional-session 的局限性在于:单机 session。在此限制的相反面,即将 session 从 web 容器中抽出来,形成独立的模块,以便分布式应用或者集群都能共享,即能解决。

spring-session 的核心思想在于此:将 session 从 web 容器中剥离,存储在独立的存储服务器中。目前支持多种形式的 session 存储器:Redis、Database、MogonDB 等。session 的管理责任委托给 spring-session 承担。当 request 进入 web 容器,根据 request 获取 session 时,由 spring-session 负责存存储器中获取 session,如果存在则返回,如果不存在则创建并持久化至存储器中。

三. JSR340 规范与 spring-session 的透明继承

JSR340 是 Java Servlet 3.1 的规范提案,其中定义了大量的 api,包括:servlet、servletRequest/HttpServletRequest/HttpServletRequestWrapper、servletResponse/HttpServletResponse/HttpServletResponseWrapper、Filter、Session 等,是标准的 web 容器需要遵循的规约,如 tomcat/jetty/weblogic 等等。

在日常的应用开发中,develpers 也在频繁的使用 servlet-api,比如:

以下的方式获取请求的 session:

HttpServletRequest request = ...
HttpSession session = request.getSession(false);

其中 HttpServletRequest 和 HttpSession 都是 servlet 规范中定义的接口,web 容器实现的标准。那如果引入 spring-session,要如何获取 session?

  • 遵循 servlet 规范,同样方式获取 session,对应用代码无侵入且对于 developers 透明化

  • 全新实现一套 session 规范,定义一套新的 api 和 session 管理机制

两种方案都可以实现,但是显然第一种更友好,且具有兼容性。spring-session 正是第一种方案的实现。

实现第一种方案的关键点在于做到透明和兼容

  • 接口适配:仍然使用 HttpServletRequest 获取 session,获取到的 session 仍然是 HttpSession 类型——适配器模式

  • 类型包装增强:Session 不能存储在 web 容器内,要外化存储——装饰模式

让人兴奋的是,以上的需求在 Servlet 规范中的扩展性都是予以支持!Servlet 规范中定义一系列的接口都是支持扩展,同时提供 Filter 支撑扩展点。建议阅读《JavaTM Servlet Specification》。

热脑活动结束,下面章节正式进入今天的主题:spring-session 揭秘

Spring Session 探索

主要从以下两个方面来说 spring-session:

  • 特点

  • 工作原理

一. 特点

spring-session 在无需绑定 web 容器的情况下提供对集群 session 的支持。并提供对以下情况的透明集成:

  • HttpSession:容许替换 web 容器的 HttpSession

  • WebSocket:使用 WebSocket 通信时,提供 Session 的活跃

  • WebSession:容许以应用中立的方式替换 webflux 的 webSession

二. 工作原理

再详细阅读源码之前先来看张图,介绍下 spring-session 中的核心模块以及之间的交互。

spring-session 分为以下核心模块:

  • SessionRepositoryFilter:Servlet 规范中 Filter 的实现,用来切换 HttpSession 至 Spring Session,包装 HttpServletRequest 和 HttpServletResponse

  • HttpServerletRequest/HttpServletResponse/HttpSessionWrapper 包装器:包装原有的 HttpServletRequest、HttpServletResponse 和 Spring Session,实现切换 Session 和透明继承 HttpSession 的关键之所在

  • Session:Spring Session 模块

  • SessionRepository:管理 Spring Session 的模块

  • HttpSessionStrategy:映射 HttpRequst 和 HttpResponse 到 Session 的策略

1. SessionRepositoryFilter

SessionRepositoryFilter 是一个 Filter 过滤器,符合 Servlet 的规范定义,用来修改包装请求和响应。这里负责包装切换 HttpSession 至 Spring Session 的请求和响应。


@Override
protected void doFilterInternal(HttpServletRequest request,
  HttpServletResponse response, FilterChain filterChain)
    throws ServletException, IOException {
 // 设置SessionRepository至Request的属性中
 request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);
 // 包装原始HttpServletRequest至SessionRepositoryRequestWrapper
 SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryRequestWrapper(
   request, response, this.servletContext);
 // 包装原始HttpServletResponse响应至SessionRepositoryResponseWrapper
 SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryResponseWrapper(
   wrappedRequest, response);
 // 设置当前请求的HttpSessionStrategy策略
 HttpServletRequest strategyRequest = this.httpSessionStrategy
   .wrapRequest(wrappedRequest, wrappedResponse);
 // 设置当前响应的HttpSessionStrategy策略
 HttpServletResponse strategyResponse = this.httpSessionStrategy
   .wrapResponse(wrappedRequest, wrappedResponse);
 try {
  filterChain.doFilter(strategyRequest, strategyResponse);
 }
 finally {
     // 提交session
  wrappedRequest.commitSession();
 }
}

以上是 SessionRepositoryFilter 的核心操作,每个 HttpRequest 进入,都会被该 Filter 包装成切换 Session 的请求很响应对象。

Tips:责任链模式 Filter 是 Servlet 规范中的非常重要的组件,在 tomcat 的实现中使用了责任链模式,将多个 Filter 组织成链式调用。Filter 的作用就是在业务逻辑执行前后对请求和响应做修改配置。配合 HttpServletRequestWrapper 和 HttpServletResponseWrapper 使用,可谓威力惊人!

2. SessionRepositoryRequestWrapper

对于 developers 获取 HttpSession 的 api

HttpServletRequest request = ...;
HttpSession session = request.getSession(true);

在 spring session 中 request 的实际类型 SessionRepositoryRequestWrapper。调用 SessionRepositoryRequestWrapper 的 getSession 方法会触发创建 spring session,而非 web 容器的 HttpSession。

SessionRepositoryRequestWrapper 用来包装原始的 HttpServletRequest 实现 HttpSession 切换至 Spring Session。是透明 Spring Session 透明集成 HttpSession 的关键。

private final class SessionRepositoryRequestWrapper
   extends HttpServletRequestWrapper {

 private final String CURRENT_SESSION_ATTR = HttpServletRequestWrapper.class
    .getName();

 // 当前请求sessionId有效
 private Boolean requestedSessionIdValid;
 // 当前请求sessionId无效
 private boolean requestedSessionInvalidated;
 private final HttpServletResponse response;
 private final ServletContext servletContext;

 private SessionRepositoryRequestWrapper(HttpServletRequest request,
   HttpServletResponse response, ServletContext servletContext) {
  // 调用HttpServletRequestWrapper构造方法,实现包装
  super(request);
  this.response = response;
  this.servletContext = servletContext;
 }
}

SessionRepositoryRequestWrapper 继承 Servlet 规范中定义的包装器 HttpServletRequestWrapper。HttpServletRequestWrapper 是 Servlet 规范 api 提供的用于扩展 HttpServletRequest 的扩张点——即装饰器模式,可以通过重写一些 api 达到功能点的增强和自定义。

Tips:装饰器模式 装饰器模式(包装模式)是对功能增强的一种绝佳模式。实际利用的是面向对象的多态性实现扩展。Servlet 规范中开放此 HttpServletRequestWrapper 接口,是让 developers 自行扩展实现。这种使用方式和 jdk 中的 FilterInputStream/FilterInputStream 如出一辙。

HttpServletRequestWrapper 中持有一个 HttpServletRequest 对象,然后实现 HttpServletRequest 接口的所有方法,所有方法实现中都是调用持有的 HttpServletRequest 对象的相应的方法。继承 HttpServletRequestWrapper 可以对其重写。SessionRepositoryRequestWrapper 继承 HttpServletRequestWrapper,在构造方法中将原有的 HttpServletRequest 通过调用 super 完成对 HttpServletRequestWrapper 中持有的 HttpServletRequest 初始化赋值,然后重写和 session 相关的方法。这样就保证 SessionRepositoryRequestWrapper 的其他方法调用都是使用原有的 HttpServletRequest 的数据,只有 session 相关的是重写的逻辑。

Tips:这里的设计是否很精妙!一切都多亏与 Servlet 规范设计的的巧妙啊!

@Override
public HttpSessionWrapper getSession() {
 return getSession(true);
}

重写 HttpServletRequest 的 getSession() 方法,调用有参数 getSession(arg) 方法,默认为 true,表示当前 reques 没有 session 时创建 session。继续看下有参数 getSession(arg) 的重写逻辑.

@Override
public HttpSessionWrapper getSession(boolean create) {
 // 从当前请求的attribute中获取session,如果有直接返回
 HttpSessionWrapper currentSession = getCurrentSession();
 if (currentSession != null) {
  return currentSession;
 }

 // 获取当前request的sessionId,这里使用了HttpSessionStrategy
 // 决定怎样将Request映射至Session,默认使用Cookie策略,即从cookies中解析sessionId
 String requestedSessionId = getRequestedSessionId();
 // 请求的如果sessionId存在且当前request的attribute中的没有session失效属性
 // 则根据sessionId获取spring session
 if (requestedSessionId != null
   && getAttribute(INVALID_SESSION_ID_ATTR) == null) {
  S session = getSession(requestedSessionId);
  // 如果spring session不为空,则将spring session包装成HttpSession并
  // 设置到当前Request的attribute中,防止同一个request getsession时频繁的到存储器
  //中获取session,提高性能
  if (session != null) {
   this.requestedSessionIdValid = true;
   currentSession = new HttpSessionWrapper(session, getServletContext());
   currentSession.setNew(false);
   setCurrentSession(currentSession);
   return currentSession;
  }
  // 如果根据sessionId,没有获取到session,则设置当前request属性,此sessionId无效
  // 同一个请求中获取session,直接返回无效
  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");
  }
 }
 // 判断是否创建session
 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)"));
 }
 // 根据sessionRepository创建spring session
 S session = SessionRepositoryFilter.this.sessionRepository.createSession();
 // 设置session的最新访问时间
 session.setLastAccessedTime(System.currentTimeMillis());
 // 包装成HttpSession透明化集成
 currentSession = new HttpSessionWrapper(session, getServletContext());
 // 设置session至Requset的attribute中,提高同一个request访问session的性能
 setCurrentSession(currentSession);
 return currentSession;
}

再来看下 spring session 的持久化。上述 SessionRepositoryFilter 在包装 HttpServletRequest 后,执行 FilterChain 中使用 finally 保证请求的 Session 始终 session 会被提交,此提交操作中将 sesionId 设置到 response 的 head 中并将 session 持久化至存储器中。

持久化只持久 spring session,并不是将 spring session 包装后的 HttpSession 持久化,因为 HttpSession 不过是包装器,持久化没有意义。

/**
 * Uses the HttpSessionStrategy to write the session id to the response and
 * persist the Session.
 */
private void commitSession() {
 // 获取当前session
 HttpSessionWrapper wrappedSession = getCurrentSession();
 // 如果当前session为空,则删除cookie中的相应的sessionId
 if (wrappedSession == null) {
  if (isInvalidateClientSession()) {
   SessionRepositoryFilter.this.httpSessionStrategy
     .onInvalidateSession(this, this.response);
  }
 }
 else {
  // 从HttpSession中获取当前spring session
  S session = wrappedSession.getSession();
  // 持久化spring session至存储器
  SessionRepositoryFilter.this.sessionRepository.save(session);
  // 如果是新创建spring session,sessionId到response的cookie
  if (!isRequestedSessionIdValid()
    || !session.getId().equals(getRequestedSessionId())) {
   SessionRepositoryFilter.this.httpSessionStrategy.onNewSession(session,
     this, this.response);
  }
 }
}

再来看下包装的响应 SessionRepositoryResponseWrapper。

3.SessionRepositoryResponseWrapper

/**
 * Allows ensuring that the session is saved if the response is committed.
 *
 * @author Rob Winch
 * @since 1.0
 */
private final class SessionRepositoryResponseWrapper
  extends OnCommittedResponseWrapper {
 private final SessionRepositoryRequestWrapper request;
 /**
  * Create a new {@link SessionRepositoryResponseWrapper}.
  * @param request the request to be wrapped
  * @param response the response to be wrapped
  */
 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();
 }
}

上面的注释已经非常详细,这里不再赘述。这里只讲述为什么需要包装原始的响应。从注释上可以看出包装响应时为了:确保如果响应被提交 session 能够被保存。

这里我有点疑惑:在上述的 SessionRepositoryFilter.doFilterInternal 方法中不是已经 request.commitSession() 了吗,FilterChain 执行完或者异常后都会执行 Finally 中的 request.commitSession。为什么这里仍然需要包装响应,为了确保 session 能够保存,包装器中的 onResponseCommitted 方法可以看出也是做了一次 request.commitSession()。难道这不是多此一举?

Tips 如果有和我相同疑问的同学,那就说明我们的基础都不扎实,对 Servlet 仍然没有一个清楚全面的认识。对于此问题,我特意在 github 上提了 issuse:Why is the request.commitSession() method called repeatedly?。

但是在提完 issue 后的回家路上,我思考了下 response 可以有流方式的写,会不会在 response.getOutStream 写的时候已经将响应全部返回到客户端,这时响应结束。

在家中是,spring sesion 作者大大已经回复了我的 issue:

Is this causing you problems? The reason is that we need to ensure that the session is created before the response is committed. If the response is already committed there will be no way to track the session (i.e. a cookie cannot be written to the response to keep track of which session id).

他的意思是:我们需要在 response 被提交之前确保 session 被创建。如果 response 已经被提交,将没有办法追踪 session(例如:无法将 cookie 写入 response 以跟踪哪个 session id)。

在此之前我又阅读了 JavaTM Servlet Specification,规范中这样解释 Response 的 flushBuffer 接口:

The isCommitted method returns a boolean value indicating whether any response bytes have been returned to the client. The flushBuffer method forces content in the buffer to be written to the client.

并且看了 ServletResponse 的 flushBuffer 的 javadocs:

/**
 * Forces any content in the buffer to be written to the client. A call to
 * this method automatically commits the response, meaning the status code
 * and headers will be written.
 *
 * @throws IOException if an I/O occurs during the flushing of the response
 *
 * @see #setBufferSize
 * @see #getBufferSize
 * @see #isCommitted
 * @see #reset
 */
public void flushBuffer() throws IOException;

结合以上两点,一旦 response 执行 flushBuffer 方法,迫使 Response 中在 Buffer 中任何数据都会被返回至 client 端。这个方法自动提交响应中的 status code 和 head。那么如果不包装请求,监听 flushBuffer 事件在提交 response 前,将 session 写入 response 和持久化 session,将导致作者大大说的无法追踪 session。

SessionRepositoryResponseWrapper 继承父类 OnCommittedResponseWrapper,其中 flushBuffer 方法如下:

/**
 * Makes sure {@link OnCommittedResponseWrapper#onResponseCommitted()} is invoked
 * before calling the superclass <code>flushBuffer()</code>.
 * @throws IOException if an input or output exception occurred
 */
@Override
public void flushBuffer() throws IOException {
    doOnResponseCommitted();
    super.flushBuffer();
}

/**
 * Calls <code>onResponseCommmitted()</code> with the current contents as long as
 * {@link #disableOnResponseCommitted()} was not invoked.
 */
private void doOnResponseCommitted() {
    if (!this.disableOnCommitted) {
        onResponseCommitted();
        disableOnResponseCommitted();
    }
}

重写 HttpServletResponse 方法,监听 response commit,当发生 response commit 时,可以在 commit 之前写 session 至 response 中并持久化 session。

Tips: spring mvc 中 HttpMessageConverters 使用到的 jackson 即调用了 outstream.flushBuffer(),当使用 @ResponseBody 时。

以上做法固然合理,但是如此重复操作两次 commit,存在两次 persist session? 这个问题后面涉及 SessionRepository 时再详述!

再看 SessionRepository 之前,先来看下 spring session 中的 session 接口。

3.Session 接口

spring-session 和 tomcat 中的 Session 的实现模式上有很大不同,tomcat 中直接对 HttpSession 接口进行实现,而 spring-session 中则抽象出单独的 Session 层接口,让后再使用适配器模式将 Session 适配层 Servlet 规范中的 HttpSession。spring-sesion 中关于 session 的实现和适配整个 UML 类图如下:

Tips:适配器模式 spring-session 单独抽象出 Session 层接口,可以应对多种场景下不同的 session 的实现,然后通过适配器模式将 Session 适配成 HttpSession 的接口,精妙至极!

Session 是 spring-session 对 session 的抽象,主要是为了鉴定用户,为 Http 请求和响应提供上下文过程,该 Session 可以被 HttpSession、WebSocket Session,非 WebSession 等使用。定义了 Session 的基本行为:

  • getId:获取 sessionId

  • setAttribute:设置 session 属性

  • getAttribte:获取 session 属性

ExipringSession:提供 Session 额外的过期特性。定义了以下关于过期的行为:

  • setLastAccessedTime:设置最近 Session 会话过程中最近的访问时间

  • getLastAccessedTime:获取最近的访问时间

  • setMaxInactiveIntervalInSeconds:设置 Session 的最大闲置时间

  • getMaxInactiveIntervalInSeconds:获取最大闲置时间

  • isExpired:判断 Session 是否过期

MapSession:基于 java.util.Map 的 ExpiringSession 的实现

RedisSession:基于 MapSession 和 Redis 的 ExpiringSession 实现,提供 Session 的持久化能力

先来看下 MapSession 的代码源码片段

public final class MapSession implements ExpiringSession, Serializable {
 /**
  * Default {@link #setMaxInactiveIntervalInSeconds(int)} (30 minutes).
  */
 public static final int DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS = 1800;

 private String id;
 private Map<String, Object> sessionAttrs = new HashMap<String, Object>();
 private long creationTime = System.currentTimeMillis();
 private long lastAccessedTime = this.creationTime;

 /**
  * Defaults to 30 minutes.
  */
 private int maxInactiveInterval = DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS;

MapSession 中持有 HashMap 类型的变量 sessionAtts 用于存储 Session 设置属性,比如调用的 setAttribute 方法的 k-v 就存储在该 HashMap 中。这个和 tomcat 内部实现 HttpSession 的方式类似,tomcat 中使用了 ConcurrentHashMap 存储。

其中 lastAccessedTime 用于记录最近的一次访问时间,maxInactiveInterval 用于记录 Session 的最大闲置时间(过期时间 - 针对没有 Request 活跃的情况下的最大时间,即相对于最近一次访问后的最大闲置时间)。

public void setAttribute(String attributeName, Object attributeValue) {
 if (attributeValue == null) {
  removeAttribute(attributeName);
 }
 else {
  this.sessionAttrs.put(attributeName, attributeValue);
 }
}

setAttribute 方法极其简单,null 时就移除 attributeName,否则 put 存储。

重点熟悉 RedisSession 如何实现 Session 的行为:setAttribute、persistence 等。

/**
 * A custom implementation of {@link Session} that uses a {@link MapSession} as the
 * basis for its mapping. It keeps track of any attributes that have changed. When
 * {@link org.springframework.session.data.redis.RedisOperationsSessionRepository.RedisSession#saveDelta()}
 * is invoked all the attributes that have been changed will be persisted.
 *
 * @author Rob Winch
 * @since 1.0
 */
final class RedisSession implements ExpiringSession {
 private final MapSession cached;
 private Long originalLastAccessTime;
 private Map<String, Object> delta = new HashMap<String, Object>();
 private boolean isNew;
 private String originalPrincipalName;

首先看 javadocs,对于阅读源码,学会看 javadocs 非常重要!

基于 MapSession 的基本映射实现的 Session,能够追踪发生变化的所有属性,当调用 saveDelta 方法后,变化的属性将被持久化!

在 RedisSession 中有两个非常重要的成员属性:

  • cached:实际上是一个 MapSession 实例,用于做本地缓存,每次在 getAttribute 时无需从 Redis 中获取,主要为了 improve 性能

  • delta:用于跟踪变化数据,做持久化

再来看下 RedisSession 中最为重要的行为 saveDelta——持久化 Session 至 Redis 中:

/**
 * Saves any attributes that have been changed and updates the expiration of this
 * session.
 */
private void saveDelta() {
 // 如果delta为空,则Session中没有任何数据需要存储
 if (this.delta.isEmpty()) {
  return;
 }
 String sessionId = getId();
 // 使用spring data redis将delta中的数据保存至Redis中
 getSessionBoundHashOperations(sessionId).putAll(this.delta);
 String principalSessionKey = getSessionAttrNameKey(
   FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME);
 String securityPrincipalSessionKey = getSessionAttrNameKey(
   SPRING_SECURITY_CONTEXT);
 if (this.delta.containsKey(principalSessionKey)
   || this.delta.containsKey(securityPrincipalSessionKey)) {
  if (this.originalPrincipalName != null) {
   String originalPrincipalRedisKey = getPrincipalKey(
     this.originalPrincipalName);
   RedisOperationsSessionRepository.this.sessionRedisOperations
     .boundSetOps(originalPrincipalRedisKey).remove(sessionId);
  }
  String principal = PRINCIPAL_NAME_RESOLVER.resolvePrincipal(this);
  this.originalPrincipalName = principal;
  if (principal != null) {
   String principalRedisKey = getPrincipalKey(principal);
   RedisOperationsSessionRepository.this.sessionRedisOperations
     .boundSetOps(principalRedisKey).add(sessionId);
  }
 } 
 // 清空delta,代表没有任何需要持久化的数据。同时保证
 //SessionRepositoryFilter和SessionRepositoryResponseWrapper的onResponseCommitted
 //只会持久化一次Session至Redis中,解决前面提到的疑问
 this.delta = new HashMap<String, Object>(this.delta.size());  
 // 更新过期时间,滚动至下一个过期时间间隔的时刻
 Long originalExpiration = this.originalLastAccessTime == null ? null
   : this.originalLastAccessTime + TimeUnit.SECONDS
     .toMillis(getMaxInactiveIntervalInSeconds());
 RedisOperationsSessionRepository.this.expirationPolicy
   .onExpirationUpdated(originalExpiration, this);
}

从 javadoc 中可以看出,saveDelta 用于存储 Session 的属性:

  1. 保存 Session 中的属性数据至 Redis 中

  2. 清空 delta 中数据,防止重复提交 Session 中的数据

  3. 更新过期时间至下一个过期时间间隔的时刻

再看下 RedisSession 中的其他行为

// 设置session的存活时间,即最大过期时间。先保存至本地缓存,然后再保存至delta
public void setMaxInactiveIntervalInSeconds(int interval) {
 this.cached.setMaxInactiveIntervalInSeconds(interval);
 this.delta.put(MAX_INACTIVE_ATTR, getMaxInactiveIntervalInSeconds());
 flushImmediateIfNecessary();
}

// 直接从本地缓存获取过期时间
public int getMaxInactiveIntervalInSeconds() {
 return this.cached.getMaxInactiveIntervalInSeconds();
}

// 直接从本地缓存中获取Session中的属性
@SuppressWarnings("unchecked")
public Object getAttribute(String attributeName) {
 return this.cached.getAttribute(attributeName);
}

// 保存Session属性至本地缓存和delta中
public void setAttribute(String attributeName, Object attributeValue) {
 this.cached.setAttribute(attributeName, attributeValue);
 this.delta.put(getSessionAttrNameKey(attributeName), attributeValue);
 flushImmediateIfNecessary();
}

除了 MapSession 和 RedisSession 还有 JdbcSession、MongoExpiringSession,感兴趣的读者可以自行阅读。

下面看 SessionRepository 的逻辑。SessionRepository 是 spring session 中用于管理 spring session 的核心组件。

4. SessionRepository

A repository interface for managing {@link Session} instances.

javadoc 中描述 SessionRepository 为管理 spring-session 的接口实例。抽象出:

S createSession();
void save(S session);
S getSession(String id);
void delete(String id);

创建、保存、获取、删除 Session 的接口行为。根据 Session 的不同,分为很多种 Session 操作仓库。

这里重点介绍下 RedisOperationsSessionRepository。在详细介绍其之前,了解下 RedisOperationsSessionRepository 的数据存储细节。

当创建一个 RedisSession,然后存储在 Redis 中时,RedisSession 的存储细节如下:

spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe spring:session:expirations:1439245080000

Redis 会为每个 RedisSession 存储三个 k-v。

  1. 第一个 k-v 用来存储 Session 的详细信息,包括 Session 的过期时间间隔、最近的访问时间、attributes 等等。这个 k 的过期时间为 Session 的最大过期时间 + 5 分钟。如果默认的最大过期时间为 30 分钟,则这个 k 的过期时间为 35 分钟

  2. 第二个 k-v 用来表示 Session 在 Redis 中的过期,这个 k-v 不存储任何有用数据,只是表示 Session 过期而设置。这个 k 在 Redis 中的过期时间即为 Session 的过期时间间隔

  3. 第三个 k-v 存储这个 Session 的 id,是一个 Set 类型的 Redis 数据结构。这个 k 中的最后的 1439245080000 值是一个时间戳,根据这个 Session 过期时刻滚动至下一分钟而计算得出。

这里不由好奇,为什么一个 RedisSession 却如此复杂的存储。关于这个可以参考 spring-session 作者本人在 github 上的两篇回答:

Why does Spring Session use spring:session:expirations?

Clarify Redis expirations and cleanup task

简单描述下,为什么 RedisSession 的存储用到了三个 Key,而非一个 Redis 过期 Key。对于 Session 的实现,需要支持 HttpSessionEvent,即 Session 创建、过期、销毁等事件。当应用用监听器设置监听相应事件,Session 发生上述行为时,监听器能够做出相应的处理。Redis 的强大之处在于支持 KeySpace Notifiction——键空间通知。即可以监视某个 key 的变化,如删除、更新、过期。当 key 发生上述行为是,以便可以接受到变化的通知做出相应的处理。具体详情可以参考:Redis Keyspace Notifications

但是 Redis 中带有过期的 key 有两种方式:

  • 当访问时发现其过期

  • Redis 后台逐步查找过期键

当访问时发现其过期,会产生过期事件,但是无法保证 key 的过期时间抵达后立即生成过期事件。具体可以参考:Timing of expired events

spring-session 为了能够及时的产生 Session 的过期时的过期事件,所以增加了:

spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe spring:session:expirations:1439245080000

spring-session 中有个定时任务,每个整分钟都会查询相应的 spring:session:expirations: 整分钟的时间戳中的过期 SessionId,然后再访问一次这个 SessionId,即 spring:session:sessions:expires:SessionId,以便能够让 Redis 及时的产生 key 过期事件——即 Session 过期事件。

接下来再看下 RedisOperationsSessionRepository 中的具体实现原理

createSession 方法:

public RedisSession createSession() {
 // new一个RedisSession实例
 RedisSession redisSession = new RedisSession();
 // 如果设置的最大过期时间不为空,则设置RedisSession的过期时间
 if (this.defaultMaxInactiveInterval != null) {
  redisSession.setMaxInactiveIntervalInSeconds(this.defaultMaxInactiveInterval);
 }
 return redisSession;
}

再来看下 RedisSession 的构造方法:

/**
 * Creates a new instance ensuring to mark all of the new attributes to be
 * persisted in the next save operation.
 */
RedisSession() {
 // 设置本地缓存为MapSession
 this(new MapSession());
 // 设置Session的基本属性
 this.delta.put(CREATION_TIME_ATTR, getCreationTime());
 this.delta.put(MAX_INACTIVE_ATTR, getMaxInactiveIntervalInSeconds());
 this.delta.put(LAST_ACCESSED_ATTR, getLastAccessedTime());
 // 标记Session的是否为新创建
 this.isNew = true;
 // 持久化
 flushImmediateIfNecessary();
}

save 方法:

public void save(RedisSession session) {
 // 调用RedisSession的saveDelta持久化Session
 session.saveDelta();
 // 如果Session为新创建,则发布一个Session创建的事件
 if (session.isNew()) {
  String sessionCreatedKey = getSessionCreatedChannel(session.getId());
  this.sessionRedisOperations.convertAndSend(sessionCreatedKey, session.delta);
  session.setNew(false);
 }
}

getSession 方法:

// 根据SessionId获取Session,这里的false代表的参数
// 指:如果Session已经过期,是否仍然获取返回
public RedisSession getSession(String id) {
 return getSession(id, false);
}

在有些情况下,Session 过期,仍然需要能够获取到 Session。这里先来看下 getSession(String id, boolean allowExpired):

private RedisSession getSession(String id, boolean allowExpired) {
 // 根据SessionId,从Redis获取到持久化的Session信息
 Map<Object, Object> entries = getSessionBoundHashOperations(id).entries();
 // 如果Redis中没有,则返回null
 if (entries.isEmpty()) {
  return null;
 }
 // 根据Session信息,加载创建一个MapSession对象
 MapSession loaded = loadSession(id, entries);
 //  判断是否允许过期获取和Session是否过期
 if (!allowExpired && loaded.isExpired()) {
  return null;
 }
 // 根据MapSession new一个信息的RedisSession,此时isNew为false
 RedisSession result = new RedisSession(loaded);
 // 设置最新的访问时间
 result.originalLastAccessTime = loaded.getLastAccessedTime();
 return result;
}

这里需要注意的是 loaded.isExpired() 和 loadSession。loaded.isExpired 判断 Session 是否过期,如果过期返回 null:

public boolean isExpired() {
 // 根据当前时间判断是否过期
 return isExpired(System.currentTimeMillis());
}
boolean isExpired(long now) {
 // 如果maxInactiveInterval小于0,表示Session永不过期
 if (this.maxInactiveInterval < 0) {
  return false;
 }
 // 最大过期时间单位转换为毫秒
 // 当前时间减去Session的最大有效期间隔以获取理论上有效的上一次访问时间
 // 然后在与实际的上一次访问时间进行比较
 // 如果大于,表示理论上的时间已经在实际的访问时间之后,那么表示Session已经过期
 return now - TimeUnit.SECONDS
   .toMillis(this.maxInactiveInterval) >= this.lastAccessedTime;
}

loadSession 中,将 Redis 中存储的 Session 信息转换为 MapSession 对象,以便从 Session 中获取属性时能够从内存直接获取提高性能:

private MapSession loadSession(String id, Map<Object, Object> entries) {
 MapSession loaded = new MapSession(id);
 for (Map.Entry<Object, Object> entry : entries.entrySet()) {
  String key = (String) entry.getKey();
  if (CREATION_TIME_ATTR.equals(key)) {
   loaded.setCreationTime((Long) entry.getValue());
  }
  else if (MAX_INACTIVE_ATTR.equals(key)) {
   loaded.setMaxInactiveIntervalInSeconds((Integer) entry.getValue());
  }
  else if (LAST_ACCESSED_ATTR.equals(key)) {
   loaded.setLastAccessedTime((Long) entry.getValue());
  }
  else if (key.startsWith(SESSION_ATTR_PREFIX)) {
   loaded.setAttribute(key.substring(SESSION_ATTR_PREFIX.length()),
     entry.getValue());
  }
 }
 return loaded;
}

至此,可以看出 spring-session 中 request.getSession(false) 的过期实现原理。

delete 方法:

public void delete(String sessionId) {
 // 获取Session
 RedisSession session = getSession(sessionId, true);
 if (session == null) {
  return;
 }
 cleanupPrincipalIndex(session);
 // 从过期集合中移除sessionId
 this.expirationPolicy.onDelete(session);
 String expireKey = getExpiredKey(session.getId());
 // 删除session的过期键
 this.sessionRedisOperations.delete(expireKey);
 // 设置session过期
 session.setMaxInactiveIntervalInSeconds(0);
 save(session);
}

至此 RedisOperationsSessionRepository 的核心原理就介绍完毕。但是 RedisOperationsSessionRepository 中还包括关于 Session 事件的处理和清理 Session 的定时任务。这部分内容在后述的 SessionEvent 部分介绍。

5. HttpSessionStrategy

A strategy for mapping HTTP request and responses to a {@link Session}.

从 javadoc 中可以看出,HttpSessionStrategy 是建立 Request/Response 和 Session 之间的映射关系的策略。

Tips:策略模式 策略模式是一个传神的神奇模式,是 java 的多态非常典型应用,是开闭原则、迪米特法则的具体体现。将同类型的一系列的算法封装在不同的类中,通过使用接口注入不同类型的实现,以达到的高扩展的目的。一般是定义一个策略接口,按照不同的场景实现各自的策略。

该策略接口中定义一套策略行为:

// 根据请求获取SessionId,即建立请求至Session的映射关系
String getRequestedSessionId(HttpServletRequest request);
// 对于新创建的Session,通知客户端
void onNewSession(Session session, HttpServletRequest request,
   HttpServletResponse response);
// 对于session无效,通知客户端
void onInvalidateSession(HttpServletRequest request, HttpServletResponse response);

如下 UML 类图:

这里主要介绍 CookieHttpSessionStrategy,这个也是默认的策略,可以查看 spring-session 中类 SpringHttpSessionConfiguration,在注册 SessionRepositoryFilter Bean 时默认采用 CookieHttpSessionStrategy:

@Bean
public <S extends ExpiringSession> SessionRepositoryFilter<? extends ExpiringSession> springSessionRepositoryFilter(
  SessionRepository<S> sessionRepository) {
 SessionRepositoryFilter<S> sessionRepositoryFilter = new SessionRepositoryFilter<S>(
   sessionRepository);
 sessionRepositoryFilter.setServletContext(this.servletContext);
 if (this.httpSessionStrategy instanceof MultiHttpSessionStrategy) {
  sessionRepositoryFilter.setHttpSessionStrategy(
    (MultiHttpSessionStrategy) this.httpSessionStrategy);
 }
 else {
  sessionRepositoryFilter.setHttpSessionStrategy(this.httpSessionStrategy);
 }
 return sessionRepositoryFilter;
}

下面来分析 CookieHttpSessionStrategy 的原理。该策略使用 Cookie 来映射 Request/Response 至 Session。即 request/requset 的 head 中 cookie 存储 SessionId,当请求至 web 服务器,可以解析请求 head 中的 cookie,然后获取 sessionId,根据 sessionId 获取 spring-session。当创建新的 session 或者 session 过期,将相应的 sessionId 写入 response 的 set-cookie 或者从 respose 中移除 sessionId。

getRequestedSessionId 方法

public String getRequestedSessionId(HttpServletRequest request) {
 // 获取当前请求的sessionId:session别名和sessionId映射
 Map<String, String> sessionIds = getSessionIds(request);
 // 获取当前请求的Session别名
 String sessionAlias = getCurrentSessionAlias(request);
 // 获取相应别名的sessionId
 return sessionIds.get(sessionAlias);
}

接下来看下具体获取 SessionIds 的具体过程:

public String getRequestedSessionId(HttpServletRequest request) {
 // 获取当前请求的sessionId:session别名和sessionId映射
 Map<String, String> sessionIds = getSessionIds(request);
 // 获取当前请求的Session别名
 String sessionAlias = getCurrentSessionAlias(request);
 // 获取相应别名的sessionId
 return sessionIds.get(sessionAlias);
}

public Map<String, String> getSessionIds(HttpServletRequest request) {
 // 解析request中的cookie值
 List<String> cookieValues = this.cookieSerializer.readCookieValues(request);
 // 获取sessionId
 String sessionCookieValue = cookieValues.isEmpty() ? ""
   : cookieValues.iterator().next();
 Map<String, String> result = new LinkedHashMap<String, String>();
 // 根据分词器对sessionId进行分割,因为spring-session支持多session。默认情况只有一个session
 StringTokenizer tokens = new StringTokenizer(sessionCookieValue, this.deserializationDelimiter);
 // 如果只有一个session,则设置默认别名为0
 if (tokens.countTokens() == 1) {
  result.put(DEFAULT_ALIAS, tokens.nextToken());
  return result;
 }
 // 如果有多个session,则建立别名和sessionId的映射
 while (tokens.hasMoreTokens()) {
  String alias = tokens.nextToken();
  if (!tokens.hasMoreTokens()) {
   break;
  }
  String id = tokens.nextToken();
  result.put(alias, id);
 }
 return result;
}

public List<String> readCookieValues(HttpServletRequest request) {
 // 获取request的cookie
 Cookie[] cookies = request.getCookies();
 List<String> matchingCookieValues = new ArrayList<String>();
 if (cookies != null) {
  for (Cookie cookie : cookies) {
   // 如果是以SESSION开头,则表示是SessionId,毕竟cookie不只有sessionId,还有可能存储其他内容
   if (this.cookieName.equals(cookie.getName())) {
    // 决策是否需要base64 decode
    String sessionId = this.useBase64Encoding
      ? base64Decode(cookie.getValue()) : cookie.getValue();
    if (sessionId == null) {
     continue;
    }
    if (this.jvmRoute != null && sessionId.endsWith(this.jvmRoute)) {
     sessionId = sessionId.substring(0,
       sessionId.length() - this.jvmRoute.length());
    }
    // 存入list中
    matchingCookieValues.add(sessionId);
   }
  }
 }
 return matchingCookieValues;
}

再来看下获取当前 request 对应的 Session 的别名方法 getCurrentSessionAlias

public String getCurrentSessionAlias(HttpServletRequest request) {
 // 如果session参数为空,则返回默认session别名
 if (this.sessionParam == null) {
  return DEFAULT_ALIAS;
 }
 // 从request中获取session别名,如果为空则返回默认别名
 String u = request.getParameter(this.sessionParam);
 if (u == null) {
  return DEFAULT_ALIAS;
 }
 if (!ALIAS_PATTERN.matcher(u).matches()) {
  return DEFAULT_ALIAS;
 }
 return u;
}

spring-session 为了支持多 session,才弄出多个 session 别名。当时一般应用场景都是一个 session,都是默认的 session 别名 0。

上述获取 sessionId 和别名映射关系中,也是默认别名 0。这里返回别名 0,所以返回当前请求对应的 sessionId。

onNewSession 方法

public void onNewSession(Session session, HttpServletRequest request,
  HttpServletResponse response) {
 // 从当前request中获取已经写入Cookie的sessionId集合
 Set<String> sessionIdsWritten = getSessionIdsWritten(request);
 // 判断是否包含,如果包含,表示该sessionId已经写入过cookie中,则直接返回
 if (sessionIdsWritten.contains(session.getId())) {
  return;
 }
 // 如果没有写入,则加入集合,后续再写入
 sessionIdsWritten.add(session.getId());
 Map<String, String> sessionIds = getSessionIds(request);
 String sessionAlias = getCurrentSessionAlias(request);
 sessionIds.put(sessionAlias, session.getId());
 // 获取cookieValue
 String cookieValue = createSessionCookieValue(sessionIds);
 //将cookieValue写入Cookie中
 this.cookieSerializer
   .writeCookieValue(new CookieValue(request, response, cookieValue));
}

sessionIdsWritten 主要是用来记录已经写入 Cookie 的 SessionId,防止 SessionId 重复写入 Cookie 中。

onInvalidateSession 方法

public void onInvalidateSession(HttpServletRequest request,
  HttpServletResponse response) {
 // 从当前request中获取sessionId和别名映射
 Map<String, String> sessionIds = getSessionIds(request);
 // 获取别名
 String requestedAlias = getCurrentSessionAlias(request);
 // 移除sessionId
 sessionIds.remove(requestedAlias);
 String cookieValue = createSessionCookieValue(sessionIds);
 // 写入移除后的sessionId
 this.cookieSerializer
   .writeCookieValue(new CookieValue(request, response, cookieValue));
}

继续看下具体的写入 writeCookieValue 原理:

public void writeCookieValue(CookieValue cookieValue) {
 // 获取request/respose和cookie值
 HttpServletRequest request = cookieValue.getRequest();
 HttpServletResponse response = cookieValue.getResponse();
 String requestedCookieValue = cookieValue.getCookieValue();
 String actualCookieValue = this.jvmRoute == null ? requestedCookieValue
   : requestedCookieValue + this.jvmRoute;
 // 构造servlet规范中的Cookie对象,注意这里cookieName为:SESSION,表示为Session,
 // 上述的从Cookie中读取SessionId,也是使用该cookieName
 Cookie sessionCookie = new Cookie(this.cookieName, this.useBase64Encoding
   ? base64Encode(actualCookieValue) : actualCookieValue);
 // 设置cookie的属性:secure、path、domain、httpOnly
 sessionCookie.setSecure(isSecureCookie(request));
 sessionCookie.setPath(getCookiePath(request));
 String domainName = getDomainName(request);
 if (domainName != null) {
  sessionCookie.setDomain(domainName);
 }
 if (this.useHttpOnlyCookie) {
  sessionCookie.setHttpOnly(true);
 }
 // 如果cookie值为空,则失效
 if ("".equals(requestedCookieValue)) {
  sessionCookie.setMaxAge(0);
 }
 else {
  sessionCookie.setMaxAge(this.cookieMaxAge);
 }
 // 写入cookie到response中
 response.addCookie(sessionCookie);
}

至此,CookieHttpSessionStrategy 介绍结束。

由于篇幅过长,关于 spring-session event 和 RedisOperationSessionRepository 清理 session 并且产生过期事件的部分后续文章介绍。

总结

spring-session 提供集群环境下 HttpSession 的透明集成。spring-session 的优势在于开箱即用,具有较强的设计模式。且支持多种持久化方式,其中 RedisSession 较为成熟,与 spring-data-redis 整合,可谓威力无穷。


作者:怀瑾握瑜

来源链接:

https://www.cnblogs.com/lxyit/p/9672097.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值