基于 spring-session 解决分布式 session 共享问题

摘要:本文主要研究 基于 spring-seesion 解决分布式 session 的共享问题。首先讲述 session 共享问题的产生背景以及常见的解决方案;然后讲解本文主要研究的 spring-session 的概念和功能;接着演示了 spring-session 的两种管理 sessionid 的实现方案,属于实战内容,需重点掌握;再接着对后台保存数据到 redis 上的数据结构进行了分析;然后对 spring-session 的核心源代码进行了解读,方便理解 spring-session 框架的实现原理;最后列举了在使用 spring-session 的实践过程中可能遇到的问题或坑,重点去理解一下。

直奔主题

HttpSession 是通过 Servlet 容器创建和管理的,像 Tomcat/Jetty 都是保存在内存中的。而如果我们把 web 服务器搭建成分布式的集群,然后利用 LVS 或 Nginx 做负载均衡,那么来自同一用户的 Http 请求将有可能被分发到两个不同的 web 站点中去。那么问题就来了,如何保证不同的 web 站点能够共享同一份 session 数据呢?

最简单的想法将 session 管理从容器中独立出来。而实现方案有很多种,下面简单介绍下:

  • 第一种是使用容器扩展来实现,大家比较容易接受的是通过容器插件来实现,比如基于 Tomcat 的 tomcat-redis-session-manager ,基于 Jetty 的 jetty-session-redis 等等。好处是对项目来说是透明的,无需改动代码。不过前者目前还不支持 Tomcat 8 ,或者说不太完善。但是由于过于依赖容器,一旦容器升级或者更换意味着又得从新来过。并且代码不在项目中,对开发者来说维护也是个问题。
  • 第二种是自己写一套会话管理的工具类,包括 Session 管理和 Cookie 管理,在需要使用会话的时候都从自己的工具类中获取,而工具类后端存储可以放到 Redis 中。很显然这个方案灵活性最大,但开发需要一些额外的时间。并且系统中存在两套 Session 方案,很容易弄错而导致取不到数据。
  • 第三种是使用框架的会话管理工具,也就是如下介绍的 spring-session ,可以理解是替换了 Servlet 那一套会话管理,接管创建和管理 Session 数据的工作。既不依赖容器,又不需要改动代码,并且是用了 spring-data-redis 那一套连接池,可以说是最完美的解决方案。

解决方案之 spring-session

介绍

Spring Session 是 Spring 的项目之一,GitHub地址:https://github.com/spring-pro…

Spring Session 提供了一套创建和管理 Servlet HttpSession 的完美方案。

功能

spring Session 提供了 API 和实现,用于管理用户的 Session 信息。除此之外,它还提供了如下特性:

  • 将 session 所保存的状态卸载到特定的外部 session 存储汇总,如 Redis 中,他们能够以独立于应用服务器的方式提供高质量的集群。
  • 控制 sessionid 如何在客户端和服务器之间进行交换,这样的话就能很容易地编写 Restful API ,因为它可以从 HTTP 头信息中获取 sessionid ,而不必再依赖于 cookie。
  • 在非 Web 请求的处理代码中,能够访问 session 数据,比如在 JMS 消息的处理代码中。
  • 支持每个浏览器上使用多个 session,从而能够很容易地构建更加丰富的终端用户体验。
  • 当用户使用 WebSocket 发送请求的时候,能够保持 HttpSession 处于活跃状态。

方案一由 cookie 管理 sessionid

在 maven 中添加如下依赖

<!-- spring-session-data-redis 是一个空的包,仅仅只有一个 META-INF 文件夹。它的作用就在于引入如下四个          
包 spring-data-redis,jedis,spring-session,commons-pool2
-->
  <dependency>
      <groupId>org.springframework.session</groupId>
      <artifactId>spring-session-data-redis</artifactId>
      <version>1.0.1.RELEASE</version>
  </dependency>
  <dependency>
    <groupId>org.springframework.data</groupId>
    <artifactId>spring-data-redis</artifactId>
    <version>1.6.1.RELEASE</version>
  </dependency>
  <dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>2.5.2</version>
  </dependency> 
  <dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session</artifactId>
    <version>1.1.0.RELEASE</version>
  </dependency>
  <dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
    <version>2.2</version>
  </dependency>

在 spring 配置文件中添加如下配置

<!-- redis 的 bean 配置如下 -->
<bean id="jedisPoolConfig" class="redis.clients.jedis.JedisPoolConfig"/>

<bean id="jedisConnectionFactory" class="org.springframework.data.redis.connection.jedis.JedisConnectionFactory">
    <property name="hostName" value="127.0.0.1" />
    <property name="port" value="6379" />
    <property name="password" value="" />
    <property name="timeout" value="3600" />
    <property name="poolConfig" ref="jedisPoolConfig" />
    <property name="usePool" value="true" />
    <property name="database" value="0"/> <!-- 默认存放在0号库中 -->
</bean>

<bean id="redisTemplate" class="org.springframework.data.redis.core.StringRedisTemplate">
    <property name="connectionFactory" ref="jedisConnectionFactory" />
</bean>

<!-- 将 session 放入 redis, spring-session 会使用此 bean -->
<bean id="redisHttpSessionConfiguration"                     class="org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration">                                                                    
    <property name="maxInactiveIntervalInSeconds" value="1800" />
</bean>

​这里前面几个 bean 都是操作 redis 时候使用的,最后一个 bean 才是 spring-session 需要用到的,其中的 id 可以不写或者保持不变,这也是一个约定优先配置的体现。这个 bean 中又会自动产生多个 bean ,用于相关操作,极大的简化了我们的配置项。其中有个比较重要的是 springSessionRepositoryFilter ,它将在下面的代理 filter 中被调用到。maxInactiveIntervalInSeconds 表示超时时间,默认是 1800 秒。上述配置可以采用 xml 来定义,官方文档中有采用注解来声明一个配置类。

在 web.xml 中配置过滤器

  接下来在 web.xml 中添加一个 session 代理 filter ,通过这个 filter 来包装 Servlet 的 getSession() 。需要注意的是这个 filter 需要放在所有 filter 链最前面,从而保证完全替换掉 tomcat 的 session。这个是约定。

<!-- delegatingFilterProxy -->
<filter>
    <filter-name>springSessionRepositoryFilter</filter-name>
    <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
</filter>
<filter-mapping>
    <filter-name>springSessionRepositoryFilter</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>

验证

第一步:编写 Controller 代码

@RequestMapping(value = "user", method = RequestMethod.POST)
public void setUser(HttpSession session) {
    User user = new User();
    user.setName("lyf");
    user.setPassword("123");
    session.setAttribute("user", user);
}
@RequestMapping(value = "user", method = RequestMethod.GET)
public String getUser(HttpSession session) {
    User user = (User) session.getAttribute("user");
    String name = user.getName();
    return "用户名称:" + name;
}

第二步:浏览器中访问 Controller

  • post请求:localhost:8080/training/user

响应头部如下:Response Headers:

Set-Cookie:SESSION=a2c10601-3204-454e-b545-85e84f587045; Path=/training/; HttpOnly
...

会发现浏览器 Cookie 中的 jsessionid 已经替换为 session**

此时使用 redis-cli 到 redis 库中查询如下:

springsession:0>keys *
1) spring:session:sessions:a2c10601-3204-454e-b545-85e84f587045
2) spring:session:expirations:1502595600000
  • get请求:localhost:8080/training/user

请求头部如下:Request Headers:

Cookie:SESSION=a2c10601-3204-454e-b545-85e84f587045;

服务器通过 Cookie 中的 session 识别码从 redis 库中找到了需要的 session 对象并返回,浏览器显示如下:

用户名称:lyf
  • 总结

    通过如上 spring-session 配置即可将其集成到项目中,之后使用的所有有关 session 的操作,都会由 spring-session 来接管创建和信息存取。官方默认 spring-session 中的 session 信息都保存在 redis 数据库中。

    此实现方式弊端:如果浏览器禁用掉了 cookie 或者是非 web 请求时根本没有 cookie 的时候,那么如上通过cookie 管理 sessionid 的实现方式将不能够实现 session 共享。

方案二由 httpheader 管理 sessionid

在 maven 中添加如下依赖

同3.1

在 spring 配置文件中添加如下配置

<!-- redis 的 bean 配置如下 -->
<bean id="jedisPoolConfig" class="redis.clients.jedis.JedisPoolConfig"/>

<!-- 替代默认使用 cookie ,这里使用的是 httpheader -->
<bean id="httpSessonStrategy"          class="org.springframework.session.web.http.HeaderHttpSessionStrategy"/>

<bean id="jedisConnectionFactory" class="org.springframework.data.redis.connection.jedis.JedisConnectionFactory">
      <property name="hostName" value="127.0.0.1" />
      <property name="port" value="6379" />
      <property name="password" value="" />
      <property name="timeout" value="3600" />
      <property name="poolConfig" ref="jedisPoolConfig" />
      <property name="usePool" value="true" />
</bean>

<bean id="redisTemplate" class="org.springframework.data.redis.core.StringRedisTemplate">
       <property name="connectionFactory" ref="jedisConnectionFactory" />
</bean>

<!-- 将 session 放入 redis -->
<bean id="redisHttpSessionConfiguration" class="org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration">
       <property name="maxInactiveIntervalInSeconds" value="1800" />
       <property name="httpSessionStrategy" ref="httpSessonStrategy"/>
</bean>

在 web.xml 中配置过滤器

同3.3

验证

第一步:编写 Controller代码

@RequestMapping(value = "user", method = RequestMethod.POST)
public void setUser(HttpSession session) {
    User user = new User();
    user.setName("lyf");
    user.setPassword("123");
    session.setAttribute("user", user);
}
@RequestMapping(value = "user", method = RequestMethod.GET)
public String getUser(HttpSession session) {
    User user = (User) session.getAttribute("user");
    String name = user.getName();
    return "用户名称:" + name;
}

第二步:浏览器中访问 Controller

  • post 请求:localhost:8080/training/user

响应头部如下:Response Headers:

x-auth-token:256064c7-b583-460f-bbd2-1f6dab3fd418
...

区别 Cookie 的地方在于,这种方式在响应头信息中添加了唯一标识字段 x-auth-token

此时使用 redis-cli 到 redis 库中查询如下:

springsession:0>keys *
1) spring:session:expirations:1502597280000
2) spring:session:sessions:256064c7-b583-460f-bbd2-1f6dab3fd418
  • get 请求:localhost:8080/training/user

    请求头部如下:Response Headers:

    x-auth-token:00ee4b6a-0aeb-42b1-a2bd-eae6f370c677

会发现此时在响应头信息中又重新创建了一个 x-auth-token ,因为 spring-seesion 的底层实现是在请求的时候服务端如果没有拿到这个唯一标识,就会重新创建一个新的 x-auth-token,
并保存到 redis 库中。

此时使用 redis-cli 到 redis 库中查询如下:

springsession:0>keys *
1) spring:session:sessions:00ee4b6a-0aeb-42b1-a2bd-eae6f370c677
2) spring:session:expirations:1502597280000
3) spring:session:sessions:256064c7-b583-460f-bbd2-1f6dab3fd418
4) spring:session:expirations:1502597460000
  • 总结

因此要想获取到 session 中的用户信息,需要将服务端返回的 x-auth-token 唯一标识符附加到 Headers上,然后服务器根据这个唯一标识符才能找到对应的用户信息
在此过程的 get 请求的 Headers 中添加如下键值对:

  x-auth-token:256064c7-b583-460f-bbd2-1f6dab3fd418

服务器通过 Headers 中的 x-auth-token 从 redis 库中找到了需要的 session 对象并返回,浏览器显示如下:

用户名称:lyf

因此:

Spring-session 可以控制客户端和服务器端之间如何进行 sessionid 的交换,这样更加易于编写 Restful API,因为它可以从 HTTP 头信息中获取 sessionid ,而不必再依赖于 cookie 。

spring-session redis 数据结构

创建 spring session

RedisSession 在创建时设置 3 个变量 creationTime ,maxInactiveInterval ,lastAccessedTime 。maxInactiveInterval 默认值为 1800 ,表示 1800s 之内该 session 没有被再次使用,则表明该 session 已过期。每次 session 被访问都会更新 lastAccessedTime 的值, session 的过期计算公式:当前时间-lastAccessedTime > maxInactiveInterval.

/**
* Creates a new instance ensuring to mark all of the new attributes to be
* persisted in the next save operation.
**/
RedisSession() {
    this(new MapSession());
    this.delta.put(CREATION_TIME_ATTR, getCreationTime());
    this.delta.put(MAX_INACTIVE_ATTR, getMaxInactiveIntervalInSeconds());
    this.delta.put(LAST_ACCESSED_ATTR, getLastAccessedTime());
    this.isNew = true;
    this.flushImmediateIfNecessary();
}
public MapSession() {
    this(UUID.randomUUID().toString());
}

flushImmediateIfNecessary 判断 session 是否需要立即写入后端存储。

获取 session

spring session在 redis 里面保存的数据包括:

  • SET 类型的spring:session:expireations:[min]

    min 表示从 1970 年 1 月 1 日 0 点 0 分经过的分钟数, SET 集合的 member 为 expires:[sessionId] ,表示 members 会在 min 分钟后过期。

  • String 类型的spring:session:sessions:expires:[sessionId]

    该数据的 TTL 表示 sessionId 过期的剩余时间,即 maxInactiveInterval。

  • Hash 类型的spring:session:sessions:[sessionId]

    session 保存的数据,记录了 creationTime,maxInactiveInterval,lastAccessedTime,attribute。前两个数据是用于 session 过期管理的辅助数据结构。

获取 session 流程:

​ 应用通过 getSession(boolean create) 方法来获取 session 数据,参数 create 表示 session 不存在时是否创建新的 session 。 getSession 方法首先从请求的 “.CURRENT_SESSION” 属性来获取 currentSession ,没有 currentSession ,则从 request 取出 sessionId ,然后读取 spring:session:sessions:[sessionId] 的值,同时根据 lastAccessedTime 和 MaxInactiveIntervalInSeconds 来判断这个 session 是否过期。如果 request 中没有 sessionId ,说明该用户是第一次访问,会根据不同的实现,如 RedisSession ,MongoExpiringSession ,GemFireSession 等来创建一个新的 session 。

​ 另外, 从 request 取 sessionId 依赖具体的 HttpSessionStrategy 的实现,spring session 给了两个默认的实现 CookieHttpSessionStrategy 和 HeaderHttpSessionStrategy ,即从 cookie 和 header 中取出 sessionId 。

具体的代码实现在第 4 章已经演示了。

session 有效期与删除

spring session 的有效期指的是访问有效期,每一次访问都会更新 lastAccessedTime 的值,过期时间为lastAccessedTime + maxInactiveInterval ,也即在有效期内每访问一次,有效期就向后延长 maxInactiveInterval。

对于过期数据,一般有三种删除策略:

1)定时删除,即在设置键的过期时间的同时,创建一个定时器, 当键的过期时间到来时,立即删除。

2)惰性删除,即在访问键的时候,判断键是否过期,过期则删除,否则返回该键值。

3)定期删除,即每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键。至于要删除多少过期键,以及要检查多少个数据库,则由算法决定。

​ redis 删除过期数据采用的是懒性删除+定期删除组合策略,也就是数据过期了并不会及时被删除。为了实现 session 过期的及时性,spring session 采用了定时删除的策略,但它并不是如上描述在设置键的同时设置定时器,而是采用固定频率(1分钟)轮询删除过期值,这里的删除是惰性删除

​ 轮询操作并没有去扫描所有的 spring:session:sessions:[sessionId] 的过期时间,而是在当前分钟数检查前一分钟应该过期的数据,即 spring:session:expirations:[min] 的 members ,然后 delete 掉 spring:session:expirations:[min] ,惰性删除 spring:session:sessions:expires:[sessionId] 。

​ 还有一点是,查看三个数据结构的TTL时间,spring:session:sessions:[sessionId] 和 spring:session:expirations:[min] 比真正的有效期大 5 分钟,目的是确保当 expire key 数据过期后,监听事件还能获取到 session 保存的原始数据。

@Scheduled(cron = "${spring.session.cleanup.cron.expression:0 * * * * *}")
public void cleanupExpiredSessions() {
    this.expirationPolicy.cleanExpiredSessions();
}
public void cleanExpiredSessions() {
    long now = System.currentTimeMillis();
    long prevMin = roundDownMinute(now);
    // preMin 时间到,将 spring:session:expirations:[min], 
    // set 集合中 members 包括了这一分钟之内需要过期的所有
    // expire key 删掉, member 元素为 expires:[sessionId]
    String expirationKey = getExpirationKey(prevMin);
    Set<Object> sessionsToExpire = this.redis.boundSetOps(expirationKey).members();
    this.redis.delete(expirationKey);
    for (Object session : sessionsToExpire) {
        // sessionKey 为 spring:session:sessions:expires:[sessionId]
        String sessionKey = getSessionKey((String) session);
        // 利用 redis 的惰性删除策略
        touch(sessionKey);
    }
}

​ spring session 在 redis 中保存了三个 key ,为什么? sessions key 记录 session 本身的数据,expires key标记 session 的准确过期时间,expiration key 保证 session 能够被及时删除,spring 监听事件能够被及时处理。

上面的代码展示了 session expires key 如何被删除,那 session 每次都是怎样更新过期时间的呢? 每一次 http 请求,在经过所有的 filter 处理过后,spring session 都会通过 onExpirationUpdated() 方法来更新 session 的过期时间, 具体的操作看下面源码的注释。

public void onExpirationUpdated(Long originalExpirationTimeInMilli,
            ExpiringSession session) {
    String keyToExpire = "expires:" + session.getId();
    long toExpire = roundUpToNextMinute(expiresInMillis(session));
    if (originalExpirationTimeInMilli != null) {
        long originalRoundedUp = roundUpToNextMinute(originalExpirationTimeInMilli);
        // 更新 expirations:[min] ,两个分钟数之内都有这个 session ,将前一个 set 中的成员删除
        if (toExpire != originalRoundedUp) {
            String expireKey = getExpirationKey(originalRoundedUp);
            this.redis.boundSetOps(expireKey).remove(keyToExpire);
        }
    }
    long sessionExpireInSeconds = session.getMaxInactiveIntervalInSeconds();
    String sessionKey = getSessionKey(keyToExpire);
    if (sessionExpireInSeconds < 0) {
        this.redis.boundValueOps(sessionKey).append("");
        this.redis.boundValueOps(sessionKey).persist();
        this.redis.boundHashOps(getSessionKey(session.getId())).persist();
        return;
    }
    String expireKey = getExpirationKey(toExpire);
    BoundSetOperations<Object, Object> expireOperations = this.redis
            .boundSetOps(expireKey);
    expireOperations.add(keyToExpire);
    long fiveMinutesAfterExpires = sessionExpireInSeconds
            + TimeUnit.MINUTES.toSeconds(5);
    // expirations:[min] key 的过期时间加 5 分钟
    expireOperations.expire(fiveMinutesAfterExpires, TimeUnit.SECONDS);
    if (sessionExpireInSeconds == 0) {
        this.redis.delete(sessionKey);
    }
    else {
        // expires:[sessionId] 值为“”,过期时间为 MaxInactiveIntervalInSeconds
        this.redis.boundValueOps(sessionKey).append("");
        this.redis.boundValueOps(sessionKey).expire(sessionExpireInSeconds,
                TimeUnit.SECONDS);
    }
    // sessions:[sessionId] 的过期时间加 5 分钟
    this.redis.boundHashOps(getSessionKey(session.getId()))
            .expire(fiveMinutesAfterExpires, TimeUnit.SECONDS);
}

源码解读

源码架构分析

使用 spring-session 需要解决两个核心问题:

问题一:如何创建集群环境下高可用的 session,要求能够可靠并高效地存储数据

解决:在高可用可扩展的集群中存储数据已经通过各种数据存储方案得到了解决,如 Redis、GemFire 以及 Apache Geode 等等

问题二:如何保证不管请求是 HTTP、WebSocket 等其他协议,服务端都能够获取到 sessionid 来找到对应的资源

解决:Spring Session 认为将请求与特定的 session 实例关联起来的问题是与协议相关的,因为在请求/响应周期中,客户端和服务器之间需要协商同意一种传递 sessionid 的方式。例如,如果请求是通过 HTTP 传递进来的,那么 session 可以通过 HTTP cookie 或 HTTP Header 信息与请求进行关联。如果使用 HTTPS 的话,那么可以借助SSL sessionid 实现请求与 session 的关联。如果使用 JMS 的话,那么 JMS 的 Header 信息能够用来存储请求和响应之间的 sessionid 。

HTTP 支持

Spring Session 对 HTTP 的支持是通过标准的 servlet filter 来实现的,这个 filter 必须要配置为拦截所有的 web 应用请求,并且它应该是 filter 链中的第一个 filter 。Spring Session filter 会确保随后调用javax.servlet.http.HttpServletRequestgetSession()方法时,都会返回 Spring Session 的HttpSession实例,而不是应用服务器默认的 HttpSession。

首先,我们了解一下标准 servlet 扩展点的一些背景知识:

在2001年,Servlet 2.3规范引入了ServletRequestWrapper。官方API中解释,ServletRequestWrapper“提供了ServletRequest接口的便利实现,开发人员如果希望将请求适配到 Servlet 的话,可以编写它的子类。这个类实现了包装(Wrapper)或者说是装饰(Decorator)模式。对方法的调用默认会通过包装的请求对象来执行”。如下的代码样例抽取自 Tomcat,展现了 ServletRequestWrapper 是如何实现的。

public class ServletRequestWrapper implements ServletRequest {

    private ServletRequest request;

    /**
     * 创建 ServletRequest 适配器,它包装了给定的请求对象。
     */
    public ServletRequestWrapper(ServletRequest request)  {
        if (request == null) {
            throw new IllegalArgumentException("Request cannot be null");   
        }
        this.request = request;
    }

    public ServletRequest getRequest() {
        return this.request;
    }
    
    public Object getAttribute(String name) {
        return this.request.getAttribute(name);
    } 
}

Servlet 2.3 规范还定义了HttpServletRequestWrapper,它是ServletRequestWrapper的子类,能够快速提供HttpServletRequest的自定义实现,如下的代码是从 Tomcat 抽取出来的,展现了HttpServletRequesWrapper类是如何运行的。

public class HttpServletRequestWrapper extends ServletRequestWrapper 
    implements HttpServletRequest {

    public HttpServletRequestWrapper(HttpServletRequest request) {
        super(request);
    }
    
    private HttpServletRequest _getHttpServletRequest() {
        return (HttpServletRequest) super.getRequest();
    }
  
    public HttpSession getSession(boolean create) {
     return this._getHttpServletRequest().getSession(create);
    }
   
    public HttpSession getSession() {
      return this._getHttpServletRequest().getSession();
    } 
}

所以,借助这些包装类就能编写代码来扩展HttpServletRequest,重载返回HttpSession的方法,让它返回由外部存储所提供的实现。如下的代码是从 Spring Session 项目中提取出来的。

/*
 * 注意,Spring Session 项目定义了扩展自
 * 标准 HttpServletRequestWrapper 的类,用来重载
 * HttpServletRequestWrapper 中与 session 相关的方法。
 */
private final class SessionRepositoryRequestWrapper
   extends HttpServletRequestWrapper {

   private HttpSessionWrapper currentSession;
   private Boolean requestedSessionIdValid;
   private boolean requestedSessionInvalidated;
   private final HttpServletResponse response;
   private final ServletContext servletContext;

   /*
   * 注意,这个构造器非常简单,它接收稍后会用到的参数,
   * 并且委托给它所扩展的 HttpServletRequestWrapper
   */
   private SessionRepositoryRequestWrapper(
      HttpServletRequest request,
      HttpServletResponse response,
      ServletContext servletContext) {
     super(request);
     this.response = response;
     this.servletContext = servletContext;
   }

   /*
   * 在这里,Spring Session 项目不再将调用委托给
   * 应用服务器,而是实现自己的逻辑,
   * 返回由外部数据存储作为支撑的 HttpSession 实例。
   *
   * @Param create 参数表示 session 不存在时是否创建新的 session
   */
   @Override
   public HttpSession getSession(boolean create) {
     // 检查是否存在 session ,如果存在,则直接返回
     if(currentSession != null) {
       return currentSession;
     }
     // 检查当前的请求中是否存在 sessionid
     String requestedSessionId = getRequestedSessionId();
     if(requestedSessionId != null) {
       // 如果存在 sessionid ,将会根据这个 sessionid,从它的 SessionRepository 中加载 session
       S session = sessionRepository.getSession(requestedSessionId);
       if(session != null) {
         // 封装 session 并返回
         this.requestedSessionIdValid = true;
         currentSession = new HttpSessionWrapper(session, getServletContext());
         currentSession.setNew(false);
         return currentSession;
       }
     }
     if(!create) {
       return null;
     }
     // session repository 中没有 session ,并且在当前请求中也没有与之关联的 sessoinid,
     // 那么就创建一个新的 session ,并将其持久化到 session repository 中
     S session = sessionRepository.createSession();
     currentSession = new HttpSessionWrapper(session, getServletContext());
     return currentSession;
   }

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

Spring Session 定义了SessionRepositoryFilter,它实现了 Servlet Filter接口。如下是抽取了这个 filter的关键部分

/*
 * SessionRepositoryFilter 只是一个标准的 ServletFilter,
 * 它的实现扩展了一个 helper 基类。
 */
public class SessionRepositoryFilter < S extends ExpiringSession >
    extends OncePerRequestFilter {

    /*
     * 这个方法是魔力真正发挥作用的地方。这个方法相当于重写了doFilter,
     * 创建了我们上文所述的封装请求对象和
     * 一个封装的响应对象,然后调用其余的 filter 链。
     * 这里,关键在于当这个 filter 后面的应用代码执行时,
     * 如果要获得 session 的话,得到的将会是 Spring Session 的
     * HttpServletSession 实例,它是由后端的外部数据存储作为支撑的。
     */
    protected void doFilterInternal(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain filterChain) throws ServletException, IOException {

        request.setAttribute(SESSION_REPOSITORY_ATTR, sessionRepository);

        SessionRepositoryRequestWrapper wrappedRequest =
          new SessionRepositoryRequestWrapper(request,response,servletContext);

        SessionRepositoryResponseWrapper wrappedResponse =
          new SessionRepositoryResponseWrapper(wrappedRequest, response);

        HttpServletRequest strategyRequest =
             httpSessionStrategy.wrapRequest(wrappedRequest, wrappedResponse);

        HttpServletResponse strategyResponse =
             httpSessionStrategy.wrapResponse(wrappedRequest, wrappedResponse);

        try {
            filterChain.doFilter(strategyRequest, strategyResponse);
        } finally {
            wrappedRequest.commitSession();
        }
    }
}

总结:通过对 spring-session 核心源码的分析得到的关键信息是,Spring Session 对 HTTP 的支持所依靠的是一个简单老式的ServletFilter,借助 servlet 规范中标准的特性来实现 Spring Session 的功能。因此,我们能够让已有的 war 文件使用 Spring Session 的功能,而无需修改已有的代码。

注意事项

  • 如上实现方式都是基于 xml 方式来配置的,官方也有通过注解方式来配置的
  • spring-session 要求 Redis 版本在2.8及以上
  • Spring Session 的核心项目并不依赖于Spring框架,所以,我们甚至能够将其应用于不使用 Spring 框架的项目中,只是需要引入 spring 常用的包,包括 spring-beans, spring-core, spring-tx 等,版本需在 3.2.9 及以上。但是当我们项目使用了 spring 的时候,版本需在 3.2.9 及以上。
  • 默认情况下,session 存储在 redis 的 key 是“spring:session::”,但如果有多个系统同时使用一个 redis,则会冲突,此时应该配置 redisNamespace 值,配置后,其 key 为 spring:session:devlops:keyName

    • 配置 redisNamesapce 的方式,在之前配置文件的 bean 中添加一个属性即可

      <!-- 将session放入redis -->
          <bean id="redisHttpSessionConfiguration" class="org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration">
              <property name="maxInactiveIntervalInSeconds" value="1800" />
              <property name="redisNamespace" value="${redisNamespace}"/>
          </bean>

      注意:spring-session 的版本在 1.1.0 及以上才支持命名空间

  • 如果想在 session 中保存一个对象,必须实现了 Serializable接口,这样 Spring-session 才能对保存的对象进行序列化,从而存储在 redis 里
  • session 的域不同会生成新的 session 的。所以在项目中做了负载均衡的话,域就是一样的,所以可以实现session 共享
  • 如果选用 redis 云服务,使用过程中会出现异常,异常原因是:很多 Redis 云服务提供商考虑到安全因素,会禁用掉 Redis 的 config 命令,因此需要我们手动在云服务后台管理系统手动配置,或者找云服务售后帮忙配置。然后我们在配置文件 RedisHttpSessionConfiguration 的 bean 中添加如下配置,解决使用 redis 云服务异常问题

    <!-- 让Spring Session不再执行config命令 -->
    <util:constant static-field="org.springframework.session.data.redis.config.ConfigureRedisAction.NO_OP">
    </util:constant>

注意:判断 config 命令是否被禁用,可以在 redis 的命令行去使用 config 命令,如果报没有找到该命令,说明 config 命令被禁用了。

参考

    原文作者:莼黑色
    原文地址: https://segmentfault.com/a/1190000011091273
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞