上一篇文章中介绍了Spring-Session的核心原理,Filter,Session,Repository等等,传送门:spring-session(一)揭秘。

这篇继上一篇的原理逐渐深入Spring-Session中的事件机制原理的探索。众所周知,Servlet规范中有对HttpSession的事件的处理,如:HttpSessionEvent/HttpSessionIdListener/HttpSessionListener,可以查看Package javax.servlet

在Spring-Session中也有相应的Session事件机制实现,包括Session创建/过期/删除事件。

本文主要从以下方面探索Spring-Session中事件机制

  • Session事件的抽象
  • 事件的触发机制

Note:

这里的事件触发机制只介绍基于RedissSession的实现。基于内存Map实现的MapSession不支持Session事件机制。其他的Session实现这里也不做关注。

一.Session事件的抽象

先来看下Session事件抽象UML类图,整体掌握事件之间的依赖关系。

Session Event最顶层是ApplicationEvent,即Spring上下文事件对象。由此可以看出Spring-Session的事件机制是基于Spring上下文事件实现。

抽象的AbstractSessionEvent事件对象提供了获取Session(这里的是指Spring Session的对象)和SessionId。

基于事件的类型,分类为:

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

Tips:

Session销毁事件只是删除和过期事件的统一,并无实际含义。

事件对象只是对事件本身的抽象,描述事件的属性,如:

  1. 获取事件产生的源:getSource获取事件产生源
  2. 获取相应事件特性:getSession/getSessoinId获取时间关联的Session

下面再深入探索以上的Session事件是如何触发,从事件源到事件监听器的链路分析事件流转过程。

二.事件的触发机制

阅读本节前,读者应该了解Redis的Pub/Sub和KeySpace Notification,如果还不是很了解,传送门Redis Keyspace Notifications和Pub/Sub。

上节中也介绍Session Event事件基于Spring的ApplicationEvent实现。先简单认识spring上下文事件机制:

  • ApplicationEventPublisher实现用于发布Spring上下文事件ApplicationEvent
  • ApplicationListener实现用于监听Spring上下文事件ApplicationEvent
  • ApplicationEvent抽象上下文事件

那么在Spring-Session中必然包含事件发布者ApplicationEventPublisher发布Session事件和ApplicationListener监听Session事件。

可以看出ApplicationEventPublisher发布一个事件:

@FunctionalInterface
public interface ApplicationEventPublisher { /**
* Notify all <strong>matching</strong> listeners registered with this
* application of an application event. Events may be framework events
* (such as RequestHandledEvent) or application-specific events.
* @param event the event to publish
* @see org.springframework.web.context.support.RequestHandledEvent
*/
default void publishEvent(ApplicationEvent event) {
publishEvent((Object) event);
} /**
* Notify all <strong>matching</strong> listeners registered with this
* application of an event.
* <p>If the specified {@code event} is not an {@link ApplicationEvent},
* it is wrapped in a {@link PayloadApplicationEvent}.
* @param event the event to publish
* @since 4.2
* @see PayloadApplicationEvent
*/
void publishEvent(Object event); }

ApplicationListener用于监听相应的事件:

@FunctionalInterface
public interface ApplicationListener<E extends ApplicationEvent> extends EventListener { /**
* Handle an application event.
* @param event the event to respond to
*/
void onApplicationEvent(E event); }

Tips:

这里使用到了发布/订阅模式,事件监听器可以监听感兴趣的事件,发布者可以发布各种事件。不过这是内部的发布订阅,即观察者模式。

Session事件的流程实现如下:

上图展示了Spring-Session事件流程图,事件源来自于Redis键空间通知,在spring-data-redis项目中抽象MessageListener监听Redis事件源,然后将其传播至spring应用上下文发布者,由发布者发布事件。在spring上下文中的监听器Listener即可监听到Session事件。

因为两者是Spring框架提供的对Spring的ApplicationEvent的支持。Session Event基于ApplicationEvent实现,必然也有其相应发布者和监听器的的实现。

Spring-Session中的RedisSession的SessionRepository是RedisOperationSessionRepository。所有关于RedisSession的管理操作都是由其实现,所以Session的产生源是RedisOperationSessionRepository。

在RedisOperationSessionRepository中持有ApplicationEventPublisher对象用于发布Session事件。

private ApplicationEventPublisher eventPublisher = new ApplicationEventPublisher() {
@Override
public void publishEvent(ApplicationEvent event) {
}
@Override
public void publishEvent(Object event) {
}
};

但是该ApplicationEventPublisher是空实现,实际实现是在应用启动时由Spring-Session自动配置。在spring-session-data-redis模块中RedisHttpSessionConfiguration中有关于创建RedisOperationSessionRepository Bean时将调用set方法将ApplicationEventPublisher配置。

@Configuration
@EnableScheduling
public class RedisHttpSessionConfiguration extends SpringHttpSessionConfiguration
implements BeanClassLoaderAware, EmbeddedValueResolverAware, ImportAware,
SchedulingConfigurer { private ApplicationEventPublisher applicationEventPublisher; @Bean
public RedisOperationsSessionRepository sessionRepository() {
RedisTemplate<Object, Object> redisTemplate = createRedisTemplate();
RedisOperationsSessionRepository sessionRepository = new RedisOperationsSessionRepository(
redisTemplate);
// 注入依赖
sessionRepository.setApplicationEventPublisher(this.applicationEventPublisher);
if (this.defaultRedisSerializer != null) {
sessionRepository.setDefaultSerializer(this.defaultRedisSerializer);
}
sessionRepository
.setDefaultMaxInactiveInterval(this.maxInactiveIntervalInSeconds);
if (StringUtils.hasText(this.redisNamespace)) {
sessionRepository.setRedisKeyNamespace(this.redisNamespace);
}
sessionRepository.setRedisFlushMode(this.redisFlushMode);
return sessionRepository;
} // 注入上下文中的ApplicationEventPublisher Bean
@Autowired
public void setApplicationEventPublisher(
ApplicationEventPublisher applicationEventPublisher) {
this.applicationEventPublisher = applicationEventPublisher;
} }

在进行自动配置时,将上下文中的ApplicationEventPublisher的注入,实际上即ApplicationContext对象。

Note:

考虑篇幅原因,以上的RedisHttpSessionConfiguration至展示片段。

对于ApplicationListener是由应用开发者自行实现,注册成Bean即可。当有Session Event发布时,即可监听。

/**
* session事件监听器
*
* @author huaijin
*/
@Component
public class SessionEventListener implements ApplicationListener<SessionDeletedEvent> { private static final String CURRENT_USER = "currentUser"; @Override
public void onApplicationEvent(SessionDeletedEvent event) {
Session session = event.getSession();
UserVo userVo = session.getAttribute(CURRENT_USER);
System.out.println("Current session's user:" + userVo.toString());
}
}

以上部分探索了Session事件的发布者和监听者,但是核心事件的触发发布则是由Redis的键空间通知机制触发,当有Session创建/删除/过期时,Redis键空间会通知Spring-Session应用。

RedisOperationsSessionRepository实现spring-data-redis中的MessageListener接口。

/**
* Listener of messages published in Redis.
*
* @author Costin Leau
* @author Christoph Strobl
*/
public interface MessageListener { /**
* Callback for processing received objects through Redis.
*
* @param message message must not be {@literal null}.
* @param pattern pattern matching the channel (if specified) - can be {@literal null}.
*/
void onMessage(Message message, @Nullable byte[] pattern);
}

该监听器即用来监听redis发布的消息。RedisOperationsSessionRepositorys实现了该Redis键空间消息通知监听器接口,实现如下:

public class RedisOperationsSessionRepository implements
FindByIndexNameSessionRepository<RedisOperationsSessionRepository.RedisSession>,
MessageListener { @Override
@SuppressWarnings("unchecked")
public void onMessage(Message message, byte[] pattern) {
// 获取该消息发布的redis通道channel
byte[] messageChannel = message.getChannel();
// 获取消息体内容
byte[] messageBody = message.getBody(); String channel = new String(messageChannel); // 如果是由Session创建通道发布的消息,则是Session创建事件
if (channel.startsWith(getSessionCreatedChannelPrefix())) {
// 从消息体中载入Session
Map<Object, Object> loaded = (Map<Object, Object>) this.defaultSerializer
.deserialize(message.getBody());
// 发布创建事件
handleCreated(loaded, channel);
return;
} // 如果消息体不是以过期键前缀,直接返回。因为spring-session在redis中的key命名规则:
// "${namespace}:sessions:expires:${sessionId}",如:
// session.example:sessions:expires:a5236a19-7325-4783-b1f0-db9d4442db9a
// 所以判断过期或者删除的键是否为spring-session的过期键。如果不是,可能是应用中其他的键的操作,所以直接return
String body = new String(messageBody);
if (!body.startsWith(getExpiredKeyPrefix())) {
return;
} // 根据channel判断键空间的事件类型del或者expire时间
boolean isDeleted = channel.endsWith(":del");
if (isDeleted || channel.endsWith(":expired")) {
int beginIndex = body.lastIndexOf(":") + 1;
int endIndex = body.length();
// Redis键空间消息通知内容即操作的键,spring-session键中命名规则:
// "${namespace}:sessions:expires:${sessionId}",以下是根据规则解析sessionId
String sessionId = body.substring(beginIndex, endIndex); // 根据sessionId加载session
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); // 发布Session delete事件
if (isDeleted) {
handleDeleted(session);
}
else {
// 否则发布Session expire事件
handleExpired(session);
}
}
}
}

下续再深入每种事件产生的前世今生。

1.Session创建事件的触发
  1. 由RedisOperationSessionRepository向Redis指定通道${namespace}:event:created:${sessionId}发布一个message
  2. MessageListener的实现RedisOperationSessionRepository监听到Redis指定通道${namespace}:event:created:${sessionId}的消息
  3. 将其传播至ApplicationEventPublisher
  4. ApplicationEventPublisher发布SessionCreateEvent
  5. ApplicationListener监听SessionCreateEvent,执行相应逻辑

RedisOperationSessionRepository中保存一个Session时,判断Session是否新创建。

如果新创建,则向

@Override
public void save(RedisSession session) {
session.saveDelta();
// 判断是否为新创建的session
if (session.isNew()) {
// 获取redis指定的channel:${namespace}:event:created:${sessionId},
// 如:session.example:event:created:82sdd-4123-o244-ps123
String sessionCreatedKey = getSessionCreatedChannel(session.getId());
// 向该通道发布session数据
this.sessionRedisOperations.convertAndSend(sessionCreatedKey, session.delta);
// 设置session为非新创建
session.setNew(false);
}
}

该save方法的调用是由HttpServletResponse提交时——即返回客户端响应调用,上篇文章已经详解,这里不再赘述。关于RedisOperationSessionRepository实现MessageListener上述已经介绍,这里同样不再赘述。

Note:

这里有点绕。个人认为RedisOperationSessionRepository发布创建然后再本身监听,主要是考虑分布式或者集群环境中SessionCreateEvent事件的处理。

2.Session删除事件的触发

Tips:

删除事件中使用到了Redis KeySpace Notification,建议先了解该技术。

  1. 由RedisOperationSessionRepository删除Redis键空间中的指定Session的过期键,Redis键空间会向__keyevent@*:del的channel发布删除事件消息
  2. MessageListener的实现RedisOperationSessionRepository监听到Redis指定通道__keyevent@*:del的消息
  3. 将其传播至ApplicationEventPublisher
  4. ApplicationEventPublisher发布SessionDeleteEvent
  5. ApplicationListener监听SessionDeleteEvent,执行相应逻辑

当调用HttpSession的invalidate方法让Session失效时,即会调用RedisOperationSessionRepository的deleteById方法删除Session的过期键。

/**
* Allows creating an HttpSession from a Session instance.
*
* @author Rob Winch
* @since 1.0
*/
private final class HttpSessionWrapper extends HttpSessionAdapter<S> {
HttpSessionWrapper(S session, ServletContext servletContext) {
super(session, servletContext);
} @Override
public void invalidate() {
super.invalidate();
SessionRepositoryRequestWrapper.this.requestedSessionInvalidated = true;
setCurrentSession(null);
clearRequestedSessionCache();
// 调用删除方法
SessionRepositoryFilter.this.sessionRepository.deleteById(getId());
}
}

上篇中介绍了包装Spring Session为HttpSession,这里不再赘述。这里重点分析deleteById内容:

@Override
public void deleteById(String sessionId) {
// 如果session为空则返回
RedisSession session = getSession(sessionId, true);
if (session == null) {
return;
} cleanupPrincipalIndex(session);
this.expirationPolicy.onDelete(session);
// 获取session的过期键
String expireKey = getExpiredKey(session.getId());
// 删除过期键,redis键空间产生del事件消息,被MessageListener即
// RedisOperationSessionRepository监听
this.sessionRedisOperations.delete(expireKey);
session.setMaxInactiveInterval(Duration.ZERO);
save(session);
}

后续流程同SessionCreateEvent流程。

3.Session失效事件的触发

Session的过期事件流程比较特殊,因为Redis的键空间通知的特殊性,Redis键空间通知不能保证过期键的通知的及时性。

  1. RedisOperationsSessionRepository中有个定时任务方法每整分运行访问整分Session过期键集合中的过期sessionId,如:spring:session:expirations:1439245080000。触发Redis键空间会向__keyevent@*:expired的channel发布过期事件消息
  2. MessageListener的实现RedisOperationSessionRepository监听到Redis指定通道__keyevent@*:expired的消息
  3. 将其传播至ApplicationEventPublisher
  4. ApplicationEventPublisher发布SessionDeleteEvent
  5. ApplicationListener监听SessionDeleteEvent,执行相应逻辑
@Scheduled(cron = "0 * * * * *")
public void cleanupExpiredSessions() {
this.expirationPolicy.cleanExpiredSessions();
}

定时任务每整分运行,执行cleanExpiredSessions方法。expirationPolicy是RedisSessionExpirationPolicy实例,是RedisSession过期策略。

public void cleanExpiredSessions() {
// 获取当前时间戳
long now = System.currentTimeMillis();
// 时间滚动至整分,去掉秒和毫秒部分
long prevMin = roundDownMinute(now);
if (logger.isDebugEnabled()) {
logger.debug("Cleaning up sessions expiring at " + new Date(prevMin));
}
// 根据整分时间获取过期键集合,如:spring:session:expirations:1439245080000
String expirationKey = getExpirationKey(prevMin);
// 获取所有的所有的过期session
Set<Object> sessionsToExpire = this.redis.boundSetOps(expirationKey).members();
// 删除过期Session键集合
this.redis.delete(expirationKey);
// touch访问所有已经过期的session,触发Redis键空间通知消息
for (Object session : sessionsToExpire) {
String sessionKey = getSessionKey((String) session);
touch(sessionKey);
}
}

将时间戳滚动至整分

static long roundDownMinute(long timeInMs) {
Calendar date = Calendar.getInstance();
date.setTimeInMillis(timeInMs);
// 清理时间错的秒位和毫秒位
date.clear(Calendar.SECOND);
date.clear(Calendar.MILLISECOND);
return date.getTimeInMillis();
}

获取过期Session的集合

String getExpirationKey(long expires) {
return this.redisSession.getExpirationsKey(expires);
} // 如:spring:session:expirations:1439245080000
String getExpirationsKey(long expiration) {
return this.keyPrefix + "expirations:" + expiration;
}

调用Redis的Exists命令,访问过期Session键,触发Redis键空间消息

/**
* By trying to access the session we only trigger a deletion if it the TTL is
* expired. This is done to handle
* https://github.com/spring-projects/spring-session/issues/93
*
* @param key the key
*/
private void touch(String key) {
this.redis.hasKey(key);
}

总结

至此Spring-Session的Session事件通知模块就已经很清晰:

  1. Redis键空间Session事件源:Session创建通道/Session删除通道/Session过期通道
  2. Spring-Session中的RedisOperationsSessionRepository消息监听器监听Redis的事件类型
  3. RedisOperationsSessionRepository负责将其传播至ApplicationEventPublisher
  4. ApplicationEventPublisher将其包装成ApplicationEvent类型的Session Event发布
  5. ApplicationListener监听Session Event,处理相应逻辑

spring-session(一)揭秘续篇的更多相关文章

  1. Re:从零开始的Spring Session(一)

    Session和Cookie这两个概念,在学习java web开发之初,大多数人就已经接触过了.最近在研究跨域单点登录的实现时,发现对于Session和Cookie的了解,并不是很深入,所以打算写两篇 ...

  2. spring session 和 spring security整合

    背景: 我要做的系统前面放置zuul. 使用自己公司提供的单点登录服务.后面的业务应用也是spring boot支撑的rest服务. 目标: 使用spring security管理权限包括权限.用户请 ...

  3. 基于Spring Boot/Spring Session/Redis的分布式Session共享解决方案

    分布式Web网站一般都会碰到集群session共享问题,之前也做过一些Spring3的项目,当时解决这个问题做过两种方案,一是利用nginx,session交给nginx控制,但是这个需要额外工作较多 ...

  4. Spring Session

    开工开工, 准备条件: 1. 本地Redis,官网:http://redis.io/,windows下 https://github.com/ServiceStack/redis-windows ht ...

  5. Spring boot配合Spring session(redis)遇到的错误

    背景:本MUEAS项目,一开始的时候,是没有引入redis的,考虑到后期性能的问题而引入.之前没有引用redis的时候,用户登录是正常的.但是,在加入redis支持后,登录就出错!错误如下: . __ ...

  6. 使用Spring Session做分布式会话管理

    在Web项目开发中,会话管理是一个很重要的部分,用于存储与用户相关的数据.通常是由符合session规范的容器来负责存储管理,也就是一旦容器关闭,重启会导致会话失效.因此打造一个高可用性的系统,必须将 ...

  7. Spring Session - Spring Boot

    The completed guide can be found in the boot sample application. Updating Dependencies Before you us ...

  8. 单点登录实现(spring session+redis完成session共享)

    一.前言 项目中用到的SSO,使用开源框架cas做的.简单的了解了一下cas,并学习了一下 单点登录的原理,有兴趣的同学也可以学习一下,写个demo玩一玩. 二.工程结构 我模拟了 sso的客户端和s ...

  9. Spring Session实现分布式session的简单示例

    前面有用 tomcat-redis-session-manager来实现分布式session管理,但是它有一定的局限性,主要是跟tomcat绑定太紧了,这里改成用Spring Session来管理分布 ...

随机推荐

  1. MES系统如何帮助烟草行业管理生产流程

    与很多其他行业一样,烟草MES系统可以帮助卷烟企业实现智能生产.精益制造.快速实现烟草企业数字化车间的创建,助力企业实现改造升级,从而提升企业生产效率,降低生产成产.烟草行业得MES者得天下. 烟草行 ...

  2. Android四大组件:BroadcastReceiver 介绍

    介绍 BroadcastReceiver 即广播组件,是 Android 的四大组件之一.用于监听和接收广播消息,并做出响应.有以下一些应用: 不同组件之间的通信(应用内或不同应用之间). 多线程之间 ...

  3. Telegram Android源码问题汇总 持续更新

    libtgvoip目录为空 git clone下来的工程中带有submodule时,submodule的内容没有下载下来,执行如下命令 cd Telegram git submodule update ...

  4. 你所不知的 PHP 断言(assert)

    PHP 中的断言常用于调试,检查一个表达式或语句是否为 FALSE.本文带你重新认识 PHP assert() 函数的神(Qi)通(Yin)广(Ji)大(Qiao). 本文基于 PHP Version ...

  5. 笔记7:Jquery知识

    jQuery 1 基本知识 jQuery 是一个 JavaScript 库.jQuery 极大地简化了 JavaScript 编程.其下载地址:http://jquery.com/download/ ...

  6. django之三剑客、静态文件配置、请求响应对象、数据库操作

    三剑客 from django.shortcuts import render,HttpResponse,redirect HttpResponse # 返回字符串 render(response, ...

  7. LeetCode 611. Valid Triangle Number有效三角形的个数 (C++)

    题目: Given an array consists of non-negative integers, your task is to count the number of triplets c ...

  8. ibatis<iterate>标签

    <iterate  property="" 从传入的参数集合中使用属性名去获取值,   这个必须是一个List类型,   否则会出现OutofRangeException, ...

  9. 【oracle】获取指定表空间的所有表名

    select owner||'.'||table_name from dba_tables where tablespace_name='A';

  10. 前端Vue项目——课程详情页面实现

    一.详情页面路由跳转 应用 Vue Router 编程式导航通过 this.$router.push() 来实现路由跳转. 1.绑定查看详情事件 修改 src/components/Course/Co ...