美文网首页安全认证
分布式共享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