Spring-Session基於Redis管理Sessiongit
在上文Tomcat Session管理分析介紹了使用tomcat-redis-session-manager來集中式管理session,其中一個侷限性就是必須使用tomcat容器;本文介紹的spring-session也能實現session的集中式管理,而且不侷限於某種容器;github
<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>
<?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;web
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;redis
<?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指定的名稱;spring
<init-param> <param-name>targetBeanName</param-name> <param-value>springSessionRepositoryFilter</param-value> </init-param>
瀏覽器中訪問:http://localhost:8080/ssession/SSessionTest,查看結果:數據庫
sessionId=d520abed-829f-4d0d-9b51-5e9bc9c7e7f2
查看redisexpress
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"
具體異常以下:apache
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註解的方法;
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中被定義;
在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等);
全部的請求都會先通過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進行持久化操做;
重點看一下重寫的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;
關於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自身提供的有效期並不能保證及時刪除;
除了依賴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自己的特性;
按期刪除機制並無刪除實際存儲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引入了更多的鍵值,而且還引入了定時器,這無疑增長了複雜性和額外的開銷,實際項目具體使用哪一種方式還須要權衡一下。