https://github.com/zhangxin1932/dubbo-spring-cloud (代码路径)
1.前置知识:
1.1 session 是什么, 主要解决什么问题
https://download.oracle.com/otndocs/jcp/servlet-4-final-eval-spec/index.html (JSR369规范)
https://www.jianshu.com/p/b5efddc433f5 (关于cookie & session 机制)
http 协议是无状态协议,通俗点说就是当你发送一次请求到服务器端,然后再次发送请求到服务器端,服务器是不知道你的这一次请求和上一次请求是来源于同一个人发送的。
session 就能很好解决这个问题。Session 是客户端与服务器通讯会话跟踪技术,服务器与客户端保持整个通讯的会话基本信息。
客户端在第一次访问服务端的时候,会收到服务端返回的一个 sessionId 并且将它存入到 cookie 中,
在之后的访问会将 cookie 中的 sessionId 放入到请求头中去访问服务器,如果服务端通过这个 sesslonId 没有找到对应的数据,
那么服务器会创建一个新的 sessionId, 默认情况下, 该 sessionId 会存入 JVM (本地缓存) 中, 并且响应给客户端。
图1: 一个cookie的设置以及发送过程
图2: 服务端在response-header中返回cookie
图3: 客户端请求时在request-header中携带cookie
图4: 客户端保存的cookie的一些及其属性
1.2 spring-session 是什么, 主要解决什么问题
1.2.1 问题引入: 分布式环境下 session 不一致问题
图5: 常规 session 在集群环境下的问题
以上图为例, 假设负载均衡处采用的轮询策略, server 集群处未实现session共享.
则当 client 首次请求时, server1 接收到请求, 并生成的 session 为 s1, 此时 client 将 s1 保存到本地 cookie 中.
当 client 第二次发起请求时携带的 session 为 s1, server2 接收到请求, 此时 server2 发现本地缓存(JVM) 中并不存在 s1, 则重新生成了新的 session 为 s2.
此时便出现了问题.
1.2.2 常见解决方案
1.session复制 2.session会话保持(黏滞会话) 3.利用cookie记录session 4.session 服务器(集群)
https://www.cnblogs.com/saoyou/p/11107488.html (分布式 session 常见解决方案)
1.2.3 spring-session 的方案
将session从web容器中剥离,存储在独立的存储服务器中。 session 的管理责任委托给spring-session承担。
当 request 进入web容器,根据request获取session时,由spring-session负责存存储器中获取session,如果存在则返回,如果不存在则创建并持久化至存储器中。
图6: spring-session在集群环境下的解决方案
1.3 Keyspace Notifications (redis 键空间通知)
http://www.redis.cn/topics/notifications.html
https://redis.io/topics/notifications
https://www.jianshu.com/p/2c3f253fb4c5
1.4 JSR 规范
JSR369 是Java Servlet 4.0 的规范提案,其中定义了大量的api,包括:
Servlet、HttpServletRequest/HttpServletRequestWrapper、HttpServletResponse/HttpServletResponseWrapper、Filter、Session等,
是标准的web容器需要遵循的规约,如tomcat/jetty等。 一般通过下述方式获取 session: HttpServletRequest request = ... HttpSession session = request.getSession(true);
https://download.oracle.com/otndocs/jcp/servlet-4-final-eval-spec/index.html (JSR369规范)
1.5 最低环境要求(来自官网)
https://docs.spring.io/spring-session/docs/current/reference/html5/#custom-sessionrepository
2.应用示例
2.1 pom.xml
<!-- <version>2.2.6.RELEASE</version> --> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>
2.2 application.yml
spring: redis: host: 192.168.0.156 port: 6379 session: store-type: redis timeout: 30m redis: # 表示不支持 键空间事件; 默认值是 notify_keyspace_events 表示支持 # 这里有个问题就是, 如果一开始启用了该功能, 后期想关闭该功能, 仅把此处设置为 none 是不行的, 必须重启 redis, 再讲此处设置为 none. # 再研究下, 看是 bug 提个 issue, 还是说 还有其他方案. configure-action: none cleanup-cron: 0 * * * * * # 清理过期 session 的定时任务 namespace: spring:session # 前缀 server: port: 8081 servlet: session: timeout: 30m cookie: http-only: true # domain: zy.com path: / # secure: true name: authId
2.3 模拟登录登出的controller
package com.zy.controller; import com.zy.vo.TbUser; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import javax.servlet.http.HttpServletRequest; /** * 分别模拟登录和登出 */ @RestController @RequestMapping("/user/") public class UserController { public static final String USER_ATTRIBUTE_NAME = "user_session"; @RequestMapping("login") public ResponseEntity<String> login(@RequestBody TbUser user, HttpServletRequest request) { request.getSession().setAttribute(USER_ATTRIBUTE_NAME, user); return ResponseEntity.ok().body("successfully to login. sessionId is: " + request.getSession().getId()); } @RequestMapping("logout") public ResponseEntity<String> logout(HttpServletRequest request) { request.getSession(false).invalidate(); return ResponseEntity.ok().body("successfully to logout.. sessionId is: " + request.getSession().getId()); } }
2.4 监听Session删除事件的监听器
package com.zy.config; import com.zy.controller.UserController; import org.springframework.context.ApplicationListener; import org.springframework.session.events.SessionDeletedEvent; import org.springframework.stereotype.Component; @Component public class SessionDeleteEventListener implements ApplicationListener<SessionDeletedEvent> { @Override public void onApplicationEvent(SessionDeletedEvent event) { System.out.println("--------------------------------"); Object user = event.getSession().getAttribute(UserController.USER_ATTRIBUTE_NAME); System.out.println("SessionDeletedEvent, user is: " + user); System.out.println("--------------------------------"); } }
2.5 MockMvc 进行mock测试
package com.zy; import com.alibaba.fastjson.JSON; import com.zy.vo.TbUser; import org.junit.Test; import org.junit.runner.RunWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.test.web.servlet.result.MockMvcResultMatchers; import javax.servlet.http.Cookie; @SpringBootTest @AutoConfigureMockMvc @RunWith(SpringRunner.class) public class TestUserController { @Autowired private MockMvc mockMvc; @Test public void testLoginAndLogout() throws Exception { TbUser user = new TbUser(); user.setUsername("tom"); user.setPassword("123456"); ResultActions actions = this.mockMvc.perform(MockMvcRequestBuilders.post("/user/login") .content(JSON.toJSONString(user)) .contentType(MediaType.APPLICATION_JSON)) .andExpect(MockMvcResultMatchers.status().isOk()); String sessionId = actions.andReturn().getResponse().getCookie("authId").getValue(); ResultActions resultActions = this.mockMvc.perform(MockMvcRequestBuilders.post("/user/logout") .cookie(new Cookie("authId", sessionId))) .andExpect(MockMvcResultMatchers.status().isOk()); } }
3.源码分析 (基于上文中的示例)
3.1 框架初始化阶段
3.1.1 加载 yml 中的配置文件
@ConfigurationProperties(prefix = "server", ignoreUnknownFields = true) public class ServerProperties { } @ConfigurationProperties(prefix = "spring.session") public class SessionProperties { }
3.1.2 加载相关自动装配的类
核心类1 SessionAutoConfiguration // 构建 DefaultCookieSerializer SessionAutoConfiguration.ServletSessionConfiguration#cookieSerializer
核心类2 RedisSessionConfiguration // 构建 ConfigureRedisAction (即redis键空间通知相关行为) RedisSessionConfiguration#configureRedisAction >> NOTIFY_KEYSPACE_EVENTS >> NONE // 构建 RedisIndexedSessionRepository 前的属性设置 RedisSessionConfiguration.SpringBootRedisHttpSessionConfiguration#customize >> setMaxInactiveIntervalInSeconds >> setRedisNamespace >> setFlushMode >> setSaveMode >> setCleanupCron
核心类3 RedisHttpSessionConfiguration // 构建 RedisIndexedSessionRepository RedisHttpSessionConfiguration#sessionRepository 在其构造器中构建了 RedisSessionExpirationPolicy (核心类), 其核心方法: >> onDelete >> onExpirationUpdated >> cleanExpiredSessions // 构建 RedisMessageListenerContainer RedisHttpSessionConfiguration#redisMessageListenerContainer 在该对象中 addMessageListener (核心方法), 主要添加了: >> SessionDeletedTopic >> SessionExpiredTopic >> SessionCreatedTopic. 这些 Topic 最终被 RedisIndexedSessionRepository 实现了其 onMessage 方法, 监听 Session 的创建, 删除, 过期事件. 该onMessage 方法 在 RedisMessageListenerContainer#executeListener 中调用时触发. 该类中同时有一个内部SubscriptionTask, 该线程主要是订阅redis事件. // 构建 EnableRedisKeyspaceNotificationsInitializer // 这里如果 redis 不支持 键空间通知 功能, 启动时便会抛异常 RedisHttpSessionConfiguration#enableRedisKeyspaceNotifications >> 如果上述步骤(核心类1)中 ConfigureRedisAction 是 NONE, 则不向 redis 发送指令, 不再支持"键空间通知"; >> 如果 ConfigureRedisAction 是 NOTIFY_KEYSPACE_EVENTS,则向redis发送指令, 支持"键空间通知"; // 构建清理过期的 sessions 的调度任务 (定时任务, cron 表达式) RedisHttpSessionConfiguration.SessionCleanupConfiguration
核心类4 SpringHttpSessionConfiguration // 核心点: 构建 SessionEventHttpSessionListenerAdapter SpringHttpSessionConfiguration#sessionEventHttpSessionListenerAdapter // 核心点: 构建 SessionRepositoryFilter SpringHttpSessionConfiguration#springSessionRepositoryFilter 其默认优先级是: Integer.MIN_VALUE + 50; 值越小, 优先级越高.
核心类5 SessionRepositoryFilterConfiguration // 将 SessionRepositoryFilter 注册到 filter 链中 SessionRepositoryFilterConfiguration#sessionRepositoryFilterRegistration 默认的拦截路径是: /*
3.2 运行时拦截阶段
3.2.1 SessionRepositoryFilter (其构造器中初始化各种对象, 上文已表述)
SessionRepositoryFilter 其实是一个遵循 servlet 规范的 Filter, 用来拦截 request & response. 其核心方法为: @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 设置固定属性 request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository); // 对 request 进行包装 SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryRequestWrapper(request, response); // 对 response 进行包装 SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryResponseWrapper(wrappedRequest, response); try { // 将请求转发给过滤器链下一个filter filterChain.doFilter(wrappedRequest, wrappedResponse); } finally { // 提交 session, 见下文表述 wrappedRequest.commitSession(); } }
/** * Uses the {@link HttpSessionIdResolver} to write the session id to the response and persist the Session. */ private void commitSession() { // 从当前请求的attribute中获取 HttpSessionWrapper HttpSessionWrapper wrappedSession = getCurrentSession(); if (wrappedSession == null) { // 如果为空, 则判断是否调用了 HttpSessionWrapper 的 invalidate 方法, 如果是, 则设置 cookieValue 为 "". if (isInvalidateClientSession()) { SessionRepositoryFilter.this.httpSessionIdResolver.expireSession(this, this.response); } } else { // 如果不为空, 获取对应的 HttpSessionWrapper 对象 S session = wrappedSession.getSession(); // 对标识变量重新赋值, 便于后续调用 clearRequestedSessionCache(); // 走进 RedisIndexedSessionRepository 的 save 方法, 真正的持久化 session 到存储服务器中 SessionRepositoryFilter.this.sessionRepository.save(session); String sessionId = session.getId(); // 如果是新创建的 session,则将 sessionId 回写到对应的 cookie 中 if (!isRequestedSessionIdValid() || !sessionId.equals(getRequestedSessionId())) { // 这里写 cookie, 默认调用 CookieHttpSessionIdResolver#setSessionId SessionRepositoryFilter.this.httpSessionIdResolver.setSessionId(this, this.response, sessionId); } } } @SuppressWarnings("unchecked") private HttpSessionWrapper getCurrentSession() { // 获取当前 session return (HttpSessionWrapper) getAttribute(CURRENT_SESSION_ATTR); } @Override public HttpSessionWrapper getSession(boolean create) { // 从当前请求的attribute中获取session,如果有直接返回 HttpSessionWrapper currentSession = getCurrentSession(); if (currentSession != null) { return currentSession; } // 获取当前请求的 session S requestedSession = getRequestedSession(); // 如果不为空, 则判断是否设置了销毁 session 的属性, 如果不是, 则: if (requestedSession != null) { if (getAttribute(INVALID_SESSION_ID_ATTR) == null) { // 设置当前 session 的最后访问时间 requestedSession.setLastAccessedTime(Instant.now()); this.requestedSessionIdValid = true; // 重新包装当前 session currentSession = new HttpSessionWrapper(requestedSession, getServletContext()); currentSession.markNotNew(); // 设置session至Requset的attribute中,提高同一个request访问session的性能 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, 这里对应于 RedisIndexedSessionRepository#createSession S session = SessionRepositoryFilter.this.sessionRepository.createSession(); session.setLastAccessedTime(Instant.now()); currentSession = new HttpSessionWrapper(session, getServletContext()); // 设置session至Requset的attribute中,提高同一个request访问session的性能 setCurrentSession(currentSession); return currentSession; }
3.2.2 RedisIndexedSessionRepository (其构造器中初始化各种对象, 上文已表述)
// 承接上文中的 getSession 方法 @Override public RedisSession createSession() { MapSession cached = new MapSession(); if (this.defaultMaxInactiveInterval != null) { cached.setMaxInactiveInterval(Duration.ofSeconds(this.defaultMaxInactiveInterval)); } // 创建 RedisSession 对象 RedisSession session = new RedisSession(cached, true); session.flushImmediateIfNecessary(); return session; } RedisSession(MapSession cached, boolean isNew) { // RedisSession 中包装了 MapSession, 用于做本地缓存,每次在 getAttribute 时无需从Redis中获取,主要为了提升性能 this.cached = cached; this.isNew = isNew; this.originalSessionId = cached.getId(); Map<String, String> indexes = RedisIndexedSessionRepository.this.indexResolver.resolveIndexesFor(this); this.originalPrincipalName = indexes.get(PRINCIPAL_NAME_INDEX_NAME); if (this.isNew) { // 分别设置 session 的 creationTime, maxInactiveInterval, lastAccessedTime 属性 // delta 用于跟踪变化数据,做持久化 this.delta.put(RedisSessionMapper.CREATION_TIME_KEY, cached.getCreationTime().toEpochMilli()); this.delta.put(RedisSessionMapper.MAX_INACTIVE_INTERVAL_KEY, (int) cached.getMaxInactiveInterval().getSeconds()); this.delta.put(RedisSessionMapper.LAST_ACCESSED_TIME_KEY, cached.getLastAccessedTime().toEpochMilli()); } if (this.isNew || (RedisIndexedSessionRepository.this.saveMode == SaveMode.ALWAYS)) { getAttributeNames().forEach((attributeName) -> this.delta.put(getSessionAttrNameKey(attributeName), cached.getAttribute(attributeName))); } }
@Override public void save(RedisSession session) { // 核心方法: session.save(); // 如果是新创建的 session, 则创建 SessionCreated 相关事件 if (session.isNew) { String sessionCreatedKey = getSessionCreatedChannel(session.getId()); this.sessionRedisOperations.convertAndSend(sessionCreatedKey, session.delta); session.isNew = false; } } /** * 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(); // 核心点1: 将创建的 session 的相关信息存储到 redis 中, hash 类型. // 在 redis 中 key 的基本格式是: spring:session:sessions:3233-999392993 getSessionBoundHashOperations(sessionId).putAll(this.delta); // SpringSecurity 相关, 略过 String principalSessionKey = getSessionAttrNameKey(FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME); String securityPrincipalSessionKey = getSessionAttrNameKey(SPRING_SECURITY_CONTEXT); if (this.delta.containsKey(principalSessionKey) || this.delta.containsKey(securityPrincipalSessionKey)) { // ... } // 清空delta,代表没有任何需要持久化的数据。同时保证 SessionRepositoryResponseWrapper 的onResponseCommitted 只会持久化一次 Session 至 Redis 中 this.delta = new HashMap<>(this.delta.size()); // 更新过期时间,滚动至下一个过期时间间隔的时刻 Long originalExpiration = (this.originalLastAccessTime != null) ? this.originalLastAccessTime.plus(getMaxInactiveInterval()).toEpochMilli() : null; // 核心点2: 设置 key 的过期时间, 默认为 session 最大时间 + 5min // 向 redis 中添加 spring:session:expirations & spring:session:sessions:expires // 这里详见: RedisSessionExpirationPolicy#onExpirationUpdated RedisIndexedSessionRepository.this.expirationPolicy.onExpirationUpdated(originalExpiration, this); }
小结: 当创建一个RedisSession,然后存储在Redis中时,Redis会为每个RedisSession存储三个k-v: >> spring:session:sessions:8b2b46a2-c39b-42b6-8a80-4132dad06f9a hash 类型. 用来存储Session的详细信息,包括Session的过期时间间隔、最近的访问时间、attributes等等。这个k的过期时间为Session的最大过期时间 + 5分钟。 >> spring:session:sessions:expires:8b2b46a2-c39b-42b6-8a80-4132dad06f9a string 类型. 表示Session在Redis中的过期,这个k-v不存储任何有用数据,只是表示Session过期而设置。这个k在Redis中的过期时间即为Session的过期时间间隔。 >> spring:session:expirations:1602148800000 set类型. 存储这个Session的id. 其中key中的 1602148800000 是一个时间戳,根据这个Session过期时刻滚动至下一分钟而计算得出。
图7: spring-session中在redis中存储的具体细节
为什么RedisSession的存储用到了三个Key,而非一个Redis过期Key ? 对于Session的实现,需要支持 HttpSessionEvent,即Session创建、过期、销毁等事件。 当应用程序监听器设置监听相应事件,Session发生上述行为时,监听器能够做出相应的处理。 Redis2.8 之后开始支持KeySpace Notifiction, 即可以监视某个key的变化,如删除、更新、过期事件。 当key发生上述行为时,监听这些事件的 client 便可以接受到变化的通知做出相应的处理(RedisIndexedSessionRepository#onMessage 方法)。 但是Redis中带有过期的key有两种方式: >> 当访问时发现其过期. 会产生过期事件,但是无法保证key的过期时间抵达后立即生成过期事件。 >> Redis后台逐步查找过期键. spring-session为了能够及时的产生Session的过期时的过期事件,所以增加了: spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe spring:session:expirations:1439245080000 #RedisHttpSessionConfiguration.SessionCleanupConfiguration#configureTasks #RedisSessionExpirationPolicy#cleanExpiredSessions spring-session 中有个定时任务,每个整分钟都会删除相应的spring:session:expirations:整分钟的时间戳中的过期SessionId. 然后再访问一次这个SessionId 即 spring:session:sessions:expires:SessionId,以便能够让Redis及时的产生key过期事件——即Session过期事件。
图8: redis中key过期事件描述
https://github.com/spring-projects/spring-session/issues/92
https://github.com/spring-projects/spring-session/issues/93
3.3 spring-session 中的事件触发机制
在上文框架初始化阶段提到, 初始化了 RedisMessageListenerContainer, 该类主要是基于 redis的 pub/sub & Key-notification 机制, 监听 redis 中 key 的各种事件.
当有相关事件发生时, 会触发执行RedisMessageListenerContainer#executeListener方法, 该方法最终执行进MessageListener 的实现类 RedisIndexedSessionRepository#onMessage 方法中.
RedisIndexedSessionRepository#onMessage 方法会有下述事件(即 ApplicationEventPublisher#publishEvent):
handleCreated
handleDeleted
handleExpired
至此, RedisMessageListenerContainer & ApplicationEventPublisher 便将 redis 的事件同 spring 的事件机制联系起来, 从而实现事件的发布订阅.
图9: spring-session中 Session 相关事件触发机制
图10: spring-session中对 Session 相关事件的抽象
4.常见配置项 & 扩展点 (不成熟, 后期慢慢验证, 本模块不建议参考)
4.1 不使用基于 cookie 的方案, 采用基于 自定义 header 的方式
step1:
@Configuration public class SpringSessionConfiguration { @Bean public HttpSessionIdResolver httpSessionIdResolver() { return new HeaderHttpSessionIdResolver("x-auth-token"); } }
4.2 不同web场景下的整合
1.常规web应用的支持 适用于普通 http 应用的 分布式 session 场景, 按照本文示例即可 2.WebSocket 应用的支持 适用于基于 websocket 应用的 分布式 session 场景 3.WebSession 应用的支持 适用于spring异步程序应用(webflux) 的 分布式 session 场景 4.Spring Security 的支持 感兴趣者自行研究
5.一些问题
5.1 关于spring-boot整合方式的问题
spring-boot2里:
个人不太建议 使用@EnableRedisHttpSession 注解了. 如果用, 则RedisSessionConfiguration 配置类将不被加载.
此时若是配置 spring.session.redis.configure-action=none (即不启用键空间通知事件), 将不生效.
5.2 启用键空间事件无法关闭的bug
#spring-boot 版本2.2.6.RELEASE 如果spring-boot中一开始支持"键空间通知" 其实是通过RedisHttpSessionConfiguration.EnableRedisKeyspaceNotificationsInitializer#afterPropertiesSet中发送 config set命令来使得redis支持的. 如果后期想关闭该功能. 仅仅配置spring.session.redis.configure-action=none是不起作用的, 需要重启 redis 服务器. (也许还有其他方案, 也许可以提个 issue).
5.3 spring-session在redis集群下监听expired事件失败 (待验证)
spring-session的expired事件有时监听会丢失,spring-session不支持redis集群场景。 其原因在于: spring-session默认会随机订阅redis集群中所有主备节点中一台, 而创建带ttl参数的session连接只会hash到所有主节点中一台。 只有订阅和session创建连接同时连接到一台redis节点才能监听到这个ttl session产生的expired事件。 解决方案: 自行对所有redis集群主备节点进行expired事件订阅。
https://github.com/spring-projects/spring-session/issues/478
参考资源
https://docs.spring.io/spring-session/docs/current/reference/html5/
https://www.cnblogs.com/lxyit/archive/2004/01/13/9720159.html