spring · 2022-01-30 0

spring-session使用及浅析

一、概述

spring-session 使用 redis 存储 session,实现不同应用 session 共享

二、配置

1.pom 文件

<properties>
    <spring.version>5.1.9.RELEASE</spring.version>
</properties>

<dependencies>

    <!--
    <dependency>
        <groupId>javax.servlet</groupId>
        <artifactId>javax.servlet-api</artifactId>
        <version>3.0.1</version>
    </dependency>
    -->

    <!-- 为了调试查看 tomcat 源码,进入 tomcat-catalina 和 tomcat-websocket -->
    <dependency>
        <groupId>org.apache.tomcat</groupId>
        <artifactId>tomcat-catalina</artifactId>
        <version>8.5.57</version>
    </dependency>

    <dependency>
        <groupId>org.apache.tomcat</groupId>
        <artifactId>tomcat-websocket</artifactId>
        <version>8.5.57</version>
    </dependency>

    <!-- spring-core -->
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-core</artifactId>
        <version>${spring.version}</version>
    </dependency>

    <!-- spring-beans -->
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-beans</artifactId>
        <version>${spring.version}</version>
    </dependency>

    <!-- spring-context -->
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-context</artifactId>
        <version>${spring.version}</version>
    </dependency>

    <!-- spring-expression -->
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-expression</artifactId>
        <version>${spring.version}</version>
    </dependency>

    <!-- spring-web -->
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-web</artifactId>
        <version>${spring.version}</version>
    </dependency>

    <!-- spring-webmvc -->
    <dependency>
        <groupId>org.springframework</groupId>
        <artifactId>spring-webmvc</artifactId>
        <version>${spring.version}</version>
    </dependency>

    <!-- jedis -->
    <dependency>
        <groupId>redis.clients</groupId>
        <artifactId>jedis</artifactId>
        <version>2.9.0</version>
    </dependency>

    <!--
        spring-session-data-redis 2.1.8.RELEASE
        依赖 spring-data-redis 2.1.10.RELEASE 和 spring-session-core 2.1.8.RELEASE
     -->
    <dependency>
        <groupId>org.springframework.session</groupId>
        <artifactId>spring-session-data-redis</artifactId>
        <version>2.1.8.RELEASE</version>
    </dependency>

</dependencies>

2. spring 配置

spring-context.xml

<?xml version="1.0" encoding="utf-8" ?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
            http://www.springframework.org/schema/beans/spring-beans.xsd
            http://www.springframework.org/schema/context
            http://www.springframework.org/schema/context/spring-context.xsd">

    <!-- 支持注解 -->
    <context:annotation-config/>

    <!-- 定义SpringSession的配置Bean -->
    <bean class="org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration">
        <!--指定Cookie的序列化规则对象,用于改变SpringSessionCookie的存放规则 -->
        <property name="cookieSerializer"  ref="defaultCookieSerializer"/>
        <!--设置Session的最大生命周期,单位为秒 默认有效期为1800 表示30分钟 -->
        <!--<property name="maxInactiveIntervalInSeconds" value="1800"/>-->
    </bean>

    <!-- 自定义Cookie规则对象 -->
    <bean id="defaultCookieSerializer" class="org.springframework.session.web.http.DefaultCookieSerializer">
        <!--指定SpringSession的Cookie数据要存放到域名的根路径下,用于解决同域名下不同项目的Session共享 -->
        <property name="cookiePath" value="/"/>
        <!--指定SpringSession的Cookie数据要存放到根域名下,用于解决同根域名但是不同子域名的Session共享,
        低版本的Tomcat(版本小于8)如果报错要在根域名前多加个点.-->
        <!--<property name="domainName" value="myweb.com"/>-->
    </bean>

    <!-- 配置redis  -->
    <bean class="org.springframework.data.redis.connection.jedis.JedisConnectionFactory">
        <constructor-arg name="standaloneConfig" ref="standaloneConfiguration"/>
    </bean>

    <bean id="standaloneConfiguration" class="org.springframework.data.redis.connection.RedisStandaloneConfiguration">
        <property name="hostName" value="localhost"/>
        <property name="port" value="6379"/>
    </bean>

</beans>

spring-servlet.xml

<?xml version="1.0" encoding="utf-8" ?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
            http://www.springframework.org/schema/beans/spring-beans.xsd
            http://www.springframework.org/schema/context
            http://www.springframework.org/schema/context/spring-context.xsd">

    <!--扫描所有组件-->
    <context:component-scan base-package="com.example.controller"/>

</beans>

3.web配置文件

web.xml

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
         version="4.0">

  <context-param>
    <param-name>contextConfigLocation</param-name>
    <param-value>classpath:spring-context.xml</param-value>
  </context-param>

  <listener>
    <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
  </listener>

  <!--
    这个 filter 需要配置为 springSessionRepositoryFilter,
    因为 RedisHttpSessionConfiguration 包露的 SessionRepositoryFilter 的 bean 名称是 springSessionRepositoryFilter,
    DelegatingFilterProxy 才能使用 SessionRepositoryFilter
  -->
  <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>
    <dispatcher>REQUEST</dispatcher>
    <dispatcher>ERROR</dispatcher>
  </filter-mapping>

  <servlet>
    <servlet-name>springmvc</servlet-name>
    <servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
    <init-param>
      <param-name>contextConfigLocation</param-name>
      <param-value>classpath:spring-servlet.xml</param-value>
    </init-param>
    <load-on-startup>1</load-on-startup>
  </servlet>

  <servlet-mapping>
    <servlet-name>springmvc</servlet-name>
    <url-pattern>/</url-pattern>
  </servlet-mapping>

  <servlet-mapping>
    <servlet-name>default</servlet-name>
    <url-pattern>*.js</url-pattern>
    <url-pattern>*.css</url-pattern>
    <url-pattern>*.img</url-pattern>
    <url-pattern>*.html</url-pattern>
  </servlet-mapping>

</web-app>

三、栗子

@Controller
@RequestMapping("/")
public class IndexController {

    @ResponseBody
    @RequestMapping(value = "/index", method = RequestMethod.GET)
    public String hello() {
        return "this is index";
    }

    @ResponseBody
    @RequestMapping(value = "/setUser/{user}", method = RequestMethod.GET)
    public String setUser(HttpServletRequest request, @PathVariable String user) {
        HttpSession session = request.getSession();
        String sessionId = session.getId();

        String threadName = Thread.currentThread().getName();

        session.setAttribute("user", user);

        return String.format("port: %s, threadName: %s, session id: %s, user: %s", 8081, threadName, sessionId, user);
    }

    @ResponseBody
    @RequestMapping(value = "/getUser", method = RequestMethod.GET)
    public String getUser(HttpServletRequest request) {
        HttpSession session = request.getSession();
        String sessionId = session.getId();

        String threadName = Thread.currentThread().getName();

        Object user = session.getAttribute("user");

        return String.format("port: %s, threadName: %s, session id: %s, user: %s", 8081, threadName, sessionId, user);
    }

}

四、浅析

版本

<!--
    spring-session-data-redis 2.1.8.RELEASE
    依赖 spring-data-redis 2.1.10.RELEASE 和 spring-session-core 2.1.8.RELEASE
 -->
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
    <version>2.1.8.RELEASE</version>
</dependency>

1. filter

Tomcat 的 ApplicationFilterChain 存储 DelegatingFilterProxy,
DelegatingFilterProxy 有 SessionRepositoryFilter

每次请求会执行 DelegatingFilterProxy 的 doFilter() 方法,再调用 SessionRepositoryFilter 的 doFilter() 方法

SessionRepositoryFilter 的 doFilterInternal() 方法,包装了 HttpServletRequest 和 HttpServletResponse,把 SessionRepositoryRequestWrapper 和 SessionRepositoryResponseWrapper 传递给下去

SessionRepositoryFilter.java

@Override
protected void doFilterInternal(HttpServletRequest request,
        HttpServletResponse response, FilterChain filterChain)
        throws ServletException, IOException {
    request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);

    SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryRequestWrapper(
            request, response, this.servletContext);
    SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryResponseWrapper(
            wrappedRequest, response);

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

2. session 的生成与获得

controller 层通过 request 获得 session,实际调用的是 SessionRepositoryRequestWrapper 的 getSession()方法

1、先通过 request.getAttribute("org.springframework.session.SessionRepository.CURRENT_SESSION") 获得 session,如果有,直接返回 HttpSessionWrapper 对象。如果没有,则执行2

2、查看是否有名为"SESSION"的cookie,如果有,得到 cookie 的值,把 cookie 值进行 base64解码,得到 sessionId;根据 sessionId,从 redis 中查询值,查询的 key 是 spring:session:sessions: + <sessionId>,例如,spring:session:sessions:4f021c89-674d-4380-a6ed-0fe1257608ec;把 redis 查询到的值保存在 RedisSession 的 cached 中;把 RedisSession 对象保存到 HttpSessionWrapper,并设置 request.setAttribute("org.springframework.session.SessionRepository.CURRENT_SESSION", currentSession),返回 HttpSessionWrapper 对象。如果没有,则执行3

3、通过 RedisOperationsSessionRepository 的 createSession() 方法创建 session,创建的是 RedisSession;RedisSession 有成员变量 cached 和 delta;MapSession 类型的 cached,保存生成的 sessionId;Map 类型的 cached,保存 lastAccessedTime、maxInactiveInterval、creationTime

4、给 session 添加属性时,保存在 RedisSession 的 cached 和 delta 中,例如,session.setAttribute("user", "zhang san"),则 cached 的 sessionAttrs 保存为 "user" -> "zhang san",delta 保存为 "sessionAttr:user" -> "zhang san"

5、从 session 获得属性时,从 RedisSession 的 cached 中获取

3. session 的保存与 response 响应

SessionRepositoryFilter 的 doFilterInternal() 方法执行完后,会调用 SessionRepositoryRequestWrapper 的 commitSession() 方法,保存 session 到 redis;再把 sessionId 进行 base64编码写入保存在 cookie,响应头添加,例如: response.addHeader("Set-Cookie", "SESSION=YTcwM2JjMTctNzUxNS00ODg0LWIxNjgtYTQzYmQxODYyNzBj; Path=/; HttpOnly; SameSite=Lax")

RedisSession 的 save() 方法保存 session 到 redis,保存内容以下:

先判断 RedisSession 的 delta 是否有值,如果有,则存储到 redis;如果 delta 没有值,直接返回

1、key 为 spring:session:sessions:a703bc17-7515-4884-b168-a43bd186270c

类型:hash
存活时间:30min (最大空闲时间) + 5min
值:
"lastAccessedTime" -> "1643944789934"
"maxInactiveInterval" -> "1800"
"creationTime" -> "1643944789934"
"lastAccesssessionAttr:useredTime" -> "zhang san"

2、key 为 spring:session:expirations:1643946600000,后面的时间戳值是 session 的 最大空闲时间 + 最后访问时间的下一整分钟的时间戳值

类型:set
存活时间:30min (最大空闲时间) + 5min
值: expires:a703bc17-7515-4884-b168-a43bd186270c

可以把这种 key 看作是桶,每次保存 session 的 时候,如果上次访问与这次访问不在同一分钟内,会把值从旧桶移除,添加到新桶中

3、key 为 spring:session:sessions:expires:a703bc17-7515-4884-b168-a43bd186270c

类型:string
存活时间:30min (最大空闲时间)
值: ""

每次 session 的续签,需要将旧桶中的数据移除,放到新桶中

spring:session:sessions:a703bc17-7515-4884-b168-a43bd186270c 存活时间是30min (最大空闲时间) + 5min,我觉得是为用户能够监听 session 过期服务了,使得 session 过期了,监听 session 的销毁方法,也能够得到 session 的值

4. session 过期清除任务

RedisHttpSessionConfiguration 的 configureTasks() 方法,cron 的 值是 "0 * * * * *",表示这个任务每整分钟执行一次。

实际调用的是 RedisSessionExpirationPolicy 的 cleanExpiredSessions() 方法。

RedisSessionExpirationPolicy.java

public void cleanExpiredSessions() {
    long now = System.currentTimeMillis();
    long prevMin = roundDownMinute(now);

    if (logger.isDebugEnabled()) {
        logger.debug("Cleaning up sessions expiring at " + new Date(prevMin));
    }

    String expirationKey = getExpirationKey(prevMin);
    Set<Object> sessionsToExpire = this.redis.boundSetOps(expirationKey).members();
    this.redis.delete(expirationKey);
    for (Object session : sessionsToExpire) {
        String sessionKey = getSessionKey((String) session);
        touch(sessionKey);
    }
}

得到当前整分钟的事件戳,形成key,例如:spring:session:expirations:1643946600000,删除这个 key,并把其值进行遍历touch,touch() 方法,调用 redis 的 hasKey()方法,例如:会判断 spring:session:sessions:expires:a703bc17-7515-4884-b168-a43bd186270c是否存在。

因为redis 的键过期机制不“保险”,这和 redis 的设计有关。

redis 在键实际过期之后不一定会被删除,可能会继续存留,可能是 1~2 分钟,可能会更久。
具有过期时间的 key 有两种方式来保证过期,一是这个键在过期的时候被访问了,二是后台运行一个定时任务自己删除过期的 key。如果没有指令持续关注 key,并且 redis 中存在许多与 TTL 关联的 key,则 key 真正被删除的时间将会有显著的延迟

5. session 事件机制

对于 servlet,我们可以继承 HttpSessionListener,来监听 session 的创建和消费,spring-session 为了无缝连接 servlet,基于 redis 的 发布/订阅,实现这一功能的

spring-session 的事件机制,事件有:

  1. Session创建事件
  2. Session删除事件
  3. Session过期事件

redis 频道命名在:

RedisOperationsSessionRepository.java

private void configureSessionChannels() {
    this.sessionCreatedChannelPrefix = this.namespace + "event:" + this.database
            + ":created:";
    this.sessionDeletedChannel = "__keyevent@" + this.database + "__:del";
    this.sessionExpiredChannel = "__keyevent@" + this.database + "__:expired";
}

redis 订阅配置在:

RedisHttpSessionConfiguration.java

@Bean
public RedisMessageListenerContainer redisMessageListenerContainer() {
    RedisMessageListenerContainer container = new RedisMessageListenerContainer();
    container.setConnectionFactory(this.redisConnectionFactory);
    if (this.redisTaskExecutor != null) {
        container.setTaskExecutor(this.redisTaskExecutor);
    }
    if (this.redisSubscriptionExecutor != null) {
        container.setSubscriptionExecutor(this.redisSubscriptionExecutor);
    }
    container.addMessageListener(sessionRepository(), Arrays.asList(
            new ChannelTopic(sessionRepository().getSessionDeletedChannel()),
            new ChannelTopic(sessionRepository().getSessionExpiredChannel())));
    container.addMessageListener(sessionRepository(),
            Collections.singletonList(new PatternTopic(
                    sessionRepository().getSessionCreatedChannelPrefix() + "*")));
    return container;
}

当发生 Session创建事件、删除事件、过期事件时,会执行

RedisOperationsSessionRepository 的 onMessage() 方法

当消息类似,spring:session:sessions:expires:a703bc17-7515-4884-b168-a43bd186270,才会处理过期和删除
这段代码的目的,是实现完成用户自定义 session 的监听功能

public void onMessage(Message message, byte[] pattern) {
    byte[] messageChannel = message.getChannel();
    byte[] messageBody = message.getBody();

    String channel = new String(messageChannel);

    if (channel.startsWith(this.sessionCreatedChannelPrefix)) {
        // TODO: is this thread safe?
        Map<Object, Object> loaded = (Map<Object, Object>) this.defaultSerializer
                .deserialize(message.getBody());
        handleCreated(loaded, channel);
        return;
    }

    String body = new String(messageBody);
    if (!body.startsWith(getExpiredKeyPrefix())) {
        return;
    }

    boolean isDeleted = channel.equals(this.sessionDeletedChannel);
    if (isDeleted || channel.equals(this.sessionExpiredChannel)) {
        int beginIndex = body.lastIndexOf(":") + 1;
        int endIndex = body.length();
        String sessionId = body.substring(beginIndex, endIndex);

        RedisSession session = getSession(sessionId, true);

        if (session == null) {
            logger.warn("Unable to publish SessionDestroyedEvent for session "
                    + sessionId);
            return;
        }

        if (logger.isDebugEnabled()) {
            logger.debug("Publishing SessionDestroyedEvent for session " + sessionId);
        }

        cleanupPrincipalIndex(session);

        if (isDeleted) {
            handleDeleted(session);
        }
        else {
            handleExpired(session);
        }
    }
}