美文网首页安全认证
分布式共享Session之SpringSession源码细节

分布式共享Session之SpringSession源码细节

作者: alexqdjay | 来源:发表于2018-03-17 14:06 被阅读163次

Simple Session

Simple SessionSpring Session的简单实现版,目的是学习Spring Session源码的同时,创建一个更容易使用和理解的框架,功能设计上达到够用就好,简单实现也是为了能根据自己的业务需求方便进行定制。

本文介绍Spring Session的源代码实现细节,以及介绍Simple Session的区别。

1. 替代Session的秘密

几乎所有的方案都类似,使用Filter把请求拦截掉然后包装RequestResponse使得Request.getSession返回的Session也是包装过的,改变了原有Session的行为,譬如存储属性值是把属性值存储在 Redis 中,这样就实现了分布式Session了。

SpringSession使用SessionRepositoryFilter这个过滤器来实现上面所说的。
SimpleSession使用SimpleSessionFilter来实现。

1.1 SessionRepositoryFilter

//包装
HttpServletRequest strategyRequest = this.httpSessionStrategy
        .wrapRequest(wrappedRequest, wrappedResponse);
//再包装
HttpServletResponse strategyResponse = this.httpSessionStrategy
        .wrapResponse(wrappedRequest, wrappedResponse);

try {
    filterChain.doFilter(strategyRequest, strategyResponse);
}
finally {
   // 提交
    wrappedRequest.commitSession();
}

包装的类是SessionRepositoryResponseWrapperSessionRepositoryRequestWrapper对应ResponseRequest

1.2 SessionRepositoryResponseWrapper

继承自OnCommittedResponseWrapper主要目标就是一个,当Response输出完毕后调用commit

@Override
protected void onResponseCommitted() {
    this.request.commitSession();
}

1.3 SessionRepositoryRequestWrapper

这个类功能比较多,因为要改变原有很多跟Session的接口,譬如getSessionisRequestedSessionIdValid等。

当然最重要的是getSession方法,返回的Session是经包装的。

1.3.1 getSession

@Override
public HttpSessionWrapper getSession(boolean create) {
    // currentSession 是存在 request 的 attribute 中
    HttpSessionWrapper currentSession = getCurrentSession();
    // 存在即返回
    if (currentSession != null) {
        return currentSession;
    }
    // 获取请求的 sessionId, Cookie策略的话从cookie里拿, header策略的话在 Http Head 中获取
    String requestedSessionId = getRequestedSessionId();
    // 如果获取到,并且没有‘sessionId失效’标识
    if (requestedSessionId != null
            && getAttribute(INVALID_SESSION_ID_ATTR) == null) {
        // 这里是从 repository 中读取,如 RedisRepository
        S session = getSession(requestedSessionId);
        // 读取到了就恢复出session
        if (session != null) {
            this.requestedSessionIdValid = true;
            currentSession = new HttpSessionWrapper(session, getServletContext());
            currentSession.setNew(false);
            setCurrentSession(currentSession);
            return currentSession;
        }
        // 没有读取到(过期了), 设置‘失效’标识, 下次不用再去 repository 中读取
        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_LOGGER.debug(
                "A new session was created. To help you troubleshoot where the session was created we provided a StackTrace (this is not an error). You can prevent this from appearing by disabling DEBUG logging for "
                        + SESSION_LOGGER_NAME,
                new RuntimeException(
                        "For debugging purposes only (not an error)"));
    }
    // 都没有那么就创建一个新的
    S session = SessionRepositoryFilter.this.sessionRepository.createSession();
    session.setLastAccessedTime(Instant.now());
    currentSession = new HttpSessionWrapper(session, getServletContext());
    setCurrentSession(currentSession);
    return currentSession;
}

这里涉及到SessionRepository下面介绍。

1.3.2 commitSession

由于现在的Session跟之前的已经完全不同,存储属性值更新属性值都是远程操作,使用‘懒操作’模式可以使得频繁的操作更加有效率。

private void commitSession() {
    HttpSessionWrapper wrappedSession = getCurrentSession();
    // 如果没有session,并且已经被标记为失效时,调用 onInvalidateSession 进行通知处理
    if (wrappedSession == null) {
        if (isInvalidateClientSession()) {
            SessionRepositoryFilter.this.httpSessionStrategy
                    .onInvalidateSession(this, this.response);
        }
    }
    // 如果存在就更新属性值
    else {
        S session = wrappedSession.getSession();
        SessionRepositoryFilter.this.sessionRepository.save(session);
        // 如果请求的sessionId跟当前的session的id不同,或者请求的sessionId无效,
        // 则调用 onNewSession 进行通知处理
        if (!isRequestedSessionIdValid()
                || !session.getId().equals(getRequestedSessionId())) {
            SessionRepositoryFilter.this.httpSessionStrategy.onNewSession(session,
                    this, this.response);
        }
    }
}

这里onInvalidateSessiononNewSession都是Strategy的方法,根据不一样的策略采取的处理也不一样。
Strategy有:

  1. CookieHttpSessionStrategy
  2. HeaderHttpSessionStrategy

1.4 Session

SpringSessionsession 有两部分组成:

  1. Session: 接口, 默认实现类MapSession, 它是session的本地对象,存储着属性及一些特征(如:lastAccessedTime), 最终会被同步到远端, 以及从远端获取下来后存储在本地的实体。
  2. HttpSessionAdapter: 为了能让SessionHttpSession 接洽起来而设立的适配器。

1.5 Repository

public interface SessionRepository<S extends Session> {
    // 创建session
    S createSession();
    
    // 保存session
    void save(S session);
    
    // 根据sessionId 获取
    S findById(String id);

    // 删除特定id的session值
    void deleteById(String id);
}

各种实现,最典型用得最多的就是RedisOperationsSessionRepository, 下面整个第2章(Spring Session Redis存储结构)就是讲整个类存储的策略和设计。

2. Spring Session Redis存储结构

session在存储时分为:

  1. session本身的一些属性存储
  2. 专门负责用于过期的key存储
  3. 以时间为key存储在该时间点需要过期的sessionId列表

2.1 为什么需要三个存储结构?

先说明第二存储是用来干嘛的,第二存储一般设置成session的过期时间如30分钟或者15分钟,同时session的客户端会注册一个redis的key过期事件的监听,一旦有key过期客户端有会事件响应和处理。

在处理事件时可能会需要该session的信息,这时候第一个存储就有用了,因此第一个存储的过期时间会比第二存储过期时间多1-3min,这就是为什么需要把属性存储和过期分开的原因。

那第三个session的用处呢?对Redis比较熟悉的同学一定会知道其中的奥秘,因为Redis的key过期方式是定期随机测试是否过期和获取时测试是否过期(也称懒删除),由于定期随机测试Task的优先级是比较低的,所以即便这个key已经过期但是没有测试到所以不会触发key过期的事件。所以,第三个存储的意义在于,存储了什么时间点会过期的session,这样可以去主动请求来触发懒删除,以此触发过期事件。

2.2 Redis 三个key和存储结构

  1. Session主内容存储,key:spring:session:sessions:{SID},内容:Mapkey : value
  2. 过期存储,key:spring:session:sessions:expires:{UUID},内容为空
  3. 过期sessionId列表存储,key:spring:session:expirations:{ExpiryTime},内容Set

2.3 运行方式

因为第二种key的存在,所以会自动失效并且发出事件,但是有延迟,所以有个定时任务在不停地扫描当前分钟过期的key,即扫描第三种key,一旦扫描到就进行删除。

相应事件的程序会把第一种key删除。

2.4 代码细节

2.4.1 更新失效时间

// 更新失效时间
public void onExpirationUpdated(Long originalExpirationTimeInMilli, Session session) {
    // NO.2 key
    String keyToExpire = "expires:" + session.getId();
    // 往后推迟 lastAccessTime + MaxInactiveInterval
    long toExpire = roundUpToNextMinute(expiresInMillis(session));
    // 原来的NO.3 key 清除掉
    if (originalExpirationTimeInMilli != null) {
        long originalRoundedUp = roundUpToNextMinute(originalExpirationTimeInMilli);
        if (toExpire != originalRoundedUp) {
            String expireKey = getExpirationKey(originalRoundedUp);
            this.redis.boundSetOps(expireKey).remove(keyToExpire);
        }
    }

    long sessionExpireInSeconds = session.getMaxInactiveInterval().getSeconds();
    String sessionKey = getSessionKey(keyToExpire);

    // MaxInactiveInterval < 0 Session永久有效
    if (sessionExpireInSeconds < 0) {
        this.redis.boundValueOps(sessionKey).append("");
        this.redis.boundValueOps(sessionKey).persist();
        this.redis.boundHashOps(getSessionKey(session.getId())).persist();
        return;
    }

    // 拼装NO.3 key
    String expireKey = getExpirationKey(toExpire);
    BoundSetOperations<Object, Object> expireOperations = this.redis
            .boundSetOps(expireKey);
    expireOperations.add(keyToExpire);

    long fiveMinutesAfterExpires = sessionExpireInSeconds
            + TimeUnit.MINUTES.toSeconds(5);
    // NO.3 key 过期时间是自身时间+5分钟
    expireOperations.expire(fiveMinutesAfterExpires, TimeUnit.SECONDS);
    // sessionKey -> NO.2 key
    if (sessionExpireInSeconds == 0) {
        this.redis.delete(sessionKey);
    }
    else {
        this.redis.boundValueOps(sessionKey).append("");
        this.redis.boundValueOps(sessionKey)
            .expire(sessionExpireInSeconds, TimeUnit.SECONDS);
    }
    // NO.1 也是过期时间推迟5min
    this.redis.boundHashOps(getSessionKey(session.getId()))
            .expire(fiveMinutesAfterExpires, TimeUnit.SECONDS);
}

2.4.2 Redis事件监听

当 Redis key 过期会往两个频道发布事件,一个是expired频道的key事件,一个是key频道的expired事件。(不过需要开启这个功能)

PUBLISH __keyspace@0__:key expired  
PUBLISH __keyspace@0__:expired key

下面是Spring Session中 Redis 的事件监听。

container.addMessageListener(messageListener,
                Arrays.asList(new PatternTopic("__keyevent@*:del"),
                        new PatternTopic("__keyevent@*:expired")));
container.addMessageListener(messageListener, Arrays.asList(new PatternTopic(messageListener.getSessionCreatedChannelPrefix() + "*")));

事件处理:

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

    String channel = new String(messageChannel);

    // 新建Session
    if (channel.startsWith(getSessionCreatedChannelPrefix())) {
        // 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;
    }

    // 删除及过期Session
    boolean isDeleted = channel.endsWith(":del");
    if (isDeleted || channel.endsWith(":expired")) {
        int beginIndex = body.lastIndexOf(":") + 1;
        int endIndex = body.length();
        String sessionId = body.substring(beginIndex, endIndex);

        RedisSession session = getSession(sessionId, true);

        if (logger.isDebugEnabled()) {
            logger.debug("Publishing SessionDestroyedEvent for session " + sessionId);
        }
        // 清楚登录用户关联的sessionId,如: userId -> set(sessionId)
        cleanupPrincipalIndex(session);

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

        return;
    }
}

其中,handleCreatehandleDeletedhandleExpired都是用于发布Spring Context的本地事件的。

3 Simple Session 简化和优化

3.1 Session存储使用Map

Session主要内容在Redis中存储采用Map结构可以优化读写性能,因为绝大多数属性属于写少读多,如果采用整体做序列化的方式,每次都是整存整取,对于session多个属性操作性能会略快,如果操作属性比较少(如一个)那么性能上会略慢,但整体上讲不会对应用构成瓶颈。

3.2 修改更新

Spring Session 将对session的修改,如创建、销毁以及put属性都做成了在请求最后(Response Commit)再一起保存到 Redis,期间随便操作多少次都不会更新到 Redis 中,这样确实减少了对 Redis 的操作,只要是多于一次的都是优化。(也可以设置成每次操作都进行更新)

但是有个问题,如果 response 已经commit了,这时候再修改session,值将不会更新到Redis,这个也算不足。

Simple Session 对值的修改也采取懒更新或者立即更新,可以通过配置进行切换。懒更新则使用比 Spring 更简单的方式进行,当SimpleSessionFilter执行完毕以后进行提交,所以如果顺序排在前面的Filter(执行after应该在SimpleSessionFilter后面)在chain.doFilter之后就不能再进行session的操作。

3.3 简化存储结构

3 key 式的存储确实是设计巧妙,但是由于Simple Session没有去实现Session变更(create, delete & expired)事件,所以也就没必要去使用 3 key 存储。因此,使用了最简洁的设计:SessionId -> map(key:value),存储Session相关属性及attributes

3.4 功能删减

Spime Session实现了主要功能,包括只实现了Redis存储方案,至于其他方案如:db,使用者根据需要自己按接口实现。至于如:Spring Security的支持,socket场景的支持,都没有纳入主要功能去实现。

4. 代码库

github: https://github.com/alexqdjay/simple-session

相关文章

网友评论

    本文标题:分布式共享Session之SpringSession源码细节

    本文链接:https://www.haomeiwen.com/subject/xxljqftx.html