Spring-Session基於Redis管理Session 原

系列文章

Nginx+Tomcat關於Session的管理

Tomcat Session管理分析

Spring-Session基於Redis管理Session

前言

在上文Tomcat Session管理分析介紹了使用tomcat-redis-session-manager來集中式管理session,其中一個侷限性就是必須使用tomcat容器;本文介紹的spring-session也能實現session的集中式管理,並且不侷限於某種容器;

spring-session管理session實戰

1.maven依賴的jar

<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
    <version>1.3.1.RELEASE</version>
    <type>pom</type>
</dependency>
<dependency>
    <groupId>biz.paluch.redis</groupId>
    <artifactId>lettuce</artifactId>
    <version>3.5.0.Final</version>
</dependency>
<dependency>
         <groupId>org.springframework</groupId>
         <artifactId>spring-web</artifactId>
         <version>4.3.4.RELEASE</version>
</dependency>

2.準備spring-session.xml配置文件

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:context="http://www.springframework.org/schema/context"
    xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd">
 
    <!--支持註解 -->
    <context:annotation-config />
 
    <bean
        class="org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration" />
 
    <bean
        class="org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory">
        <property name="hostName" value="localhost" />
        <property name="port" value="6379" />
    </bean>
</beans>

session同樣是使用redis來做集中式存儲,爲了方便測試使用本地的6379端口redis,LettuceConnectionFactory是redis連接工廠類;
RedisHttpSessionConfiguration可以簡單理解爲spring-session使用redis來存儲session的功能類,此類本身使用了@Configuration註解,@Configuration註解相當於把該類作爲spring的xml配置文件中的,此類中包含了很多bean對象同樣也是註解@Bean

3.準備servelt類

public class SSessionTest extends HttpServlet {
    private static final long serialVersionUID = 1L;
 
    public SSessionTest() {
        super();
    }
 
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        response.getWriter().append("sessionId=" + request.getSession().getId());
    }
 
    protected void doPost(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
        doGet(request, response);
    }
}

定義了一個簡單的servelt,每次請求都在界面打印sessionId;

4.配置web.xml

<?xml version="1.0" encoding="UTF-8"?>
<web-app>
    <display-name>Archetype Created Web Application</display-name>
 
    <context-param>
        <param-name>contextConfigLocation</param-name>
        <param-value>classpath*:spring-session.xml</param-value>
    </context-param>
 
    <filter>
        <filter-name>springSessionRepositoryFilter</filter-name>
        <filter-class>org.springframework.web.filter.DelegatingFilterProxy
        </filter-class>
    </filter>
    <filter-mapping>
        <filter-name>springSessionRepositoryFilter</filter-name>
        <url-pattern>/*</url-pattern>
    </filter-mapping>
    <listener>
        <listener-class>org.springframework.web.context.ContextLoaderListener
        </listener-class>
    </listener>
 
    <servlet>
        <servlet-name>SSessionTest</servlet-name>
        <display-name>SSessionTest</display-name>
        <description></description>
        <servlet-class>zh.maven.ssesion.SSessionTest</servlet-class>
    </servlet>
    <servlet-mapping>
        <servlet-name>SSessionTest</servlet-name>
        <url-pattern>/SSessionTest</url-pattern>
    </servlet-mapping>
</web-app>

首先配置了加載類路徑下的spring-session.xml配置文件,然後配置了一個名稱爲springSessionRepositoryFilter的過濾器;這裏定義的class是類DelegatingFilterProxy,此類本身並不是過濾器,是一個代理類,可以通過使用targetBeanName參數來指定具體的過濾器類(如下所示),如果不指定默認就是filter-name指定的名稱;

<init-param>
    <param-name>targetBeanName</param-name>
    <param-value>springSessionRepositoryFilter</param-value>
</init-param>

5.測試

瀏覽器中訪問:http://localhost:8080/ssession/SSessionTest,查看結果:

sessionId=d520abed-829f-4d0d-9b51-5e9bc9c7e7f2

查看redis

127.0.0.1:6379> keys *
1) "spring:session:expirations:1530194760000"
2) "spring:session:sessions:expires:d520abed-829f-4d0d-9b51-5e9bc9c7e7f2"
3) "spring:session:sessions:d520abed-829f-4d0d-9b51-5e9bc9c7e7f2"

6.常見問題

具體異常如下:

org.springframework.beans.factory.NoSuchBeanDefinitionException: No bean named 'springSessionRepositoryFilter' available
    at org.springframework.beans.factory.support.DefaultListableBeanFactory.getBeanDefinition(DefaultListableBeanFactory.java:680)
    at org.springframework.beans.factory.support.AbstractBeanFactory.getMergedLocalBeanDefinition(AbstractBeanFactory.java:1183)
    at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:284)
    at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202)
    at org.springframework.context.support.AbstractApplicationContext.getBean(AbstractApplicationContext.java:1087)
    at org.springframework.web.filter.DelegatingFilterProxy.initDelegate(DelegatingFilterProxy.java:326)
    at org.springframework.web.filter.DelegatingFilterProxy.initFilterBean(DelegatingFilterProxy.java:235)
    at org.springframework.web.filter.GenericFilterBean.init(GenericFilterBean.java:199)
    at org.apache.catalina.core.ApplicationFilterConfig.initFilter(ApplicationFilterConfig.java:285)
    at org.apache.catalina.core.ApplicationFilterConfig.getFilter(ApplicationFilterConfig.java:266)
    at org.apache.catalina.core.ApplicationFilterConfig.<init>(ApplicationFilterConfig.java:108)
    at org.apache.catalina.core.StandardContext.filterStart(StandardContext.java:4981)
    at org.apache.catalina.core.StandardContext.startInternal(StandardContext.java:5683)
    at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:145)
    at org.apache.catalina.core.ContainerBase$StartChild.call(ContainerBase.java:1702)
    at org.apache.catalina.core.ContainerBase$StartChild.call(ContainerBase.java:1692)
    at java.util.concurrent.FutureTask.run(FutureTask.java:262)
    at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1145)
    at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:615)
    at java.lang.Thread.run(Thread.java:745)

指定的filter找不到實現類,原因是沒有使用配置,此配置可以讓系統能夠識別相應的註解,而在類RedisHttpSessionConfiguration中使用了大量的註解,其中就有個使用@Bean註解的方法;

spring-session管理session分析

1.DelegatingFilterProxy代理類

DelegatingFilterProxy裏沒有實現過濾器的任何邏輯,具體邏輯在其指定的filter-name過濾器中;

@Override
    protected void initFilterBean() throws ServletException {
        synchronized (this.delegateMonitor) {
            if (this.delegate == null) {
                // If no target bean name specified, use filter name.
                if (this.targetBeanName == null) {
                    this.targetBeanName = getFilterName();
                }
                // Fetch Spring root application context and initialize the delegate early,
                // if possible. If the root application context will be started after this
                // filter proxy, we'll have to resort to lazy initialization.
                WebApplicationContext wac = findWebApplicationContext();
                if (wac != null) {
                    this.delegate = initDelegate(wac);
                }
            }
        }
    }

初始化過濾器,如果沒有配置targetBeanName,則直接使用filter-name,這裏指定的是springSessionRepositoryFilter,這個名稱是一個固定值此filter在RedisHttpSessionConfiguration中被定義;

2.RedisHttpSessionConfiguration配置類

在RedisHttpSessionConfiguration的父類SpringHttpSessionConfiguration中定義了springSessionRepositoryFilter

@Bean
    public <S extends ExpiringSession> SessionRepositoryFilter<? extends ExpiringSession> springSessionRepositoryFilter(
            SessionRepository<S> sessionRepository) {
        SessionRepositoryFilter<S> sessionRepositoryFilter = new SessionRepositoryFilter<S>(
                sessionRepository);
        sessionRepositoryFilter.setServletContext(this.servletContext);
        if (this.httpSessionStrategy instanceof MultiHttpSessionStrategy) {
            sessionRepositoryFilter.setHttpSessionStrategy(
                    (MultiHttpSessionStrategy) this.httpSessionStrategy);
        }
        else {
            sessionRepositoryFilter.setHttpSessionStrategy(this.httpSessionStrategy);
        }
        return sessionRepositoryFilter;
    }

此方法返回值是SessionRepositoryFilter,這個其實就是真實的過濾器;方法參數sessionRepository同樣使用@Bean註解的方式定義;

@Bean
    public RedisOperationsSessionRepository sessionRepository(
            @Qualifier("sessionRedisTemplate") RedisOperations<Object, Object> sessionRedisTemplate,
            ApplicationEventPublisher applicationEventPublisher) {
        RedisOperationsSessionRepository sessionRepository = new RedisOperationsSessionRepository(
                sessionRedisTemplate);
        sessionRepository.setApplicationEventPublisher(applicationEventPublisher);
        sessionRepository
                .setDefaultMaxInactiveInterval(this.maxInactiveIntervalInSeconds);
        if (this.defaultRedisSerializer != null) {
            sessionRepository.setDefaultSerializer(this.defaultRedisSerializer);
        }
 
        String redisNamespace = getRedisNamespace();
        if (StringUtils.hasText(redisNamespace)) {
            sessionRepository.setRedisKeyNamespace(redisNamespace);
        }
 
        sessionRepository.setRedisFlushMode(this.redisFlushMode);
        return sessionRepository;
    }

此方法的返回值是RedisOperationsSessionRepository,有關於session持久化到redis的相關操作都在此類中;
注:持久化到redis只是spring-session的一種方式,也支持持久化到其他數據庫中(jdbc,Mongo,Hazelcast等);

3.SessionRepositoryFilter過濾器

所有的請求都會先經過SessionRepositoryFilter過濾器,doFilter方法如下:

protected void doFilterInternal(HttpServletRequest request,
        HttpServletResponse response, FilterChain filterChain)
        throws ServletException, IOException {
    request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);
 
    SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryRequestWrapper(
            request, response, this.servletContext);
    SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryResponseWrapper(
            wrappedRequest, response);
 
    HttpServletRequest strategyRequest = this.httpSessionStrategy
            .wrapRequest(wrappedRequest, wrappedResponse);
    HttpServletResponse strategyResponse = this.httpSessionStrategy
            .wrapResponse(wrappedRequest, wrappedResponse);
 
    try {
        filterChain.doFilter(strategyRequest, strategyResponse);
    }
    finally {
        wrappedRequest.commitSession();
    }
}

request被包裝成了SessionRepositoryRequestWrapper對象,response被包裝成了SessionRepositoryResponseWrapper對象,SessionRepositoryRequestWrapper中重寫了getSession等方法;finally中執行了commitSession方法,將session進行持久化操作;

4.SessionRepositoryRequestWrapper包裝類

重點看一下重寫的getSession方法,代碼如下:

@Override
        public HttpSessionWrapper getSession(boolean create) {
            HttpSessionWrapper currentSession = getCurrentSession();
            if (currentSession != null) {
                return currentSession;
            }
            String requestedSessionId = getRequestedSessionId();
            if (requestedSessionId != null
                    && getAttribute(INVALID_SESSION_ID_ATTR) == null) {
                S session = getSession(requestedSessionId);
                if (session != null) {
                    this.requestedSessionIdValid = true;
                    currentSession = new HttpSessionWrapper(session, getServletContext());
                    currentSession.setNew(false);
                    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_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(System.currentTimeMillis());
            currentSession = new HttpSessionWrapper(session, getServletContext());
            setCurrentSession(currentSession);
            return currentSession;
        }
 
        private S getSession(String sessionId) {
            S session = SessionRepositoryFilter.this.sessionRepository
                    .getSession(sessionId);
            if (session == null) {
                return null;
            }
            session.setLastAccessedTime(System.currentTimeMillis());
            return session;
        }

大致分爲三步,首先去本地內存中獲取session,如果獲取不到去指定的數據庫中獲取,這裏其實就是去redis裏面獲取,sessionRepository就是上面定義的RedisOperationsSessionRepository對象;如果redis裏面也沒有則創建一個新的session;

5.RedisOperationsSessionRepository類

關於session的保存,更新,刪除,獲取操作都在此類中;

5.1保存session

每次在消息處理完之後,會執行finally中的commitSession方法,每個session被保存都會創建三組數據,如下所示:

127.0.0.1:6379> keys *
1) "spring:session:expirations:1530254160000"
2) "spring:session:sessions:expires:d5e0f376-69d1-4fd4-9802-78eb5a3db144"
3) "spring:session:sessions:d5e0f376-69d1-4fd4-9802-78eb5a3db144"

hash結構記錄
key格式:spring:session:sessions:[sessionId],對應的value保存session的所有數據包括:creationTime,maxInactiveInterval,lastAccessedTime,attribute;
set結構記錄
key格式:spring:session:expirations:[過期時間],對應的value爲expires:[sessionId]列表,有效期默認是30分鐘,即1800秒;
string結構記錄
key格式:spring:session:sessions:expires:[sessionId],對應的value爲空;該數據的TTL表示sessionId過期的剩餘時間;

相關代碼如下:

public void onExpirationUpdated(Long originalExpirationTimeInMilli,
            ExpiringSession session) {
        String keyToExpire = "expires:" + session.getId();
        long toExpire = roundUpToNextMinute(expiresInMillis(session));
 
        if (originalExpirationTimeInMilli != null) {
            long originalRoundedUp = roundUpToNextMinute(originalExpirationTimeInMilli);
            if (toExpire != originalRoundedUp) {
                String expireKey = getExpirationKey(originalRoundedUp);
                this.redis.boundSetOps(expireKey).remove(keyToExpire);
            }
        }
 
        long sessionExpireInSeconds = session.getMaxInactiveIntervalInSeconds();
        String sessionKey = getSessionKey(keyToExpire);
 
        if (sessionExpireInSeconds < 0) {
            this.redis.boundValueOps(sessionKey).append("");
            this.redis.boundValueOps(sessionKey).persist();
            this.redis.boundHashOps(getSessionKey(session.getId())).persist();
            return;
        }
 
        String expireKey = getExpirationKey(toExpire);
        BoundSetOperations<Object, Object> expireOperations = this.redis
                .boundSetOps(expireKey);
        expireOperations.add(keyToExpire);
 
        long fiveMinutesAfterExpires = sessionExpireInSeconds
                + TimeUnit.MINUTES.toSeconds(5);
 
        expireOperations.expire(fiveMinutesAfterExpires, TimeUnit.SECONDS);
        if (sessionExpireInSeconds == 0) {
            this.redis.delete(sessionKey);
        }
        else {
            this.redis.boundValueOps(sessionKey).append("");
            this.redis.boundValueOps(sessionKey).expire(sessionExpireInSeconds,
                    TimeUnit.SECONDS);
        }
        this.redis.boundHashOps(getSessionKey(session.getId()))
                .expire(fiveMinutesAfterExpires, TimeUnit.SECONDS);
    }
 
    static long expiresInMillis(ExpiringSession session) {
        int maxInactiveInSeconds = session.getMaxInactiveIntervalInSeconds();
        long lastAccessedTimeInMillis = session.getLastAccessedTime();
        return lastAccessedTimeInMillis + TimeUnit.SECONDS.toMillis(maxInactiveInSeconds);
    }
 
    static long roundUpToNextMinute(long timeInMs) {
        Calendar date = Calendar.getInstance();
        date.setTimeInMillis(timeInMs);
        date.add(Calendar.MINUTE, 1);
        date.clear(Calendar.SECOND);
        date.clear(Calendar.MILLISECOND);
        return date.getTimeInMillis();
    }

getMaxInactiveIntervalInSeconds默認是1800秒,expiresInMillis返回了一個到期的時間戳;roundUpToNextMinute方法在此基礎上添加了1分鐘,並且清除了秒和毫秒,返回的long值被用來當做key,用來記錄一分鐘內應當過期的key列表,也就是上面的set結構記錄;
後面的代碼分別爲以上三個key值指定了有效期,spring:session:sessions:expires是30分鐘,而另外2個都是35分鐘;
理論上只需要爲spring:session:sessions:[sessionId]指定有效期就行了,爲什麼還要再保存兩個key,官方的說法是依賴redis自身提供的有效期並不能保證及時刪除;

5.2定期刪除

除了依賴redis本身的有效期機制,spring-session提供了一個定時器,用來定期檢查需要被清理的session;

@Scheduled(cron = "${spring.session.cleanup.cron.expression:0 * * * * *}")
public void cleanupExpiredSessions() {
    this.expirationPolicy.cleanExpiredSessions();
}
 
public void cleanExpiredSessions() {
    long now = System.currentTimeMillis();
    long prevMin = roundDownMinute(now);
 
    if (logger.isDebugEnabled()) {
        logger.debug("Cleaning up sessions expiring at " + new Date(prevMin));
    }
 
    String expirationKey = getExpirationKey(prevMin);
    Set<Object> sessionsToExpire = this.redis.boundSetOps(expirationKey).members();
    this.redis.delete(expirationKey);
    for (Object session : sessionsToExpire) {
        String sessionKey = getSessionKey((String) session);
        touch(sessionKey);
    }
}
 
/**
 * 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);
}

同樣是通過roundDownMinute方法來獲取key,獲取這一分鐘內要被刪除的session,此value是set數據結構,裏面存放這需要被刪除的sessionId;
(注:這裏面存放的的是spring:session:sessions:expires:[sessionId],並不是實際存儲session數據的spring:session:sessions:[sessionId])
首先刪除了spring:session:expirations:[過期時間],然後遍歷set執行touch方法,並沒有直接執行刪除操作,看touch方法的註釋大致意義就是嘗試訪問一下key,如果key已經過去則觸發刪除操作,利用了redis本身的特性;

5.3鍵空間通知(keyspace notification)

定期刪除機制並沒有刪除實際存儲session數據的spring:session:sessions:[sessionId],這裏利用了redis的keyspace notification功能,大致就是通過命令產生一個通知,具體什麼命令可以配置(包括:刪除,過期等)具體可以查看:http://redisdoc.com/topic/not...
spring-session的keyspace notification配置在ConfigureNotifyKeyspaceEventsAction類中,RedisOperationsSessionRepository負責接收消息通知,具體代碼如下:

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);
 
        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;
        }
 
        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);
            }
 
            cleanupPrincipalIndex(session);
 
            if (isDeleted) {
                handleDeleted(sessionId, session);
            }
            else {
                handleExpired(sessionId, session);
            }
 
            return;
        }
    }

接收已spring:session:sessions:expires開頭的通知,然後截取出sessionId,然後通過sessionId刪除實際存儲session的數據;
此處有個疑問就是爲什麼要引入spring:session:sessions:expires:[sessionId]類型key,spring:session:expirations的value直接保存spring:session:sessions:[sessionId]不就可以了嗎,這裏使用此key的目的可能是讓有效期和實際的數據分開,如果不這樣有地方監聽到session過期,而此時session已經被移除,導致獲取不到session的內容;並且在上面設置有效期的時候,spring:session:sessions:[sessionId]的有效期多了5分鐘,應該也是爲了這個考慮的;

總結

比起之前介紹的tomcat-redis-session-manager來管理session,spring-session引入了更多的鍵值,並且還引入了定時器,這無疑增加了複雜性和額外的開銷,實際項目具體使用哪種方式還需要權衡一下。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章