spring-session没有HttpSessionAttributeListener的解决办法

spring-session中对于servlet3.0标准中HttpSessionAttributeListener监听器没有支持,所以如果想要监听spring-session中session属性的变化就需要自己编码处理,经上网查阅资料与源码,问题得以解决。

起因


  • 最近的项目需要统计登录session,但是现有的系统中,用户访问后创建的session不一定是登录的session,只有在session的属性中设置了指定的值才算作登录。servlet3.0中支持HttpSessionAttributeListener用来监听session属性的修改,但是蛋疼的是spring-session中对于servlet标准监听器只支持HttpSessionListener,所以得自己想办法解决。

问题剖析


  • spring-session的GitHub repository上有人提了issue,spring-session目前还不支持HttpSessionAttributeListener,通过网上对于spring-session源码解析的资料,得知spring-session对于HttpSessionListener的处理是通过redisRedis Keyspace Notifications功能实现的。为此,我们可以参考HttpSessionListener的实现逻辑,处理下session属性的监听。
  • spring-session在创建session后,会持久化session到redis中,对于一个session,会创建3个redis的值,分别为:

    • spring:session:sessions:+sessionId:此值为hash,其中存储了session中的属性,还包括session的持续时长、上次访问时间以及创建时间
    • spring:session:sessions:expires:+sessionId:此key中没有值,主要是利用这个key的过期时间,确定session是否过期
    • spring:session:expirations:+时间戳:此值为set,其中存储了对应时间过期的sessionid,通过源码可以得知,此处所有的时间戳都是整分钟的时间戳,spring-session在处理时会先将session的过期时间取下一个最近的整分钟时间戳,并将sessionId存储到对应的set中。
  • spring-session中在操作redis时,主要是通过RedisOperationsSessionRepository这个类实现的,我们可以在业务代码中注入此类的对象,即可操作session。RedisOperationsSessionRepository中涉及到保存session到redis的方法是 save() 方法,代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    public void save(RedisSession session) {
    session.saveDelta(); // 保存session到redis
    if (session.isNew()) { // session是新建的,需要通过pub/sub发布消息,触发httpSessionListener,不在本篇文章分析的范围内
    String sessionCreatedKey = getSessionCreatedChannel(session.getId());
    this.sessionRedisOperations.convertAndSend(sessionCreatedKey, session.delta);
    session.setNew(false);
    }
    }

    其中最主要的就是 session.saveDelta() 方法,RedisSession代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    /**
    * A custom implementation of {@link Session} that uses a {@link MapSession} as the
    * basis for its mapping. It keeps track of any attributes that have changed. When
    * {@link org.springframework.session.data.redis.RedisOperationsSessionRepository.RedisSession#saveDelta()}
    * is invoked all the attributes that have been changed will be persisted.
    *
    * @author Rob Winch
    * @since 1.0
    */
    final class RedisSession implements Session {
    private final MapSession cached; // 内存中的session
    private Instant originalLastAccessTime; // 上次访问时间
    private Map<String, Object> delta = new HashMap<>(); // 更新的数据
    private boolean isNew;// 是否发布过创建的消息
    private String originalPrincipalName;

    /**
    * Creates a new instance ensuring to mark all of the new attributes to be
    * persisted in the next save operation.
    */
    RedisSession() {
    this(new MapSession());
    // 新建session的时候会默认将创建时间、上次访问时间和session有效期添加到session
    this.delta.put(CREATION_TIME_ATTR, getCreationTime().toEpochMilli());
    this.delta.put(MAX_INACTIVE_ATTR, (int) getMaxInactiveInterval().getSeconds());
    this.delta.put(LAST_ACCESSED_ATTR, getLastAccessedTime().toEpochMilli());
    this.isNew = true;
    this.flushImmediateIfNecessary();
    }
    ...
    省略无关代码
    ...

    /**
    * Saves any attributes that have been changed and updates the expiration of this
    * session.
    */
    // 保存session到redis的方法
    private void saveDelta() {
    if (this.delta.isEmpty()) {
    return;
    }
    String sessionId = getId();
    // 此处为保存session属性,delta为更新的session信息
    getSessionBoundHashOperations(sessionId).putAll(this.delta);
    String principalSessionKey = getSessionAttrNameKey(
    ...
    省略无关代码
    ...
    this.delta = new HashMap<>(this.delta.size());
    // 下面是更新过期时间的逻辑
    Long originalExpiration = this.originalLastAccessTime == null ? null
    : this.originalLastAccessTime.plus(getMaxInactiveInterval()).toEpochMilli();
    RedisOperationsSessionRepository.this.expirationPolicy
    .onExpirationUpdated(originalExpiration, this);
    }
    }

    getSessionBoundHashOperations方法代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    private String keyPrefix = DEFAULT_SPRING_SESSION_REDIS_PREFIX;
    static final String DEFAULT_SPRING_SESSION_REDIS_PREFIX = "spring:session:";

    private BoundHashOperations<Object, Object, Object> getSessionBoundHashOperations(
    String sessionId) {
    // 此处拼装得到的就是spring:session:sessions:sessionId
    String key = getSessionKey(sessionId);
    // 操作redis中session对应的set,更新hashkey\hashvalue
    return this.sessionRedisOperations.boundHashOps(key);
    }

    String getSessionKey(String sessionId) {
    return this.keyPrefix + "sessions:" + sessionId;
    }

    可以看到,最终session属性变化的时候,会从RedisSession的cache的MapSession中将属性持久化到redis对应的set中。

  • redisRedis Keyspace Notifications,官网文档指出,此功能可以在redis的数据发生变化时,通过 Pub/Sub 将变动发布到对应的频道,可以用来监控数据的变化,例如

    • 订阅频道:__keyspace@0__:mykey 可以监控db0上的key为mykey的数据变化;
    • 订阅频道:__keyspace@0__:del 可以监控db0上的所有del命令操作的数据;

    如此一来便可以通过Keyspace Notifications订阅频道,获取session中属性的变化

解决方案


  • 整体的思路就是监听spring-session对应的redis的db的hsethdel操作,当检测到向session中设置值时,即可触发对应的类,并调用需要调用的方法即可。以监听hset为例,具体代码如下:

  • 在配置文件中增加redis监听配置

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    <!-- 定义Spring Redis的序列化器 -->
    <!-- String序列化 -->
    <bean id="stringRedisSerializer" class="org.springframework.data.redis.serializer.StringRedisSerializer"/>

    <!-- 将监听实现类注册到spring容器中 -->
    <bean id="dataSyncEventListener" class="com.yuyu.listener.LoginAttributeListener"/>
    <!-- 注册监听器并引入监听实现类 -->
    <bean id="messageListener" class="org.springframework.data.redis.listener.adapter.MessageListenerAdapter">
    <property name="delegate" ref="dataSyncEventListener"/>
    <property name="serializer" ref="stringRedisSerializer"/>
    </bean>

    <!-- 消息监听 -->
    <redis:listener-container>
    <!--指定消息处理方法,序列化方式及主题名称-->
    <redis:listener ref="messageListener" method="onMessage" serializer="stringRedisSerializer" topic="__keyevent@0__:hset"/>
    </redis:listener-container>
  • 增加MessageListener的实现类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    public class SessionAttributeListener implements MessageListener {
    // 注入session操作类
    @Resource(name = "sessionRepository")
    private RedisOperationsSessionRepository sessionRepository;

    /**
    * 监听redis的hset操作,并判断是否是向session中写入属性
    */
    @Override
    public void onMessage(Message message, byte[] pattern) {
    if (message == null || message.getChannel() == null
    || message.getBody() == null) {
    return;
    }
    // 获取频道发布的消息
    String messageStr = new String(message.getBody());
    // 向session中设置属性了
    if(messageStr.startsWith("spring:session:sessions:")){
    // 获取sessionId
    String sessionId = messageStr.substring(messageStr.lastIndexOf(":") + 1);
    // 获取session
    Session addAttributeSession = sessionRepository.getSession(sessionId);
    }
    }
    }