Spring Session

配置文件

  • pom.xml引入spring-session

<dependency>
      <groupId>org.springframework.session</groupId>
      <artifactId>spring-session-data-redis</artifactId>
      <version>1.2.0.RELEASE</version>
</dependency>
  • web.xml

    <!--    配置spring-session-->
    <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>*.do</url-pattern>
    </filter-mapping>
  • Spring-session配置文件, 將該文件單獨(dú)導(dǎo)入Spring配置文件中

<?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:aop="http://www.springframework.org/schema/aop"
       xmlns:tx="http://www.springframework.org/schema/tx" xmlns:jdbc="http://www.springframework.org/schema/jdbc"
       xmlns:context="http://www.springframework.org/schema/context"
       xsi:schemaLocation="
     http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
     http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
     http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx.xsd
     http://www.springframework.org/schema/aop http://www.springframework.org/schema/aop/spring-aop.xsd">
    <!--    設(shè)置redis的配置-->
    <bean id="redisHttpSessionConfiguration"
          class="org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration">
        <property name="maxInactiveIntervalInSeconds" value="1800" />
    </bean>
    <bean id="defaultCookieSerializer" 
          class="org.springframework.session.web.http.DefaultCookieSerializer">
        <property name="CookieName" value="SESSION_NAME" />
        <property name="domainName" value="localhost" />
        <property name="useHttpOnlyCookie" value="true" />
        <property name="cookiePath" value="/" />
        <property name="cookieMaxAge" value="31536000" />
    </bean>
    <bean id="jedisPoolConfig"
          class="redis.clients.jedis.JedisPoolConfig">
        <property name="maxTotal" value="20" />
        <property name="maxIdle" value="20" />
        <property name="minIdle" value="10" />
    </bean>
    <bean id="jedisConnectionFactory"
          class="org.springframework.data.redis.connection.jedis.JedisConnectionFactory">
        <property name="hostName" value="127.0.0.1" />
        <property name="port" value="6379" />
        <property name="poolConfig" ref="jedisPoolConfig" />
    </bean>

</beans>
  • 示例

## User 必須實(shí)現(xiàn)Serializable, 否則session寫入不到redis里
    @RequestMapping(value = "login.do", method = RequestMethod.GET)
    @ResponseBody
    public ServerResponse<User> login(String username, String password, HttpSession session) {
        ServerResponse<User> serverResponse = iUserService.login(username,
                password);
        if (serverResponse.isSuccess()) {
             session.setAttribute(Const.CURRENT_USER, serverResponse.getData());
//            RedisPoolUntil.setEx(session.getId(), JsonUtil.obj2String
//                    (serverResponse.getData()), 60 * 30);
        }
        return serverResponse;
    }
redis

Spring Session探索

主要從以下兩個(gè)方面來(lái)說(shuō)spring-session:

  • 特點(diǎn)
  • 工作原理

一.特點(diǎn)

spring-session在無(wú)需綁定web容器的情況下提供對(duì)集群session的支持。并提供對(duì)以下情況的透明集成:

  • HttpSession:容許替換web容器的HttpSession
  • WebSocket:使用WebSocket通信時(shí),提供Session的活躍
  • WebSession:容許以應(yīng)用中立的方式替換webflux的webSession

二.工作原理

再詳細(xì)閱讀源碼之前先來(lái)看張圖,介紹下spring-session中的核心模塊以及之間的交互。

image

spring-session分為以下核心模塊:

  • SessionRepositoryFilter:Servlet規(guī)范中Filter的實(shí)現(xiàn),用來(lái)切換HttpSession至Spring Session,包裝HttpServletRequest和HttpServletResponse
  • HttpServerletRequest/HttpServletResponse/HttpSessionWrapper包裝器:包裝原有的HttpServletRequest、HttpServletResponse和Spring Session,實(shí)現(xiàn)切換Session和透明繼承HttpSession的關(guān)鍵之所在
  • Session:Spring Session模塊
  • SessionRepository:管理Spring Session的模塊
  • HttpSessionStrategy:映射HttpRequst和HttpResponse到Session的策略
1. SessionRepositoryFilter

SessionRepositoryFilter是一個(gè)Filter過濾器,符合Servlet的規(guī)范定義,用來(lái)修改包裝請(qǐng)求和響應(yīng)。這里負(fù)責(zé)包裝切換HttpSession至Spring Session的請(qǐng)求和響應(yīng)。


@Override
protected void doFilterInternal(HttpServletRequest request,
        HttpServletResponse response, FilterChain filterChain)
                throws ServletException, IOException {
    // 設(shè)置SessionRepository至Request的屬性中
    request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);
    // 包裝原始HttpServletRequest至SessionRepositoryRequestWrapper
    SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryRequestWrapper(
            request, response, this.servletContext);
    // 包裝原始HttpServletResponse響應(yīng)至SessionRepositoryResponseWrapper
    SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryResponseWrapper(
            wrappedRequest, response);
    // 設(shè)置當(dāng)前請(qǐng)求的HttpSessionStrategy策略
    HttpServletRequest strategyRequest = this.httpSessionStrategy
            .wrapRequest(wrappedRequest, wrappedResponse);
    // 設(shè)置當(dāng)前響應(yīng)的HttpSessionStrategy策略
    HttpServletResponse strategyResponse = this.httpSessionStrategy
            .wrapResponse(wrappedRequest, wrappedResponse);
    try {
        filterChain.doFilter(strategyRequest, strategyResponse);
    }
    finally {
        // 提交session
        wrappedRequest.commitSession();
    }
}

以上是SessionRepositoryFilter的核心操作,每個(gè)HttpRequest進(jìn)入,都會(huì)被該Filter包裝成切換Session的請(qǐng)求很響應(yīng)對(duì)象。

Tips:責(zé)任鏈模式
Filter是Servlet規(guī)范中的非常重要的組件,在tomcat的實(shí)現(xiàn)中使用了責(zé)任鏈模式,將多個(gè)Filter組織成鏈?zhǔn)秸{(diào)用。Filter的作用就是在業(yè)務(wù)邏輯執(zhí)行前后對(duì)請(qǐng)求和響應(yīng)做修改配置。配合HttpServletRequestWrapper和HttpServletResponseWrapper使用,可謂威力驚人!

2. SessionRepositoryRequestWrapper

對(duì)于developers獲取HttpSession的api

HttpServletRequest request = ...;
HttpSession session = request.getSession(true);

在spring session中request的實(shí)際類型SessionRepositoryRequestWrapper。調(diào)用SessionRepositoryRequestWrapper的getSession方法會(huì)觸發(fā)創(chuàng)建spring session,而非web容器的HttpSession。

SessionRepositoryRequestWrapper用來(lái)包裝原始的HttpServletRequest實(shí)現(xiàn)HttpSession切換至Spring Session。是透明Spring Session透明集成HttpSession的關(guān)鍵。

private final class SessionRepositoryRequestWrapper
            extends HttpServletRequestWrapper {

    private final String CURRENT_SESSION_ATTR = HttpServletRequestWrapper.class
                .getName();

    // 當(dāng)前請(qǐng)求sessionId有效
    private Boolean requestedSessionIdValid;
    // 當(dāng)前請(qǐng)求sessionId無(wú)效
    private boolean requestedSessionInvalidated;
    private final HttpServletResponse response;
    private final ServletContext servletContext;

    private SessionRepositoryRequestWrapper(HttpServletRequest request,
            HttpServletResponse response, ServletContext servletContext) {
        // 調(diào)用HttpServletRequestWrapper構(gòu)造方法,實(shí)現(xiàn)包裝
        super(request);
        this.response = response;
        this.servletContext = servletContext;
    }
}

SessionRepositoryRequestWrapper繼承Servlet規(guī)范中定義的包裝器HttpServletRequestWrapper。HttpServletRequestWrapper是Servlet規(guī)范api提供的用于擴(kuò)展HttpServletRequest的擴(kuò)張點(diǎn)——即裝飾器模式,可以通過重寫一些api達(dá)到功能點(diǎn)的增強(qiáng)和自定義。

Tips:裝飾器模式
裝飾器模式(包裝模式)是對(duì)功能增強(qiáng)的一種絕佳模式。實(shí)際利用的是面向?qū)ο蟮亩鄳B(tài)性實(shí)現(xiàn)擴(kuò)展。Servlet規(guī)范中開放此HttpServletRequestWrapper接口,是讓developers自行擴(kuò)展實(shí)現(xiàn)。這種使用方式和jdk中的FilterInputStream/FilterInputStream如出一轍。

HttpServletRequestWrapper中持有一個(gè)HttpServletRequest對(duì)象,然后實(shí)現(xiàn)HttpServletRequest接口的所有方法,所有方法實(shí)現(xiàn)中都是調(diào)用持有的HttpServletRequest對(duì)象的相應(yīng)的方法。繼承HttpServletRequestWrapper 可以對(duì)其重寫。SessionRepositoryRequestWrapper繼承HttpServletRequestWrapper,在構(gòu)造方法中將原有的HttpServletRequest通過調(diào)用super完成對(duì)HttpServletRequestWrapper中持有的HttpServletRequest初始化賦值,然后重寫和session相關(guān)的方法。這樣就保證SessionRepositoryRequestWrapper的其他方法調(diào)用都是使用原有的HttpServletRequest的數(shù)據(jù),只有session相關(guān)的是重寫的邏輯。

Tips:
這里的設(shè)計(jì)是否很精妙!一切都多虧與Servlet規(guī)范設(shè)計(jì)的的巧妙啊!

@Override
public HttpSessionWrapper getSession() {
    return getSession(true);
}

重寫HttpServletRequest的getSession()方法,調(diào)用有參數(shù)getSession(arg)方法,默認(rèn)為true,表示當(dāng)前reques沒有session時(shí)創(chuàng)建session。繼續(xù)看下有參數(shù)getSession(arg)的重寫邏輯.

@Override
public HttpSessionWrapper getSession(boolean create) {
    // 從當(dāng)前請(qǐng)求的attribute中獲取session,如果有直接返回
    HttpSessionWrapper currentSession = getCurrentSession();
    if (currentSession != null) {
        return currentSession;
    }

    // 獲取當(dāng)前request的sessionId,這里使用了HttpSessionStrategy
    // 決定怎樣將Request映射至Session,默認(rèn)使用Cookie策略,即從cookies中解析sessionId
    String requestedSessionId = getRequestedSessionId();
    // 請(qǐng)求的如果sessionId存在且當(dāng)前request的attribute中的沒有session失效屬性
    // 則根據(jù)sessionId獲取spring session
    if (requestedSessionId != null
            && getAttribute(INVALID_SESSION_ID_ATTR) == null) {
        S session = getSession(requestedSessionId);
        // 如果spring session不為空,則將spring session包裝成HttpSession并
        // 設(shè)置到當(dāng)前Request的attribute中,防止同一個(gè)request getsession時(shí)頻繁的到存儲(chǔ)器
        //中獲取session,提高性能
        if (session != null) {
            this.requestedSessionIdValid = true;
            currentSession = new HttpSessionWrapper(session, getServletContext());
            currentSession.setNew(false);
            setCurrentSession(currentSession);
            return currentSession;
        }
        // 如果根據(jù)sessionId,沒有獲取到session,則設(shè)置當(dāng)前request屬性,此sessionId無(wú)效
        // 同一個(gè)請(qǐng)求中獲取session,直接返回?zé)o效
        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");
        }
    }
    // 判斷是否創(chuàng)建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)"));
    }
    // 根據(jù)sessionRepository創(chuàng)建spring session
    S session = SessionRepositoryFilter.this.sessionRepository.createSession();
    // 設(shè)置session的最新訪問時(shí)間
    session.setLastAccessedTime(System.currentTimeMillis());
    // 包裝成HttpSession透明化集成
    currentSession = new HttpSessionWrapper(session, getServletContext());
    // 設(shè)置session至Requset的attribute中,提高同一個(gè)request訪問session的性能
    setCurrentSession(currentSession);
    return currentSession;
}

再來(lái)看下spring session的持久化。上述SessionRepositoryFilter在包裝HttpServletRequest后,執(zhí)行FilterChain中使用finally保證請(qǐng)求的Session始終session會(huì)被提交,此提交操作中將sesionId設(shè)置到response的head中并將session持久化至存儲(chǔ)器中。

持久化只持久spring session,并不是將spring session包裝后的HttpSession持久化,因?yàn)镠ttpSession不過是包裝器,持久化沒有意義。

/**
 * Uses the HttpSessionStrategy to write the session id to the response and
 * persist the Session.
 */
private void commitSession() {
    // 獲取當(dāng)前session
    HttpSessionWrapper wrappedSession = getCurrentSession();
    // 如果當(dāng)前session為空,則刪除cookie中的相應(yīng)的sessionId
    if (wrappedSession == null) {
        if (isInvalidateClientSession()) {
            SessionRepositoryFilter.this.httpSessionStrategy
                    .onInvalidateSession(this, this.response);
        }
    }
    else {
        // 從HttpSession中獲取當(dāng)前spring session
        S session = wrappedSession.getSession();
        // 持久化spring session至存儲(chǔ)器
        SessionRepositoryFilter.this.sessionRepository.save(session);
        // 如果是新創(chuàng)建spring session,sessionId到response的cookie
        if (!isRequestedSessionIdValid()
                || !session.getId().equals(getRequestedSessionId())) {
            SessionRepositoryFilter.this.httpSessionStrategy.onNewSession(session,
                    this, this.response);
        }
    }
}

再來(lái)看下包裝的響應(yīng)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();
    }
}

上面的注釋已經(jīng)非常詳細(xì),這里不再贅述。這里只講述為什么需要包裝原始的響應(yīng)。從注釋上可以看出包裝響應(yīng)時(shí)為了:確保如果響應(yīng)被提交session能夠被保存。

這里我有點(diǎn)疑惑:在上述的SessionRepositoryFilter.doFilterInternal方法中不是已經(jīng)request.commitSession()了嗎,F(xiàn)ilterChain執(zhí)行完或者異常后都會(huì)執(zhí)行Finally中的request.commitSession。為什么這里仍然需要包裝響應(yīng),為了確保session能夠保存,包裝器中的onResponseCommitted方法可以看出也是做了一次request.commitSession()。難道這不是多此一舉?

Tips
如果有和我相同疑問的同學(xué),那就說(shuō)明我們的基礎(chǔ)都不扎實(shí),對(duì)Servlet仍然沒有一個(gè)清楚全面的認(rèn)識(shí)。對(duì)于此問題,我特意在github上提了issuse:Why is the request.commitSession() method called repeatedly?。

但是在提完issue后的回家路上,我思考了下response可以有流方式的寫,會(huì)不會(huì)在response.getOutStream寫的時(shí)候已經(jīng)將響應(yīng)全部返回到客戶端,這時(shí)響應(yīng)結(jié)束。

在家中是,spring sesion作者大大已經(jīng)回復(fù)了我的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被創(chuàng)建。如果response已經(jīng)被提交,將沒有辦法追蹤session(例如:無(wú)法將cookie寫入response以跟蹤哪個(gè)session id)。

在此之前我又閱讀了JavaTM Servlet Specification,規(guī)范中這樣解釋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;

結(jié)合以上兩點(diǎn),一旦response執(zhí)行flushBuffer方法,迫使Response中在Buffer中任何數(shù)據(jù)都會(huì)被返回至client端。這個(gè)方法自動(dòng)提交響應(yīng)中的status code和head。那么如果不包裝請(qǐng)求,監(jiān)聽flushBuffer事件在提交response前,將session寫入response和持久化session,將導(dǎo)致作者大大說(shuō)的無(wú)法追蹤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方法,監(jiān)聽response commit,當(dāng)發(fā)生response commit時(shí),可以在commit之前寫session至response中并持久化session。

Tips:
spring mvc中HttpMessageConverters使用到的jackson即調(diào)用了outstream.flushBuffer(),當(dāng)使用@ResponseBody時(shí)。

以上做法固然合理,但是如此重復(fù)操作兩次commit,存在兩次persist session?
這個(gè)問題后面涉及SessionRepository時(shí)再詳述!

再看SessionRepository之前,先來(lái)看下spring session中的session接口。

3.Session接口

spring-session和tomcat中的Session的實(shí)現(xiàn)模式上有很大不同,tomcat中直接對(duì)HttpSession接口進(jìn)行實(shí)現(xiàn),而spring-session中則抽象出單獨(dú)的Session層接口,讓后再使用適配器模式將Session適配層Servlet規(guī)范中的HttpSession。spring-sesion中關(guān)于session的實(shí)現(xiàn)和適配整個(gè)UML類圖如下:

image

Tips:適配器模式
spring-session單獨(dú)抽象出Session層接口,可以應(yīng)對(duì)多種場(chǎng)景下不同的session的實(shí)現(xiàn),然后通過適配器模式將Session適配成HttpSession的接口,精妙至極!

Session是spring-session對(duì)session的抽象,主要是為了鑒定用戶,為Http請(qǐng)求和響應(yīng)提供上下文過程,該Session可以被HttpSession、WebSocket Session,非WebSession等使用。定義了Session的基本行為:

  • getId:獲取sessionId
  • setAttribute:設(shè)置session屬性
  • getAttribte:獲取session屬性

ExipringSession:提供Session額外的過期特性。定義了以下關(guān)于過期的行為:

  • setLastAccessedTime:設(shè)置最近Session會(huì)話過程中最近的訪問時(shí)間
  • getLastAccessedTime:獲取最近的訪問時(shí)間
  • setMaxInactiveIntervalInSeconds:設(shè)置Session的最大閑置時(shí)間
  • getMaxInactiveIntervalInSeconds:獲取最大閑置時(shí)間
  • isExpired:判斷Session是否過期

MapSession:基于java.util.Map的ExpiringSession的實(shí)現(xiàn)

RedisSession:基于MapSession和Redis的ExpiringSession實(shí)現(xiàn),提供Session的持久化能力

先來(lái)看下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用于存儲(chǔ)Session設(shè)置屬性,比如調(diào)用的setAttribute方法的k-v就存儲(chǔ)在該HashMap中。這個(gè)和tomcat內(nèi)部實(shí)現(xiàn)HttpSession的方式類似,tomcat中使用了ConcurrentHashMap存儲(chǔ)。

其中l(wèi)astAccessedTime用于記錄最近的一次訪問時(shí)間,maxInactiveInterval用于記錄Session的最大閑置時(shí)間(過期時(shí)間-針對(duì)沒有Request活躍的情況下的最大時(shí)間,即相對(duì)于最近一次訪問后的最大閑置時(shí)間)。

public void setAttribute(String attributeName, Object attributeValue) {
    if (attributeValue == null) {
        removeAttribute(attributeName);
    }
    else {
        this.sessionAttrs.put(attributeName, attributeValue);
    }
}

setAttribute方法極其簡(jiǎn)單,null時(shí)就移除attributeName,否則put存儲(chǔ)。

重點(diǎn)熟悉RedisSession如何實(shí)現(xiàn)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,對(duì)于閱讀源碼,學(xué)會(huì)看javadocs非常重要!

基于MapSession的基本映射實(shí)現(xiàn)的Session,能夠追蹤發(fā)生變化的所有屬性,當(dāng)調(diào)用saveDelta方法后,變化的屬性將被持久化!

在RedisSession中有兩個(gè)非常重要的成員屬性:

  • cached:實(shí)際上是一個(gè)MapSession實(shí)例,用于做本地緩存,每次在getAttribute時(shí)無(wú)需從Redis中獲取,主要為了improve性能
  • delta:用于跟蹤變化數(shù)據(jù),做持久化

再來(lái)看下RedisSession中最為重要的行為saveDelta——持久化Session至Redis中:

/**
 * Saves any attributes that have been changed and updates the expiration of this
 * session.
 */
private void saveDelta() {
    // 如果delta為空,則Session中沒有任何數(shù)據(jù)需要存儲(chǔ)
    if (this.delta.isEmpty()) {
        return;
    }
    String sessionId = getId();
    // 使用spring data redis將delta中的數(shù)據(jù)保存至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,代表沒有任何需要持久化的數(shù)據(jù)。同時(shí)保證
    //SessionRepositoryFilter和SessionRepositoryResponseWrapper的onResponseCommitted
    //只會(huì)持久化一次Session至Redis中,解決前面提到的疑問
    this.delta = new HashMap<String, Object>(this.delta.size());  
    // 更新過期時(shí)間,滾動(dòng)至下一個(gè)過期時(shí)間間隔的時(shí)刻
    Long originalExpiration = this.originalLastAccessTime == null ? null
            : this.originalLastAccessTime + TimeUnit.SECONDS
                    .toMillis(getMaxInactiveIntervalInSeconds());
    RedisOperationsSessionRepository.this.expirationPolicy
            .onExpirationUpdated(originalExpiration, this);
}

從javadoc中可以看出,saveDelta用于存儲(chǔ)Session的屬性:

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

再看下RedisSession中的其他行為

// 設(shè)置session的存活時(shí)間,即最大過期時(shí)間。先保存至本地緩存,然后再保存至delta
public void setMaxInactiveIntervalInSeconds(int interval) {
    this.cached.setMaxInactiveIntervalInSeconds(interval);
    this.delta.put(MAX_INACTIVE_ATTR, getMaxInactiveIntervalInSeconds());
    flushImmediateIfNecessary();
}

// 直接從本地緩存獲取過期時(shí)間
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的接口實(shí)例。抽象出:

S createSession();
void save(S session);
S getSession(String id);
void delete(String id);

創(chuàng)建、保存、獲取、刪除Session的接口行為。根據(jù)Session的不同,分為很多種Session操作倉(cāng)庫(kù)。

image

這里重點(diǎn)介紹下RedisOperationsSessionRepository。在詳細(xì)介紹其之前,了解下RedisOperationsSessionRepository的數(shù)據(jù)存儲(chǔ)細(xì)節(jié)。

當(dāng)創(chuàng)建一個(gè)RedisSession,然后存儲(chǔ)在Redis中時(shí),RedisSession的存儲(chǔ)細(xì)節(jié)如下:

spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe
spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe
spring:session:expirations:1439245080000

Redis會(huì)為每個(gè)RedisSession存儲(chǔ)三個(gè)k-v。

  1. 第一個(gè)k-v用來(lái)存儲(chǔ)Session的詳細(xì)信息,包括Session的過期時(shí)間間隔、最近的訪問時(shí)間、attributes等等。這個(gè)k的過期時(shí)間為Session的最大過期時(shí)間 + 5分鐘。如果默認(rèn)的最大過期時(shí)間為30分鐘,則這個(gè)k的過期時(shí)間為35分鐘
  2. 第二個(gè)k-v用來(lái)表示Session在Redis中的過期,這個(gè)k-v不存儲(chǔ)任何有用數(shù)據(jù),只是表示Session過期而設(shè)置。這個(gè)k在Redis中的過期時(shí)間即為Session的過期時(shí)間間隔
  3. 第三個(gè)k-v存儲(chǔ)這個(gè)Session的id,是一個(gè)Set類型的Redis數(shù)據(jù)結(jié)構(gòu)。這個(gè)k中的最后的1439245080000值是一個(gè)時(shí)間戳,根據(jù)這個(gè)Session過期時(shí)刻滾動(dòng)至下一分鐘而計(jì)算得出。

這里不由好奇,為什么一個(gè)RedisSession卻如此復(fù)雜的存儲(chǔ)。關(guān)于這個(gè)可以參考spring-session作者本人在github上的兩篇回答:

Why does Spring Session use spring:session:expirations?

Clarify Redis expirations and cleanup task

簡(jiǎn)單描述下,為什么RedisSession的存儲(chǔ)用到了三個(gè)Key,而非一個(gè)Redis過期Key。
對(duì)于Session的實(shí)現(xiàn),需要支持HttpSessionEvent,即Session創(chuàng)建、過期、銷毀等事件。當(dāng)應(yīng)用用監(jiān)聽器設(shè)置監(jiān)聽相應(yīng)事件,Session發(fā)生上述行為時(shí),監(jiān)聽器能夠做出相應(yīng)的處理。
Redis的強(qiáng)大之處在于支持KeySpace Notifiction——鍵空間通知。即可以監(jiān)視某個(gè)key的變化,如刪除、更新、過期。當(dāng)key發(fā)生上述行為是,以便可以接受到變化的通知做出相應(yīng)的處理。具體詳情可以參考:
Redis Keyspace Notifications

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

  • 當(dāng)訪問時(shí)發(fā)現(xiàn)其過期
  • Redis后臺(tái)逐步查找過期鍵

當(dāng)訪問時(shí)發(fā)現(xiàn)其過期,會(huì)產(chǎn)生過期事件,但是無(wú)法保證key的過期時(shí)間抵達(dá)后立即生成過期事件。具體可以參考:Timing of expired events

spring-session為了能夠及時(shí)的產(chǎn)生Session的過期時(shí)的過期事件,所以增加了:

spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe
spring:session:expirations:1439245080000

spring-session中有個(gè)定時(shí)任務(wù),每個(gè)整分鐘都會(huì)查詢相應(yīng)的spring:session:expirations:整分鐘的時(shí)間戳中的過期SessionId,然后再訪問一次這個(gè)SessionId,即spring:session:sessions:expires:SessionId,以便能夠讓Redis及時(shí)的產(chǎn)生key過期事件——即Session過期事件。

接下來(lái)再看下RedisOperationsSessionRepository中的具體實(shí)現(xiàn)原理

createSession方法:
public RedisSession createSession() {
    // new一個(gè)RedisSession實(shí)例
    RedisSession redisSession = new RedisSession();
    // 如果設(shè)置的最大過期時(shí)間不為空,則設(shè)置RedisSession的過期時(shí)間
    if (this.defaultMaxInactiveInterval != null) {
        redisSession.setMaxInactiveIntervalInSeconds(this.defaultMaxInactiveInterval);
    }
    return redisSession;
}

再來(lái)看下RedisSession的構(gòu)造方法:

/**
 * Creates a new instance ensuring to mark all of the new attributes to be
 * persisted in the next save operation.
 */
RedisSession() {
    // 設(shè)置本地緩存為MapSession
    this(new MapSession());
    // 設(shè)置Session的基本屬性
    this.delta.put(CREATION_TIME_ATTR, getCreationTime());
    this.delta.put(MAX_INACTIVE_ATTR, getMaxInactiveIntervalInSeconds());
    this.delta.put(LAST_ACCESSED_ATTR, getLastAccessedTime());
    // 標(biāo)記Session的是否為新創(chuàng)建
    this.isNew = true;
    // 持久化
    flushImmediateIfNecessary();
}
save方法:
public void save(RedisSession session) {
    // 調(diào)用RedisSession的saveDelta持久化Session
    session.saveDelta();
    // 如果Session為新創(chuàng)建,則發(fā)布一個(gè)Session創(chuàng)建的事件
    if (session.isNew()) {
        String sessionCreatedKey = getSessionCreatedChannel(session.getId());
        this.sessionRedisOperations.convertAndSend(sessionCreatedKey, session.delta);
        session.setNew(false);
    }
}
getSession方法:
// 根據(jù)SessionId獲取Session,這里的false代表的參數(shù)
// 指:如果Session已經(jīng)過期,是否仍然獲取返回
public RedisSession getSession(String id) {
    return getSession(id, false);
}

在有些情況下,Session過期,仍然需要能夠獲取到Session。這里先來(lái)看下getSession(String id, boolean allowExpired):

private RedisSession getSession(String id, boolean allowExpired) {
    // 根據(jù)SessionId,從Redis獲取到持久化的Session信息
    Map<Object, Object> entries = getSessionBoundHashOperations(id).entries();
    // 如果Redis中沒有,則返回null
    if (entries.isEmpty()) {
        return null;
    }
    // 根據(jù)Session信息,加載創(chuàng)建一個(gè)MapSession對(duì)象
    MapSession loaded = loadSession(id, entries);
    //  判斷是否允許過期獲取和Session是否過期
    if (!allowExpired && loaded.isExpired()) {
        return null;
    }
    // 根據(jù)MapSession new一個(gè)信息的RedisSession,此時(shí)isNew為false
    RedisSession result = new RedisSession(loaded);
    // 設(shè)置最新的訪問時(shí)間
    result.originalLastAccessTime = loaded.getLastAccessedTime();
    return result;
}

這里需要注意的是loaded.isExpired()和loadSession。loaded.isExpired判斷Session是否過期,如果過期返回null:

public boolean isExpired() {
    // 根據(jù)當(dāng)前時(shí)間判斷是否過期
    return isExpired(System.currentTimeMillis());
}
boolean isExpired(long now) {
    // 如果maxInactiveInterval小于0,表示Session永不過期
    if (this.maxInactiveInterval < 0) {
        return false;
    }
    // 最大過期時(shí)間單位轉(zhuǎn)換為毫秒
    // 當(dāng)前時(shí)間減去Session的最大有效期間隔以獲取理論上有效的上一次訪問時(shí)間
    // 然后在與實(shí)際的上一次訪問時(shí)間進(jìn)行比較
    // 如果大于,表示理論上的時(shí)間已經(jīng)在實(shí)際的訪問時(shí)間之后,那么表示Session已經(jīng)過期
    return now - TimeUnit.SECONDS
            .toMillis(this.maxInactiveInterval) >= this.lastAccessedTime;
}

loadSession中,將Redis中存儲(chǔ)的Session信息轉(zhuǎn)換為MapSession對(duì)象,以便從Session中獲取屬性時(shí)能夠從內(nèi)存直接獲取提高性能:

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)的過期實(shí)現(xiàn)原理。

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);
    // 設(shè)置session過期
    session.setMaxInactiveIntervalInSeconds(0);
    save(session);
}

至此RedisOperationsSessionRepository的核心原理就介紹完畢。但是RedisOperationsSessionRepository中還包括關(guān)于Session事件的處理和清理Session的定時(shí)任務(wù)。這部分內(nèi)容在后述的SessionEvent部分介紹。

5. HttpSessionStrategy

A strategy for mapping HTTP request and responses to a {@link Session}.

從javadoc中可以看出,HttpSessionStrategy是建立Request/Response和Session之間的映射關(guān)系的策略。

Tips:策略模式
策略模式是一個(gè)傳神的神奇模式,是java的多態(tài)非常典型應(yīng)用,是開閉原則、迪米特法則的具體體現(xiàn)。將同類型的一系列的算法封裝在不同的類中,通過使用接口注入不同類型的實(shí)現(xiàn),以達(dá)到的高擴(kuò)展的目的。一般是定義一個(gè)策略接口,按照不同的場(chǎng)景實(shí)現(xiàn)各自的策略。

該策略接口中定義一套策略行為:

// 根據(jù)請(qǐng)求獲取SessionId,即建立請(qǐng)求至Session的映射關(guān)系
String getRequestedSessionId(HttpServletRequest request);
// 對(duì)于新創(chuàng)建的Session,通知客戶端
void onNewSession(Session session, HttpServletRequest request,
            HttpServletResponse response);
// 對(duì)于session無(wú)效,通知客戶端
void onInvalidateSession(HttpServletRequest request, HttpServletResponse response);

如下UML類圖:

image

這里主要介紹CookieHttpSessionStrategy,這個(gè)也是默認(rèn)的策略,可以查看spring-session中類SpringHttpSessionConfiguration,在注冊(cè)SessionRepositoryFilter Bean時(shí)默認(rèn)采用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;
}

下面來(lái)分析CookieHttpSessionStrategy的原理。該策略使用Cookie來(lái)映射Request/Response至Session。即request/requset的head中cookie存儲(chǔ)SessionId,當(dāng)請(qǐng)求至web服務(wù)器,可以解析請(qǐng)求head中的cookie,然后獲取sessionId,根據(jù)sessionId獲取spring-session。當(dāng)創(chuàng)建新的session或者session過期,將相應(yīng)的sessionId寫入response的set-cookie或者從respose中移除sessionId。

getRequestedSessionId方法
public String getRequestedSessionId(HttpServletRequest request) {
    // 獲取當(dāng)前請(qǐng)求的sessionId:session別名和sessionId映射
    Map<String, String> sessionIds = getSessionIds(request);
    // 獲取當(dāng)前請(qǐng)求的Session別名
    String sessionAlias = getCurrentSessionAlias(request);
    // 獲取相應(yīng)別名的sessionId
    return sessionIds.get(sessionAlias);
}

接下來(lái)看下具體獲取SessionIds的具體過程:

public String getRequestedSessionId(HttpServletRequest request) {
    // 獲取當(dāng)前請(qǐng)求的sessionId:session別名和sessionId映射
    Map<String, String> sessionIds = getSessionIds(request);
    // 獲取當(dāng)前請(qǐng)求的Session別名
    String sessionAlias = getCurrentSessionAlias(request);
    // 獲取相應(yīng)別名的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>();
    // 根據(jù)分詞器對(duì)sessionId進(jìn)行分割,因?yàn)閟pring-session支持多session。默認(rèn)情況只有一個(gè)session
    StringTokenizer tokens = new StringTokenizer(sessionCookieValue, this.deserializationDelimiter);
    // 如果只有一個(gè)session,則設(shè)置默認(rèn)別名為0
    if (tokens.countTokens() == 1) {
        result.put(DEFAULT_ALIAS, tokens.nextToken());
        return result;
    }
    // 如果有多個(gè)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,還有可能存儲(chǔ)其他內(nèi)容
            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;
}

再來(lái)看下獲取當(dāng)前request對(duì)應(yīng)的Session的別名方法getCurrentSessionAlias

public String getCurrentSessionAlias(HttpServletRequest request) {
    // 如果session參數(shù)為空,則返回默認(rèn)session別名
    if (this.sessionParam == null) {
        return DEFAULT_ALIAS;
    }
    // 從request中獲取session別名,如果為空則返回默認(rèn)別名
    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,才弄出多個(gè)session別名。當(dāng)時(shí)一般應(yīng)用場(chǎng)景都是一個(gè)session,都是默認(rèn)的session別名0。

上述獲取sessionId和別名映射關(guān)系中,也是默認(rèn)別名0。這里返回別名0,所以返回當(dāng)前請(qǐng)求對(duì)應(yīng)的sessionId。

onNewSession方法
public void onNewSession(Session session, HttpServletRequest request,
        HttpServletResponse response) {
    // 從當(dāng)前request中獲取已經(jīng)寫入Cookie的sessionId集合
    Set<String> sessionIdsWritten = getSessionIdsWritten(request);
    // 判斷是否包含,如果包含,表示該sessionId已經(jīng)寫入過cookie中,則直接返回
    if (sessionIdsWritten.contains(session.getId())) {
        return;
    }
    // 如果沒有寫入,則加入集合,后續(xù)再寫入
    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主要是用來(lái)記錄已經(jīng)寫入Cookie的SessionId,防止SessionId重復(fù)寫入Cookie中。

onInvalidateSession方法
public void onInvalidateSession(HttpServletRequest request,
        HttpServletResponse response) {
    // 從當(dāng)前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));
}

繼續(xù)看下具體的寫入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;
    // 構(gòu)造servlet規(guī)范中的Cookie對(duì)象,注意這里cookieName為:SESSION,表示為Session,
    // 上述的從Cookie中讀取SessionId,也是使用該cookieName
    Cookie sessionCookie = new Cookie(this.cookieName, this.useBase64Encoding
            ? base64Encode(actualCookieValue) : actualCookieValue);
    // 設(shè)置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介紹結(jié)束。

由于篇幅過長(zhǎng),關(guān)于spring-session event和RedisOperationSessionRepository清理session并且產(chǎn)生過期事件的部分后續(xù)文章介紹。

總結(jié)

spring-session提供集群環(huán)境下HttpSession的透明集成。spring-session的優(yōu)勢(shì)在于開箱即用,具有較強(qiáng)的設(shè)計(jì)模式。且支持多種持久化方式,其中RedisSession較為成熟,與spring-data-redis整合,可謂威力無(wú)窮。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容