spring-session揭秘:
一.為什么要spring-session
在傳統單機web應用中,一般使用tomcat/jetty等web容器時,用戶的session都是由容器管理。瀏覽器使用cookie中記錄sessionId,容器根據sessionId判斷用戶是否存在會話session。這里的限制是,session存儲在web容器中,被單台服務器容器管理。
但是網站主鍵演變,分布式應用和集群是趨勢(提高性能)。此時用戶的請求可能被負載分發至不同的服務器,此時傳統的web容器管理用戶會話session的方式即行不通。除非集群或者分布式web應用能夠共享session,盡管tomcat等支持這樣做。但是這樣存在以下兩點問題:
-
需要侵入web容器,提高問題的復雜
-
web容器之間共享session,集群機器之間勢必要交互耦合
基於這些,必須提供新的可靠的集群分布式/集群session的解決方案,突破traditional-session單機限制(即web容器session方式,下面簡稱traditional-session),spring-session應用而生。
二.比較traditional-session方案和spring-session方案
下圖展示了traditional-session和spring-session的區別
傳統模式中,當request進入web容器,根據reqest獲取session時,如果web容器中存在session則返回,如果不存在,web容器則創建一個session。然后返回response時,將sessonId作為response的head一並返回給客戶端或者瀏覽器。
但traditional-session的局限性在於:單機session。在此限制的相反面,即將session從web容器中抽出來,形成獨立的模塊,以便分布式應用或者集群都能共享,即能解決。
spring-session的核心思想在於此:將session從web容器中剝離,存儲在獨立的存儲服務器中。目前支持多種形式的session存儲器:Redis、Database、MogonDB等。session的管理責任委托給spring-session承擔。當request進入web容器,根據request獲取session時,由spring-session負責存存儲器中獲取session,如果存在則返回,如果不存在則創建並持久化至存儲器中。
三.JSR340規范與spring-session的透明繼承
JSR340是Java Servlet 3.1的規范提案,其中定義了大量的api,包括:servlet、servletRequest/HttpServletRequest/HttpServletRequestWrapper、servletResponse/HttpServletResponse/HttpServletResponseWrapper、Filter、Session等,是標准的web容器需要遵循的規約,如tomcat/jetty/weblogic等等。在日常的應用開發中,develpers也在頻繁的使用servlet-api,比如:
以下的方式獲取請求的session:
HttpServletRequest request = ... HttpSession session = request.getSession(false);
其中HttpServletRequest和HttpSession都是servlet規范中定義的接口,web容器實現的標准。那如果引入spring-session,要如何獲取session?
-
遵循servlet規范,同樣方式獲取session,對應用代碼無侵入且對於developers透明化
-
全新實現一套session規范,定義一套新的api和session管理機制
兩種方案都可以實現,但是顯然第一種更友好,且具有兼容性。spring-session正是第一種方案的實現。
實現第一種方案的關鍵點在於做到透明和兼容
-
接口適配:仍然使用HttpServletRequest獲取session,獲取到的session仍然是HttpSession類型——適配器模式
-
類型包裝增強:Session不能存儲在web容器內,要外化存儲——裝飾模式
讓人興奮的是,以上的需求在Servlet規范中的擴展性都是予以支持!Servlet規范中定義一系列的接口都是支持擴展,同時提供Filter支撐擴展點。建議閱讀《JavaTM Servlet Specification》。
Spring Session探索
一.特點
spring-session在無需綁定web容器的情況下提供對集群session的支持。並提供對以下情況的透明集成:
-
HttpSession:容許替換web容器的HttpSession
-
WebSocket:使用WebSocket通信時,提供Session的活躍
-
WebSession:容許以應用中立的方式替換webflux的webSession
二.工作原理
再詳細閱讀源碼之前先來看張圖,介紹下spring-session中的核心模塊以及之間的交互。
spring-session分為以下核心模塊:
-
SessionRepositoryFilter:Servlet規范中Filter的實現,用來切換HttpSession至Spring Session,包裝HttpServletRequest和HttpServletResponse
-
HttpServerletRequest/HttpServletResponse/HttpSessionWrapper包裝器:包裝原有的HttpServletRequest、HttpServletResponse和Spring Session,實現切換Session和透明繼承HttpSession的關鍵之所在
-
Session:Spring Session模塊
-
SessionRepository:管理Spring Session的模塊
-
HttpSessionStrategy:映射HttpRequst和HttpResponse到Session的策略
1. SessionRepositoryFilter
SessionRepositoryFilter是一個Filter過濾器,符合Servlet的規范定義,用來修改包裝請求和響應。這里負責包裝切換HttpSession至Spring Session的請求和響應。
@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { // 設置SessionRepository至Request的屬性中 request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository); // 包裝原始HttpServletRequest至SessionRepositoryRequestWrapper SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryRequestWrapper( request, response, this.servletContext); // 包裝原始HttpServletResponse響應至SessionRepositoryResponseWrapper SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryResponseWrapper( wrappedRequest, response); // 設置當前請求的HttpSessionStrategy策略 HttpServletRequest strategyRequest = this.httpSessionStrategy .wrapRequest(wrappedRequest, wrappedResponse); // 設置當前響應的HttpSessionStrategy策略 HttpServletResponse strategyResponse = this.httpSessionStrategy .wrapResponse(wrappedRequest, wrappedResponse); try { filterChain.doFilter(strategyRequest, strategyResponse); } finally { // 提交session wrappedRequest.commitSession(); } }
以上是SessionRepositoryFilter的核心操作,每個HttpRequest進入,都會被該Filter包裝成切換Session的請求很響應對象。
Tips:責任鏈模式
Filter是Servlet規范中的非常重要的組件,在tomcat的實現中使用了責任鏈模式,將多個Filter組織成鏈式調用。Filter的作用就是在業務邏輯執行前后對請求和響應做修改配置。配合HttpServletRequestWrapper和HttpServletResponseWrapper使用,可謂威力驚人!
2. SessionRepositoryRequestWrapper
對於developers獲取HttpSession的api
HttpServletRequest request = ...; HttpSession session = request.getSession(true);
在spring session中request的實際類型SessionRepositoryRequestWrapper。調用SessionRepositoryRequestWrapper的getSession方法會觸發創建spring session,而非web容器的HttpSession。SessionRepositoryRequestWrapper用來包裝原始的HttpServletRequest實現HttpSession切換至Spring Session。是透明Spring Session透明集成HttpSession的關鍵。
private final class SessionRepositoryRequestWrapper extends HttpServletRequestWrapper { private final String CURRENT_SESSION_ATTR = HttpServletRequestWrapper.class .getName(); // 當前請求sessionId有效 private Boolean requestedSessionIdValid; // 當前請求sessionId無效 private boolean requestedSessionInvalidated; private final HttpServletResponse response; private final ServletContext servletContext; private SessionRepositoryRequestWrapper(HttpServletRequest request, HttpServletResponse response, ServletContext servletContext) { // 調用HttpServletRequestWrapper構造方法,實現包裝 super(request); this.response = response; this.servletContext = servletContext; } }
SessionRepositoryRequestWrapper繼承Servlet規范中定義的包裝器HttpServletRequestWrapper。HttpServletRequestWrapper是Servlet規范api提供的用於擴展HttpServletRequest的擴張點——即裝飾器模式,可以通過重寫一些api達到功能點的增強和自定義。
Tips:裝飾器模式
裝飾器模式(包裝模式)是對功能增強的一種絕佳模式。實際利用的是面向對象的多態性實現擴展。Servlet規范中開放此HttpServletRequestWrapper接口,是讓developers自行擴展實現。這種使用方式和jdk中的FilterInputStream/FilterInputStream如出一轍。
HttpServletRequestWrapper中持有一個HttpServletRequest對象,然后實現HttpServletRequest接口的所有方法,所有方法實現中都是調用持有的HttpServletRequest對象的相應的方法。繼承HttpServletRequestWrapper 可以對其重寫。SessionRepositoryRequestWrapper繼承HttpServletRequestWrapper,在構造方法中將原有的HttpServletRequest通過調用super完成對HttpServletRequestWrapper中持有的HttpServletRequest初始化賦值,然后重寫和session相關的方法。這樣就保證SessionRepositoryRequestWrapper的其他方法調用都是使用原有的HttpServletRequest的數據,只有session相關的是重寫的邏輯。
Tips:
這里的設計是否很精妙!一切都多虧與Servlet規范設計的的巧妙啊!
@Override public HttpSessionWrapper getSession() { return getSession(true); }
重寫HttpServletRequest的getSession()方法,調用有參數getSession(arg)方法,默認為true,表示當前reques沒有session時創建session。繼續看下有參數getSession(arg)的重寫邏輯.
@Override public HttpSessionWrapper getSession(boolean create) { // 從當前請求的attribute中獲取session,如果有直接返回 HttpSessionWrapper currentSession = getCurrentSession(); if (currentSession != null) { return currentSession; } // 獲取當前request的sessionId,這里使用了HttpSessionStrategy // 決定怎樣將Request映射至Session,默認使用Cookie策略,即從cookies中解析sessionId String requestedSessionId = getRequestedSessionId(); // 請求的如果sessionId存在且當前request的attribute中的沒有session失效屬性 // 則根據sessionId獲取spring session if (requestedSessionId != null && getAttribute(INVALID_SESSION_ID_ATTR) == null) { S session = getSession(requestedSessionId); // 如果spring session不為空,則將spring session包裝成HttpSession並 // 設置到當前Request的attribute中,防止同一個request getsession時頻繁的到存儲器 //中獲取session,提高性能 if (session != null) { this.requestedSessionIdValid = true; currentSession = new HttpSessionWrapper(session, getServletContext()); currentSession.setNew(false); setCurrentSession(currentSession); return currentSession; } // 如果根據sessionId,沒有獲取到session,則設置當前request屬性,此sessionId無效 // 同一個請求中獲取session,直接返回無效 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"); } } // 判斷是否創建session 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)")); } // 根據sessionRepository創建spring session S session = SessionRepositoryFilter.this.sessionRepository.createSession(); // 設置session的最新訪問時間 session.setLastAccessedTime(System.currentTimeMillis()); // 包裝成HttpSession透明化集成 currentSession = new HttpSessionWrapper(session, getServletContext()); // 設置session至Requset的attribute中,提高同一個request訪問session的性能 setCurrentSession(currentSession); return currentSession; }
再來看下spring session的持久化。上述SessionRepositoryFilter在包裝HttpServletRequest后,執行FilterChain中使用finally保證請求的Session始終session會被提交,此提交操作中將sesionId設置到response的head中並將session持久化至存儲器中。持久化只持久spring session,並不是將spring session包裝后的HttpSession持久化,因為HttpSession不過是包裝器,持久化沒有意義。
/** * Uses the HttpSessionStrategy to write the session id to the response and * persist the Session. */ private void commitSession() { // 獲取當前session HttpSessionWrapper wrappedSession = getCurrentSession(); // 如果當前session為空,則刪除cookie中的相應的sessionId if (wrappedSession == null) { if (isInvalidateClientSession()) { SessionRepositoryFilter.this.httpSessionStrategy .onInvalidateSession(this, this.response); } } else { // 從HttpSession中獲取當前spring session S session = wrappedSession.getSession(); // 持久化spring session至存儲器 SessionRepositoryFilter.this.sessionRepository.save(session); // 如果是新創建spring session,sessionId到response的cookie if (!isRequestedSessionIdValid() || !session.getId().equals(getRequestedSessionId())) { SessionRepositoryFilter.this.httpSessionStrategy.onNewSession(session, this, this.response); } } }
再來看下包裝的響應SessionRepositoryResponseWrapper。
3.SessionRepositoryResponseWrapper
/** * Allows ensuring that the session is saved if the response is committed. * * @author Rob Winch * @since 1.0 */ private final class SessionRepositoryResponseWrapper extends OnCommittedResponseWrapper { private final SessionRepositoryRequestWrapper request; /** * Create a new {@link SessionRepositoryResponseWrapper}. * @param request the request to be wrapped * @param response the response to be wrapped */ SessionRepositoryResponseWrapper(SessionRepositoryRequestWrapper request, HttpServletResponse response) { super(response); if (request == null) { throw new IllegalArgumentException("request cannot be null"); } this.request = request; } @Override protected void onResponseCommitted() { this.request.commitSession(); } }
上面的注釋已經非常詳細,這里不再贅述。這里只講述為什么需要包裝原始的響應。從注釋上可以看出包裝響應時為了:確保如果響應被提交session能夠被保存。
這里我有點疑惑:在上述的SessionRepositoryFilter.doFilterInternal方法中不是已經request.commitSession()了嗎,FilterChain執行完或者異常后都會執行Finally中的request.commitSession。為什么這里仍然需要包裝響應,為了確保session能夠保存,包裝器中的onResponseCommitted方法可以看出也是做了一次request.commitSession()。難道這不是多此一舉?
Tips
如果有和我相同疑問的同學,那就說明我們的基礎都不扎實,對Servlet仍然沒有一個清楚全面的認識。對於此問題,這里是github上的issuse:
https://github.com/spring-projects/spring-session/issues/1183
但是在提完issue后的回家路上,我思考了下response可以有流方式的寫,會不會在response.getOutStream寫的時候已經將響應全部返回到客戶端,這時響應結束。在家中是,spring sesion作者大大已經回復了我的issue:
Is this causing you problems? The reason is that we need to ensure that the session is created before the response is committed. If the response is already committed there will be no way to track the session (i.e. a cookie cannot be written to the response to keep track of which session id).
他的意思是:我們需要在response被提交之前確保session被創建。如果response已經被提交,將沒有辦法追蹤session(例如:無法將cookie寫入response以跟蹤哪個session id)。
在此之前我又閱讀了JavaTM Servlet Specification,規范中這樣解釋Response的flushBuffer接口:
The isCommitted method returns a boolean value indicating whether any response bytes have been returned to the client. The flushBuffer method forces content in the buffer to be written to the client.
並且看了ServletResponse的flushBuffer的javadocs:
/** * Forces any content in the buffer to be written to the client. A call to * this method automatically commits the response, meaning the status code * and headers will be written. * * @throws IOException if an I/O occurs during the flushing of the response * * @see #setBufferSize * @see #getBufferSize * @see #isCommitted * @see #reset */ public void flushBuffer() throws IOException;
結合以上兩點,一旦response執行flushBuffer方法,迫使Response中在Buffer中任何數據都會被返回至client端。這個方法自動提交響應中的status code和head。那么如果不包裝請求,監聽flushBuffer事件在提交response前,將session寫入response和持久化session,將導致作者大大說的無法追蹤session。SessionRepositoryResponseWrapper繼承父類OnCommittedResponseWrapper,其中flushBuffer方法如下:
/** * Makes sure {@link OnCommittedResponseWrapper#onResponseCommitted()} is invoked * before calling the superclass <code>flushBuffer()</code>. * @throws IOException if an input or output exception occurred */ @Override public void flushBuffer() throws IOException { doOnResponseCommitted(); super.flushBuffer(); } /** * Calls <code>onResponseCommmitted()</code> with the current contents as long as * {@link #disableOnResponseCommitted()} was not invoked. */ private void doOnResponseCommitted() { if (!this.disableOnCommitted) { onResponseCommitted(); disableOnResponseCommitted(); } }
重寫HttpServletResponse方法,監聽response commit,當發生response commit時,可以在commit之前寫session至response中並持久化session。
Tips:
spring mvc中HttpMessageConverters使用到的jackson即調用了outstream.flushBuffer(),當使用@ResponseBody時。
以上做法固然合理,但是如此重復操作兩次commit,存在兩次persist session?
這個問題后面涉及SessionRepository時再詳述!
3.Session接口
spring-session和tomcat中的Session的實現模式上有很大不同,tomcat中直接對HttpSession接口進行實現,而spring-session中則抽象出單獨的Session層接口,讓后再使用適配器模式將Session適配層Servlet規范中的HttpSession。spring-sesion中關於session的實現和適配整個UML類圖如下:
Tips:適配器模式
spring-session單獨抽象出Session層接口,可以應對多種場景下不同的session的實現,然后通過適配器模式將Session適配成HttpSession的接口,精妙至極!
Session是spring-session對session的抽象,主要是為了鑒定用戶,為Http請求和響應提供上下文過程,該Session可以被HttpSession、WebSocket Session,非WebSession等使用。定義了Session的基本行為:
-
getId:獲取sessionId
-
setAttribute:設置session屬性
-
getAttribte:獲取session屬性
ExipringSession:提供Session額外的過期特性。定義了以下關於過期的行為:
-
setLastAccessedTime:設置最近Session會話過程中最近的訪問時間
-
getLastAccessedTime:獲取最近的訪問時間
-
setMaxInactiveIntervalInSeconds:設置Session的最大閑置時間
-
getMaxInactiveIntervalInSeconds:獲取最大閑置時間
-
isExpired:判斷Session是否過期
MapSession:基於java.util.Map的ExpiringSession的實現
RedisSession:基於MapSession和Redis的ExpiringSession實現,提供Session的持久化能力
先來看下MapSession的代碼源碼片段
public final class MapSession implements ExpiringSession, Serializable { /** * Default {@link #setMaxInactiveIntervalInSeconds(int)} (30 minutes). */ public static final int DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS = 1800; private String id; private Map<String, Object> sessionAttrs = new HashMap<String, Object>(); private long creationTime = System.currentTimeMillis(); private long lastAccessedTime = this.creationTime; /** * Defaults to 30 minutes. */ private int maxInactiveInterval = DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS;
MapSession中持有HashMap類型的變量sessionAtts用於存儲Session設置屬性,比如調用的setAttribute方法的k-v就存儲在該HashMap中。這個和tomcat內部實現HttpSession的方式類似,tomcat中使用了ConcurrentHashMap存儲。
其中lastAccessedTime用於記錄最近的一次訪問時間,maxInactiveInterval用於記錄Session的最大閑置時間(過期時間-針對沒有Request活躍的情況下的最大時間,即相對於最近一次訪問后的最大閑置時間)。
public void setAttribute(String attributeName, Object attributeValue) { if (attributeValue == null) { removeAttribute(attributeName); } else { this.sessionAttrs.put(attributeName, attributeValue); } }
setAttribute方法極其簡單,null時就移除attributeName,否則put存儲。重點熟悉RedisSession如何實現Session的行為:setAttribute、persistence等。
/** * 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 ExpiringSession { private final MapSession cached; private Long originalLastAccessTime; private Map<String, Object> delta = new HashMap<String, Object>(); private boolean isNew; private String originalPrincipalName;
}
首先看javadocs,對於閱讀源碼,學會看javadocs非常重要!
基於MapSession的基本映射實現的Session,能夠追蹤發生變化的所有屬性,當調用saveDelta方法后,變化的屬性將被持久化!
在RedisSession中有兩個非常重要的成員屬性:
-
cached:實際上是一個MapSession實例,用於做本地緩存,每次在getAttribute時無需從Redis中獲取,主要為了improve性能
-
delta:用於跟蹤變化數據,做持久化
再來看下RedisSession中最為重要的行為saveDelta——持久化Session至Redis中:
/** * Saves any attributes that have been changed and updates the expiration of this * session. */ private void saveDelta() { // 如果delta為空,則Session中沒有任何數據需要存儲 if (this.delta.isEmpty()) { return; } String sessionId = getId(); // 使用spring data redis將delta中的數據保存至Redis中 getSessionBoundHashOperations(sessionId).putAll(this.delta); String principalSessionKey = getSessionAttrNameKey( FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME); String securityPrincipalSessionKey = getSessionAttrNameKey( SPRING_SECURITY_CONTEXT); if (this.delta.containsKey(principalSessionKey) || this.delta.containsKey(securityPrincipalSessionKey)) { if (this.originalPrincipalName != null) { String originalPrincipalRedisKey = getPrincipalKey( this.originalPrincipalName); RedisOperationsSessionRepository.this.sessionRedisOperations .boundSetOps(originalPrincipalRedisKey).remove(sessionId); } String principal = PRINCIPAL_NAME_RESOLVER.resolvePrincipal(this); this.originalPrincipalName = principal; if (principal != null) { String principalRedisKey = getPrincipalKey(principal); RedisOperationsSessionRepository.this.sessionRedisOperations .boundSetOps(principalRedisKey).add(sessionId); } } // 清空delta,代表沒有任何需要持久化的數據。同時保證 //SessionRepositoryFilter和SessionRepositoryResponseWrapper的onResponseCommitted //只會持久化一次Session至Redis中,解決前面提到的疑問 this.delta = new HashMap<String, Object>(this.delta.size()); // 更新過期時間,滾動至下一個過期時間間隔的時刻 Long originalExpiration = this.originalLastAccessTime == null ? null : this.originalLastAccessTime + TimeUnit.SECONDS .toMillis(getMaxInactiveIntervalInSeconds()); RedisOperationsSessionRepository.this.expirationPolicy .onExpirationUpdated(originalExpiration, this); }
從javadoc中可以看出,saveDelta用於存儲Session的屬性:
-
保存Session中的屬性數據至Redis中
-
清空delta中數據,防止重復提交Session中的數據
-
更新過期時間至下一個過期時間間隔的時刻
再看下RedisSession中的其他行為
// 設置session的存活時間,即最大過期時間。先保存至本地緩存,然后再保存至delta public void setMaxInactiveIntervalInSeconds(int interval) { this.cached.setMaxInactiveIntervalInSeconds(interval); this.delta.put(MAX_INACTIVE_ATTR, getMaxInactiveIntervalInSeconds()); flushImmediateIfNecessary(); } // 直接從本地緩存獲取過期時間 public int getMaxInactiveIntervalInSeconds() { return this.cached.getMaxInactiveIntervalInSeconds(); } // 直接從本地緩存中獲取Session中的屬性 @SuppressWarnings("unchecked") public Object getAttribute(String attributeName) { return this.cached.getAttribute(attributeName); } // 保存Session屬性至本地緩存和delta中 public void setAttribute(String attributeName, Object attributeValue) { this.cached.setAttribute(attributeName, attributeValue); this.delta.put(getSessionAttrNameKey(attributeName), attributeValue); flushImmediateIfNecessary(); }
除了MapSession和RedisSession還有JdbcSession、MongoExpiringSession,感興趣的讀者可以自行閱讀。
下面看SessionRepository的邏輯。SessionRepository是spring session中用於管理spring session的核心組件。
4. SessionRepository
A repository interface for managing {@link Session} instances.
javadoc中描述SessionRepository為管理spring-session的接口實例。抽象出:
S createSession(); void save(S session); S getSession(String id); void delete(String id);
創建、保存、獲取、刪除Session的接口行為。根據Session的不同,分為很多種Session操作倉庫。
這里重點介紹下RedisOperationsSessionRepository。在詳細介紹其之前,了解下RedisOperationsSessionRepository的數據存儲細節。
當創建一個RedisSession,然后存儲在Redis中時,RedisSession的存儲細節如下:
spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe
spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe
spring:session:expirations:1439245080000
Redis會為每個RedisSession存儲三個k-v。
-
第一個k-v用來存儲Session的詳細信息,包括Session的過期時間間隔、最近的訪問時間、attributes等等。這個k的過期時間為Session的最大過期時間 + 5分鍾。如果默認的最大過期時間為30分鍾,則這個k的過期時間為35分鍾
-
第二個k-v用來表示Session在Redis中的過期,這個k-v不存儲任何有用數據,只是表示Session過期而設置。這個k在Redis中的過期時間即為Session的過期時間間隔
-
第三個k-v存儲這個Session的id,是一個Set類型的Redis數據結構。這個k中的最后的1439245080000值是一個時間戳,根據這個Session過期時刻滾動至下一分鍾而計算得出。
這里不由好奇,為什么一個RedisSession卻如此復雜的存儲。關於這個可以參考spring-session作者本人在github上的兩篇回答:
https://github.com/spring-projects/spring-session/commit/d96c8f2ce5968ced90cb43009156a61bd21853d1
簡單描述下,為什么RedisSession的存儲用到了三個Key,而非一個Redis過期Key。
對於Session的實現,需要支持HttpSessionEvent,即Session創建、過期、銷毀等事件。當應用用監聽器設置監聽相應事件,Session發生上述行為時,監聽器能夠做出相應的處理。
Redis的強大之處在於支持KeySpace Notifiction——鍵空間通知。即可以監視某個key的變化,如刪除、更新、過期。當key發生上述行為是,以便可以接受到變化的通知做出相應的處理。具體詳情可以參考:
https://redis.io/topics/notifications
但是Redis中帶有過期的key有兩種方式:
-
當訪問時發現其過期
-
Redis后台逐步查找過期鍵
當訪問時發現其過期,會產生過期事件,但是無法保證key的過期時間抵達后立即生成過期事件。具體可以參考:
https://redis.io/topics/notifications
spring-session為了能夠及時的產生Session的過期時的過期事件,所以增加了:
spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe
spring:session:expirations:1439245080000
spring-session中有個定時任務,每個整分鍾都會查詢相應的spring:session:expirations:整分鍾的時間戳中的過期SessionId,然后再訪問一次這個SessionId,即spring:session:sessions:expires:SessionId,以便能夠讓Redis及時的產生key過期事件——即Session過期事件。
接下來再看下RedisOperationsSessionRepository中的具體實現原理
createSession方法:
public RedisSession createSession() { // new一個RedisSession實例 RedisSession redisSession = new RedisSession(); // 如果設置的最大過期時間不為空,則設置RedisSession的過期時間 if (this.defaultMaxInactiveInterval != null) { redisSession.setMaxInactiveIntervalInSeconds(this.defaultMaxInactiveInterval); } return redisSession; }
再來看下RedisSession的構造方法:
/** * Creates a new instance ensuring to mark all of the new attributes to be * persisted in the next save operation. */ RedisSession() { // 設置本地緩存為MapSession this(new MapSession()); // 設置Session的基本屬性 this.delta.put(CREATION_TIME_ATTR, getCreationTime()); this.delta.put(MAX_INACTIVE_ATTR, getMaxInactiveIntervalInSeconds()); this.delta.put(LAST_ACCESSED_ATTR, getLastAccessedTime()); // 標記Session的是否為新創建 this.isNew = true; // 持久化 flushImmediateIfNecessary(); }
save方法:
public void save(RedisSession session) { // 調用RedisSession的saveDelta持久化Session session.saveDelta(); // 如果Session為新創建,則發布一個Session創建的事件 if (session.isNew()) { String sessionCreatedKey = getSessionCreatedChannel(session.getId()); this.sessionRedisOperations.convertAndSend(sessionCreatedKey, session.delta); session.setNew(false); } }
getSession方法:
// 根據SessionId獲取Session,這里的false代表的參數 // 指:如果Session已經過期,是否仍然獲取返回 public RedisSession getSession(String id) { return getSession(id, false); }
在有些情況下,Session過期,仍然需要能夠獲取到Session。這里先來看下getSession(String id, boolean allowExpired):
private RedisSession getSession(String id, boolean allowExpired) { // 根據SessionId,從Redis獲取到持久化的Session信息 Map<Object, Object> entries = getSessionBoundHashOperations(id).entries(); // 如果Redis中沒有,則返回null if (entries.isEmpty()) { return null; } // 根據Session信息,加載創建一個MapSession對象 MapSession loaded = loadSession(id, entries); // 判斷是否允許過期獲取和Session是否過期 if (!allowExpired && loaded.isExpired()) { return null; } // 根據MapSession new一個信息的RedisSession,此時isNew為false RedisSession result = new RedisSession(loaded); // 設置最新的訪問時間 result.originalLastAccessTime = loaded.getLastAccessedTime(); return result; }
這里需要注意的是loaded.isExpired()和loadSession。loaded.isExpired判斷Session是否過期,如果過期返回null:
public boolean isExpired() { // 根據當前時間判斷是否過期 return isExpired(System.currentTimeMillis()); } boolean isExpired(long now) { // 如果maxInactiveInterval小於0,表示Session永不過期 if (this.maxInactiveInterval < 0) { return false; } // 最大過期時間單位轉換為毫秒 // 當前時間減去Session的最大有效期間隔以獲取理論上有效的上一次訪問時間 // 然后在與實際的上一次訪問時間進行比較 // 如果大於,表示理論上的時間已經在實際的訪問時間之后,那么表示Session已經過期 return now - TimeUnit.SECONDS .toMillis(this.maxInactiveInterval) >= this.lastAccessedTime; }
loadSession中,將Redis中存儲的Session信息轉換為MapSession對象,以便從Session中獲取屬性時能夠從內存直接獲取提高性能:
private MapSession loadSession(String id, Map<Object, Object> entries) { MapSession loaded = new MapSession(id); for (Map.Entry<Object, Object> entry : entries.entrySet()) { String key = (String) entry.getKey(); if (CREATION_TIME_ATTR.equals(key)) { loaded.setCreationTime((Long) entry.getValue()); } else if (MAX_INACTIVE_ATTR.equals(key)) { loaded.setMaxInactiveIntervalInSeconds((Integer) entry.getValue()); } else if (LAST_ACCESSED_ATTR.equals(key)) { loaded.setLastAccessedTime((Long) entry.getValue()); } else if (key.startsWith(SESSION_ATTR_PREFIX)) { loaded.setAttribute(key.substring(SESSION_ATTR_PREFIX.length()), entry.getValue()); } } return loaded; }
至此,可以看出spring-session中request.getSession(false)的過期實現原理。
delete方法:
public void delete(String sessionId) { // 獲取Session RedisSession session = getSession(sessionId, true); if (session == null) { return; } cleanupPrincipalIndex(session); // 從過期集合中移除sessionId this.expirationPolicy.onDelete(session); String expireKey = getExpiredKey(session.getId()); // 刪除session的過期鍵 this.sessionRedisOperations.delete(expireKey); // 設置session過期 session.setMaxInactiveIntervalInSeconds(0); save(session); }
至此RedisOperationsSessionRepository的核心原理就介紹完畢。但是RedisOperationsSessionRepository中還包括關於Session事件的處理和清理Session的定時任務。這部分內容在后述的SessionEvent部分介紹。
5. HttpSessionStrategy
A strategy for mapping HTTP request and responses to a {@link Session}.
從javadoc中可以看出,HttpSessionStrategy是建立Request/Response和Session之間的映射關系的策略。
Tips:策略模式
策略模式是一個傳神的神奇模式,是java的多態非常典型應用,是開閉原則、迪米特法則的具體體現。將同類型的一系列的算法封裝在不同的類中,通過使用接口注入不同類型的實現,以達到的高擴展的目的。一般是定義一個策略接口,按照不同的場景實現各自的策略。
該策略接口中定義一套策略行為:
// 根據請求獲取SessionId,即建立請求至Session的映射關系 String getRequestedSessionId(HttpServletRequest request); // 對於新創建的Session,通知客戶端 void onNewSession(Session session, HttpServletRequest request, HttpServletResponse response); // 對於session無效,通知客戶端 void onInvalidateSession(HttpServletRequest request, HttpServletResponse response);
如下UML類圖:
這里主要介紹CookieHttpSessionStrategy,這個也是默認的策略,可以查看spring-session中類SpringHttpSessionConfiguration,在注冊SessionRepositoryFilter Bean時默認采用CookieHttpSessionStrategy:
@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; }
下面來分析CookieHttpSessionStrategy的原理。該策略使用Cookie來映射Request/Response至Session。即request/requset的head中cookie存儲SessionId,當請求至web服務器,可以解析請求head中的cookie,然后獲取sessionId,根據sessionId獲取spring-session。當創建新的session或者session過期,將相應的sessionId寫入response的set-cookie或者從respose中移除sessionId。
getRequestedSessionId方法
public String getRequestedSessionId(HttpServletRequest request) { // 獲取當前請求的sessionId:session別名和sessionId映射 Map<String, String> sessionIds = getSessionIds(request); // 獲取當前請求的Session別名 String sessionAlias = getCurrentSessionAlias(request); // 獲取相應別名的sessionId return sessionIds.get(sessionAlias); }
接下來看下具體獲取SessionIds的具體過程:
public String getRequestedSessionId(HttpServletRequest request) { // 獲取當前請求的sessionId:session別名和sessionId映射 Map<String, String> sessionIds = getSessionIds(request); // 獲取當前請求的Session別名 String sessionAlias = getCurrentSessionAlias(request); // 獲取相應別名的sessionId return sessionIds.get(sessionAlias); } public Map<String, String> getSessionIds(HttpServletRequest request) { // 解析request中的cookie值 List<String> cookieValues = this.cookieSerializer.readCookieValues(request); // 獲取sessionId String sessionCookieValue = cookieValues.isEmpty() ? "" : cookieValues.iterator().next(); Map<String, String> result = new LinkedHashMap<String, String>(); // 根據分詞器對sessionId進行分割,因為spring-session支持多session。默認情況只有一個session StringTokenizer tokens = new StringTokenizer(sessionCookieValue, this.deserializationDelimiter); // 如果只有一個session,則設置默認別名為0 if (tokens.countTokens() == 1) { result.put(DEFAULT_ALIAS, tokens.nextToken()); return result; } // 如果有多個session,則建立別名和sessionId的映射 while (tokens.hasMoreTokens()) { String alias = tokens.nextToken(); if (!tokens.hasMoreTokens()) { break; } String id = tokens.nextToken(); result.put(alias, id); } return result; } public List<String> readCookieValues(HttpServletRequest request) { // 獲取request的cookie Cookie[] cookies = request.getCookies(); List<String> matchingCookieValues = new ArrayList<String>(); if (cookies != null) { for (Cookie cookie : cookies) { // 如果是以SESSION開頭,則表示是SessionId,畢竟cookie不只有sessionId,還有可能存儲其他內容 if (this.cookieName.equals(cookie.getName())) { // 決策是否需要base64 decode String sessionId = this.useBase64Encoding ? base64Decode(cookie.getValue()) : cookie.getValue(); if (sessionId == null) { continue; } if (this.jvmRoute != null && sessionId.endsWith(this.jvmRoute)) { sessionId = sessionId.substring(0, sessionId.length() - this.jvmRoute.length()); } // 存入list中 matchingCookieValues.add(sessionId); } } } return matchingCookieValues; }
再來看下獲取當前request對應的Session的別名方法getCurrentSessionAlias
public String getCurrentSessionAlias(HttpServletRequest request) { // 如果session參數為空,則返回默認session別名 if (this.sessionParam == null) { return DEFAULT_ALIAS; } // 從request中獲取session別名,如果為空則返回默認別名 String u = request.getParameter(this.sessionParam); if (u == null) { return DEFAULT_ALIAS; } if (!ALIAS_PATTERN.matcher(u).matches()) { return DEFAULT_ALIAS; } return u; }
spring-session為了支持多session,才弄出多個session別名。當時一般應用場景都是一個session,都是默認的session別名0。
上述獲取sessionId和別名映射關系中,也是默認別名0。這里返回別名0,所以返回當前請求對應的sessionId。
onNewSession方法
public void onNewSession(Session session, HttpServletRequest request, HttpServletResponse response) { // 從當前request中獲取已經寫入Cookie的sessionId集合 Set<String> sessionIdsWritten = getSessionIdsWritten(request); // 判斷是否包含,如果包含,表示該sessionId已經寫入過cookie中,則直接返回 if (sessionIdsWritten.contains(session.getId())) { return; } // 如果沒有寫入,則加入集合,后續再寫入 sessionIdsWritten.add(session.getId()); Map<String, String> sessionIds = getSessionIds(request); String sessionAlias = getCurrentSessionAlias(request); sessionIds.put(sessionAlias, session.getId()); // 獲取cookieValue String cookieValue = createSessionCookieValue(sessionIds); //將cookieValue寫入Cookie中 this.cookieSerializer .writeCookieValue(new CookieValue(request, response, cookieValue)); }
sessionIdsWritten主要是用來記錄已經寫入Cookie的SessionId,防止SessionId重復寫入Cookie中。
onInvalidateSession方法
public void onInvalidateSession(HttpServletRequest request, HttpServletResponse response) { // 從當前request中獲取sessionId和別名映射 Map<String, String> sessionIds = getSessionIds(request); // 獲取別名 String requestedAlias = getCurrentSessionAlias(request); // 移除sessionId sessionIds.remove(requestedAlias); String cookieValue = createSessionCookieValue(sessionIds); // 寫入移除后的sessionId this.cookieSerializer .writeCookieValue(new CookieValue(request, response, cookieValue)); }
繼續看下具體的寫入writeCookieValue原理:
public void writeCookieValue(CookieValue cookieValue) { // 獲取request/respose和cookie值 HttpServletRequest request = cookieValue.getRequest(); HttpServletResponse response = cookieValue.getResponse(); String requestedCookieValue = cookieValue.getCookieValue(); String actualCookieValue = this.jvmRoute == null ? requestedCookieValue : requestedCookieValue + this.jvmRoute; // 構造servlet規范中的Cookie對象,注意這里cookieName為:SESSION,表示為Session, // 上述的從Cookie中讀取SessionId,也是使用該cookieName Cookie sessionCookie = new Cookie(this.cookieName, this.useBase64Encoding ? base64Encode(actualCookieValue) : actualCookieValue); // 設置cookie的屬性:secure、path、domain、httpOnly sessionCookie.setSecure(isSecureCookie(request)); sessionCookie.setPath(getCookiePath(request)); String domainName = getDomainName(request); if (domainName != null) { sessionCookie.setDomain(domainName); } if (this.useHttpOnlyCookie) { sessionCookie.setHttpOnly(true); } // 如果cookie值為空,則失效 if ("".equals(requestedCookieValue)) { sessionCookie.setMaxAge(0); } else { sessionCookie.setMaxAge(this.cookieMaxAge); } // 寫入cookie到response中 response.addCookie(sessionCookie); }
總結
spring-session提供集群環境下HttpSession的透明集成。spring-session的優勢在於開箱即用,具有較強的設計模式。且支持多種持久化方式,其中RedisSession較為成熟,與spring-data-redis整合,可謂威力無窮。
Session事件的抽象
先來看下Session事件抽象UML類圖,整體掌握事件之間的依賴關系。
Session Event最頂層是ApplicationEvent,即Spring上下文事件對象。由此可以看出Spring-Session的事件機制是基於Spring上下文事件實現。
抽象的AbstractSessionEvent事件對象提供了獲取Session(這里的是指Spring Session的對象)和SessionId。
基於事件的類型,分類為:
-
Session創建事件
-
Session刪除事件
-
Session過期事件
Tips:
Session銷毀事件只是刪除和過期事件的統一,並無實際含義。
事件對象只是對事件本身的抽象,描述事件的屬性,如:
-
獲取事件產生的源:getSource獲取事件產生源
-
獲取相應事件特性:getSession/getSessoinId獲取時間關聯的Session
下面再深入探索以上的Session事件是如何觸發,從事件源到事件監聽器的鏈路分析事件流轉過程。
事件的觸發機制
閱讀本節前,讀者應該了解Redis的Pub/Sub和KeySpace Notification。
介紹Session Event事件基於Spring的ApplicationEvent實現。先簡單認識spring上下文事件機制:
-
ApplicationEventPublisher實現用於發布Spring上下文事件ApplicationEvent
-
ApplicationListener實現用於監聽Spring上下文事件ApplicationEvent
-
ApplicationEvent抽象上下文事件
那么在Spring-Session中必然包含事件發布者ApplicationEventPublisher發布Session事件和ApplicationListener監聽Session事件。
可以看出ApplicationEventPublisher發布一個事件:
@FunctionalInterface public interface ApplicationEventPublisher { /** * Notify all <strong>matching</strong> listeners registered with this * application of an application event. Events may be framework events * (such as RequestHandledEvent) or application-specific events. * @param event the event to publish * @see org.springframework.web.context.support.RequestHandledEvent */ default void publishEvent(ApplicationEvent event) { publishEvent((Object) event); } /** * Notify all <strong>matching</strong> listeners registered with this * application of an event. * <p>If the specified {@code event} is not an {@link ApplicationEvent}, * it is wrapped in a {@link PayloadApplicationEvent}. * @param event the event to publish * @since 4.2 * @see PayloadApplicationEvent */ void publishEvent(Object event); }
ApplicationListener用於監聽相應的事件:
@FunctionalInterface public interface ApplicationListener<E extends ApplicationEvent> extends EventListener { /** * Handle an application event. * @param event the event to respond to */ void onApplicationEvent(E event); }
Tips:
這里使用到了發布/訂閱模式,事件監聽器可以監聽感興趣的事件,發布者可以發布各種事件。不過這是內部的發布訂閱,即觀察者模式。
Session事件的流程實現如下:
上圖展示了Spring-Session事件流程圖,事件源來自於Redis鍵空間通知,在spring-data-redis項目中抽象MessageListener監聽Redis事件源,然后將其傳播至spring應用上下文發布者,由發布者發布事件。在spring上下文中的監聽器Listener即可監聽到Session事件。
因為兩者是Spring框架提供的對Spring的ApplicationEvent的支持。Session Event基於ApplicationEvent實現,必然也有其相應發布者和監聽器的的實現。
Spring-Session中的RedisSession的SessionRepository是RedisOperationSessionRepository。所有關於RedisSession的管理操作都是由其實現,所以Session的產生源是RedisOperationSessionRepository。
在RedisOperationSessionRepository中持有ApplicationEventPublisher對象用於發布Session事件。
private ApplicationEventPublisher eventPublisher = new ApplicationEventPublisher() { @Override public void publishEvent(ApplicationEvent event) { } @Override public void publishEvent(Object event) { } };
但是該ApplicationEventPublisher是空實現,實際實現是在應用啟動時由Spring-Session自動配置。在spring-session-data-redis模塊中RedisHttpSessionConfiguration中有關於創建RedisOperationSessionRepository Bean時將調用set方法將ApplicationEventPublisher配置。
@Configuration @EnableScheduling public class RedisHttpSessionConfiguration extends SpringHttpSessionConfiguration implements BeanClassLoaderAware, EmbeddedValueResolverAware, ImportAware, SchedulingConfigurer { private ApplicationEventPublisher applicationEventPublisher; @Bean public RedisOperationsSessionRepository sessionRepository() { RedisTemplate<Object, Object> redisTemplate = createRedisTemplate(); RedisOperationsSessionRepository sessionRepository = new RedisOperationsSessionRepository( redisTemplate); // 注入依賴 sessionRepository.setApplicationEventPublisher(this.applicationEventPublisher); if (this.defaultRedisSerializer != null) { sessionRepository.setDefaultSerializer(this.defaultRedisSerializer); } sessionRepository .setDefaultMaxInactiveInterval(this.maxInactiveIntervalInSeconds); if (StringUtils.hasText(this.redisNamespace)) { sessionRepository.setRedisKeyNamespace(this.redisNamespace); } sessionRepository.setRedisFlushMode(this.redisFlushMode); return sessionRepository; } // 注入上下文中的ApplicationEventPublisher Bean @Autowired public void setApplicationEventPublisher( ApplicationEventPublisher applicationEventPublisher) { this.applicationEventPublisher = applicationEventPublisher; } }
在進行自動配置時,將上下文中的ApplicationEventPublisher的注入,實際上即ApplicationContext對象。
Note:
考慮篇幅原因,以上的RedisHttpSessionConfiguration至展示片段。
對於ApplicationListener是由應用開發者自行實現,注冊成Bean即可。當有Session Event發布時,即可監聽。
/** * session事件監聽器 * * @author huaijin */ @Component public class SessionEventListener implements ApplicationListener<SessionDeletedEvent> { private static final String CURRENT_USER = "currentUser"; @Override public void onApplicationEvent(SessionDeletedEvent event) { Session session = event.getSession(); UserVo userVo = session.getAttribute(CURRENT_USER); System.out.println("Current session's user:" + userVo.toString()); } }
以上部分探索了Session事件的發布者和監聽者,但是核心事件的觸發發布則是由Redis的鍵空間通知機制觸發,當有Session創建/刪除/過期時,Redis鍵空間會通知Spring-Session應用。
RedisOperationsSessionRepository實現spring-data-redis中的MessageListener接口。
/** * Listener of messages published in Redis. * * @author Costin Leau * @author Christoph Strobl */ public interface MessageListener { /** * Callback for processing received objects through Redis. * * @param message message must not be {@literal null}. * @param pattern pattern matching the channel (if specified) - can be {@literal null}. */ void onMessage(Message message, @Nullable byte[] pattern); }
該監聽器即用來監聽redis發布的消息。RedisOperationsSessionRepositorys實現了該Redis鍵空間消息通知監聽器接口,實現如下:
public class RedisOperationsSessionRepository implements FindByIndexNameSessionRepository<RedisOperationsSessionRepository.RedisSession>, MessageListener { @Override @SuppressWarnings("unchecked") public void onMessage(Message message, byte[] pattern) { // 獲取該消息發布的redis通道channel byte[] messageChannel = message.getChannel(); // 獲取消息體內容 byte[] messageBody = message.getBody(); String channel = new String(messageChannel); // 如果是由Session創建通道發布的消息,則是Session創建事件 if (channel.startsWith(getSessionCreatedChannelPrefix())) { // 從消息體中載入Session Map<Object, Object> loaded = (Map<Object, Object>) this.defaultSerializer .deserialize(message.getBody()); // 發布創建事件 handleCreated(loaded, channel); return; } // 如果消息體不是以過期鍵前綴,直接返回。因為spring-session在redis中的key命名規則: // "${namespace}:sessions:expires:${sessionId}",如: // session.example:sessions:expires:a5236a19-7325-4783-b1f0-db9d4442db9a // 所以判斷過期或者刪除的鍵是否為spring-session的過期鍵。如果不是,可能是應用中其他的鍵的操作,所以直接return String body = new String(messageBody); if (!body.startsWith(getExpiredKeyPrefix())) { return; } // 根據channel判斷鍵空間的事件類型del或者expire時間 boolean isDeleted = channel.endsWith(":del"); if (isDeleted || channel.endsWith(":expired")) { int beginIndex = body.lastIndexOf(":") + 1; int endIndex = body.length(); // Redis鍵空間消息通知內容即操作的鍵,spring-session鍵中命名規則: // "${namespace}:sessions:expires:${sessionId}",以下是根據規則解析sessionId String sessionId = body.substring(beginIndex, endIndex); // 根據sessionId加載session RedisSession session = getSession(sessionId, true); if (session == null) { logger.warn("Unable to publish SessionDestroyedEvent for session " + sessionId); return; } if (logger.isDebugEnabled()) { logger.debug("Publishing SessionDestroyedEvent for session " + sessionId); } cleanupPrincipalIndex(session); // 發布Session delete事件 if (isDeleted) { handleDeleted(session); } else { // 否則發布Session expire事件 handleExpired(session); } } } }
1.Session創建事件的觸發
-
由RedisOperationSessionRepository向Redis指定通道**{sessionId}**發布一個message
-
MessageListener的實現RedisOperationSessionRepository監聽到Redis指定通道**{sessionId}**的消息
-
將其傳播至ApplicationEventPublisher
-
ApplicationEventPublisher發布SessionCreateEvent
-
ApplicationListener監聽SessionCreateEvent,執行相應邏輯
RedisOperationSessionRepository中保存一個Session時,判斷Session是否新創建。如果新創建,則向
@Override public void save(RedisSession session) { session.saveDelta(); // 判斷是否為新創建的session if (session.isNew()) { // 獲取redis指定的channel:${namespace}:event:created:${sessionId}, // 如:session.example:event:created:82sdd-4123-o244-ps123 String sessionCreatedKey = getSessionCreatedChannel(session.getId()); // 向該通道發布session數據 this.sessionRedisOperations.convertAndSend(sessionCreatedKey, session.delta); // 設置session為非新創建 session.setNew(false); } }
該save方法的調用是由HttpServletResponse提交時——即返回客戶端響應調用
Note:
這里有點繞。個人認為RedisOperationSessionRepository發布創建然后再本身監聽,主要是考慮分布式或者集群環境中SessionCreateEvent事件的處理。
2.Session刪除事件的觸發
Tips:
刪除事件中使用到了Redis KeySpace Notification,建議先了解該技術。
-
由RedisOperationSessionRepository刪除Redis鍵空間中的指定Session的過期鍵,Redis鍵空間會向 __keyevent@*:del 的channel發布刪除事件消息
-
MessageListener的實現RedisOperationSessionRepository監聽到Redis指定通道__keyevent@*:del 的消息
-
將其傳播至ApplicationEventPublisher
-
ApplicationEventPublisher發布SessionDeleteEvent
-
ApplicationListener監聽SessionDeleteEvent,執行相應邏輯
當調用HttpSession的invalidate方法讓Session失效時,即會調用RedisOperationSessionRepository的deleteById方法刪除Session的過期鍵。
/** * Allows creating an HttpSession from a Session instance. * * @author Rob Winch * @since 1.0 */ private final class HttpSessionWrapper extends HttpSessionAdapter<S> { HttpSessionWrapper(S session, ServletContext servletContext) { super(session, servletContext); } @Override public void invalidate() { super.invalidate(); SessionRepositoryRequestWrapper.this.requestedSessionInvalidated = true; setCurrentSession(null); clearRequestedSessionCache(); // 調用刪除方法 SessionRepositoryFilter.this.sessionRepository.deleteById(getId()); } }
@Override public void deleteById(String sessionId) { // 如果session為空則返回 RedisSession session = getSession(sessionId, true); if (session == null) { return; } cleanupPrincipalIndex(session); this.expirationPolicy.onDelete(session); // 獲取session的過期鍵 String expireKey = getExpiredKey(session.getId()); // 刪除過期鍵,redis鍵空間產生del事件消息,被MessageListener即 // RedisOperationSessionRepository監聽 this.sessionRedisOperations.delete(expireKey); session.setMaxInactiveInterval(Duration.ZERO); save(session); }
后續流程同SessionCreateEvent流程。
3.Session失效事件的觸發
Session的過期事件流程比較特殊,因為Redis的鍵空間通知的特殊性,Redis鍵空間通知不能保證過期鍵的通知的及時性。
-
RedisOperationsSessionRepository中有個定時任務方法每整分運行訪問整分Session過期鍵集合中的過期sessionId,如:spring:session:expirations:1439245080000。觸發Redis鍵空間會向 __keyevent@*:expired 的channel發布過期事件消息
-
MessageListener的實現RedisOperationSessionRepository監聽到Redis指定通道__keyevent@*:expired 的消息
-
將其傳播至ApplicationEventPublisher
-
ApplicationEventPublisher發布SessionDeleteEvent
-
ApplicationListener監聽SessionDeleteEvent,執行相應邏輯
@Scheduled(cron = "0 * * * * *") public void cleanupExpiredSessions() { this.expirationPolicy.cleanExpiredSessions(); }
定時任務每整分運行,執行cleanExpiredSessions方法。expirationPolicy是RedisSessionExpirationPolicy實例,是RedisSession過期策略。
public void cleanExpiredSessions() { // 獲取當前時間戳 long now = System.currentTimeMillis(); // 時間滾動至整分,去掉秒和毫秒部分 long prevMin = roundDownMinute(now); if (logger.isDebugEnabled()) { logger.debug("Cleaning up sessions expiring at " + new Date(prevMin)); } // 根據整分時間獲取過期鍵集合,如:spring:session:expirations:1439245080000 String expirationKey = getExpirationKey(prevMin); // 獲取所有的所有的過期session Set<Object> sessionsToExpire = this.redis.boundSetOps(expirationKey).members(); // 刪除過期Session鍵集合 this.redis.delete(expirationKey); // touch訪問所有已經過期的session,觸發Redis鍵空間通知消息 for (Object session : sessionsToExpire) { String sessionKey = getSessionKey((String) session); touch(sessionKey); } }
將時間戳滾動至整分
static long roundDownMinute(long timeInMs) { Calendar date = Calendar.getInstance(); date.setTimeInMillis(timeInMs); // 清理時間錯的秒位和毫秒位 date.clear(Calendar.SECOND); date.clear(Calendar.MILLISECOND); return date.getTimeInMillis(); }
獲取過期Session的集合
String getExpirationKey(long expires) { return this.redisSession.getExpirationsKey(expires); } // 如:spring:session:expirations:1439245080000 String getExpirationsKey(long expiration) { return this.keyPrefix + "expirations:" + expiration; }
調用Redis的Exists命令,訪問過期Session鍵,觸發Redis鍵空間消息
/** * 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); }
-
Redis鍵空間Session事件源:Session創建通道/Session刪除通道/Session過期通道
-
Spring-Session中的RedisOperationsSessionRepository消息監聽器監聽Redis的事件類型
-
RedisOperationsSessionRepository負責將其傳播至ApplicationEventPublisher
-
ApplicationEventPublisher將其包裝成ApplicationEvent類型的Session Event發布
-
ApplicationListener監聽Session Event,處理相應邏輯