1. 程式人生 > >spring-session(一)揭秘

spring-session(一)揭秘

imp redis key 共享session eba linked 存儲器 執行 映射關系 訪問時間

前言

在開始spring-session揭秘之前,先做下熱腦(活動活動腦子)運動。主要從以下三個方面進行熱腦:

  1. 為什麽要spring-session
  2. 比較traditional-session方案和spring-session方案
  3. JSR340規範與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探索

主要從以下兩個方面來說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:Why is the request.commitSession() method called repeatedly?。

但是在提完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時再詳述!

再看SessionRepository之前,先來看下spring session中的session接口。

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的屬性:

  1. 保存Session中的屬性數據至Redis中
  2. 清空delta中數據,防止重復提交Session中的數據
  3. 更新過期時間至下一個過期時間間隔的時刻

再看下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。

  1. 第一個k-v用來存儲Session的詳細信息,包括Session的過期時間間隔、最近的訪問時間、attributes等等。這個k的過期時間為Session的最大過期時間 + 5分鐘。如果默認的最大過期時間為30分鐘,則這個k的過期時間為35分鐘
  2. 第二個k-v用來表示Session在Redis中的過期,這個k-v不存儲任何有用數據,只是表示Session過期而設置。這個k在Redis中的過期時間即為Session的過期時間間隔
  3. 第三個k-v存儲這個Session的id,是一個Set類型的Redis數據結構。這個k中的最後的1439245080000值是一個時間戳,根據這個Session過期時刻滾動至下一分鐘而計算得出。

這裏不由好奇,為什麽一個RedisSession卻如此復雜的存儲。關於這個可以參考spring-session作者本人在github上的兩篇回答:

Why does Spring Session use spring:session:expirations?

Clarify Redis expirations and cleanup task

簡單描述下,為什麽RedisSession的存儲用到了三個Key,而非一個Redis過期Key。
對於Session的實現,需要支持HttpSessionEvent,即Session創建、過期、銷毀等事件。當應用用監聽器設置監聽相應事件,Session發生上述行為時,監聽器能夠做出相應的處理。
Redis的強大之處在於支持KeySpace Notifiction——鍵空間通知。即可以監視某個key的變化,如刪除、更新、過期。當key發生上述行為是,以便可以接受到變化的通知做出相應的處理。具體詳情可以參考:
Redis Keyspace Notifications

但是Redis中帶有過期的key有兩種方式:

  • 當訪問時發現其過期
  • Redis後臺逐步查找過期鍵

當訪問時發現其過期,會產生過期事件,但是無法保證key的過期時間抵達後立即生成過期事件。具體可以參考:Timing of expired events

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);
}

至此,CookieHttpSessionStrategy介紹結束。

由於篇幅過長,關於spring-session event和RedisOperationSessionRepository清理session並且產生過期事件的部分後續文章介紹。

總結

spring-session提供集群環境下HttpSession的透明集成。spring-session的優勢在於開箱即用,具有較強的設計模式。且支持多種持久化方式,其中RedisSession較為成熟,與spring-data-redis整合,可謂威力無窮。

spring-session(一)揭秘