一、概述
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 的事件机制,事件有:
- Session创建事件
- Session删除事件
- 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);
}
}
}