• Re:从零开始的Spring Session(一)


    Session和Cookie这两个概念,在学习java web开发之初,大多数人就已经接触过了。最近在研究跨域单点登录的实现时,发现对于Session和Cookie的了解,并不是很深入,所以打算写两篇文章记录一下自己的理解。在我们的应用集成Spring Session之前,先补充一点Session和Cookie的关键知识。

    Session与Cookie基础

    由于http协议是无状态的协议,为了能够记住请求的状态,于是引入了Session和Cookie的机制。我们应该有一个很明确的概念,那就是Session是存在于服务器端的,在单体式应用中,他是由tomcat管理的,存在于tomcat的内存中,当我们为了解决分布式场景中的session共享问题时,引入了redis,其共享内存,以及支持key自动过期的特性,非常契合session的特性,我们在企业开发中最常用的也就是这种模式。但是只要你愿意,也可以选择存储在JDBC,Mongo中,这些,spring都提供了默认的实现,在大多数情况下,我们只需要引入配置即可。而Cookie则是存在于客户端,更方便理解的说法,可以说存在于浏览器。Cookie并不常用,至少在我不长的web开发生涯中,并没有什么场景需要我过多的关注Cookie。http协议允许从服务器返回Response时携带一些Cookie,并且同一个域下对Cookie的数量有所限制,之前说过Session的持久化依赖于服务端的策略,而Cookie的持久化则是依赖于本地文件。
    虽然说Cookie并不常用,但是有一类特殊的Cookie却是我们需要额外关注的,那便是与Session相关的sessionId【也放在cookie中】,他是真正维系客户端和服务端的桥梁。

    代码示例

    用户发起请求,服务器响应请求,并做一些用户信息的处理,随后返回响应给用户;用户再次发起请求,携带sessionId,服务器便能够识别,这个用户就是之前请求的那个。

    使用Springboot编写一个非常简单的服务端,来加深对其的理解。需求很简单,当浏览器访问localhost:8080/test/cookie?browser=xxx时,如果没有获取到session,则将request中的browser存入session;如果获取到session,便将session中的browser值输出。顺便将request中的所有cookie打印出来。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    @Controller
    public class CookieController {

    @RequestMapping("/test/cookie")
    public String cookie(@RequestParam("browser") String browser, HttpServletRequest request, HttpSession session) {
    //取出session中的browser
    Object sessionBrowser = session.getAttribute("browser");
    if (sessionBrowser == null) {
    System.out.println("不存在session,设置browser=" + browser);
    session.setAttribute("browser", browser);
    } else {
    System.out.println("存在session,browser=" + sessionBrowser.toString());
    }
    Cookie[] cookies = request.getCookies();
    if (cookies != null && cookies.length > 0) {
    for (Cookie cookie : cookies) {
    System.out.println(cookie.getName() + " : " + cookie.getValue());
    }
    }
    return "index";
    }
    }

    我们没有引入其他任何依赖,看看原生的session机制是什么。

    1 使用chrome浏览器,访问localhost:8080/test/cookie?browser=chrome,控制台输出如下:

    1
    Session Info:	不存在session,设置browser=chrome

    既没有session,也没有cookie,我们将browser=chrome设置到session中。

    再次访问同样的端点,控制台输出如下:

    1
    2
    Session Info:	存在session,browser=chrome
    Cookie Info: JSESSIONID : 4CD1D96E04FC390EA6C60E8C40A636AF

    多次访问之后,控制台依旧打印出同样的信息。

    稍微解读下这个现象,可以验证一些结论。当服务端往session中保存一些数据时,Response中自动添加了一个Cookie:JSESSIONID:xxxx,再后续的请求中,浏览器也是自动的带上了这个Cookie,服务端根据Cookie中的JSESSIONID取到了对应的session。这验证了一开始的说法,客户端服务端是通过JSESSIONID进行交互的,并且,添加和携带key为JSESSIONID的Cookie都是tomcat和浏览器自动帮助我们完成的,这很关键。

    2 使用360浏览器,访问localhost:8080/test/cookie?browser=360

    第一次访问:

    1
    Session Info:	不存在session,设置browser=360

    后续访问:

    1
    2
    Session Info:	存在session,browser=360
    Cookie Info: JSESSIONID : 320C21A645A160C4843D076204DA2F40

    为什么要再次使用另一个浏览器访问呢?先卖个关子,我们最起码可以得出结论,不同浏览器,访问是隔离的,甚至重新打开同一个浏览器,JSESSIONID也是不同的。另外可以尝试把保存session的操作注视掉,则可以发现Response中就不会返回JSESSIONID了,即这是一次无状态的请求。

    安全问题

    其实上述的知识点,都是非常浅显的,之所以啰嗦一句,是为了引出这一节的内容,以及方便观察后续我们引入Spring Session之后的发生的变化。

    还记得上一节的代码示例中,我们使用了两个浏览器:

    • chrome浏览器访问时,JSESSIONID为4CD1D96E04FC390EA6C60E8C40A636AF,后端session记录的值为:browser=chrome。
    • 360浏览器访问时,JSESSIONID为320C21A645A160C4843D076204DA2F40,后端session记录的值为:browser=360。

    我们使用chrome插件Edit this Cookie,将chrome浏览器中的JSESSIONID修改为360浏览器中的值

    EditThisCookieEditThisCookie

    同样访问原来的端点:localhost:8080/test/cookie?browser=chrome,得到的输出如下:

    1
    2
    存在session,browser=360
    JSESSIONID : 320C21A645A160C4843D076204DA2F40

    证实了一点,存放在客户端的Cookie的确是存在安全问题的,我们使用360的JSESSIONID“骗”过了服务器。毕竟,服务器只能通过Cookie中的JSESSIONID来辨别身份。(这提示我们不要在公共场合保存Cookie信息,现在的浏览器在保存Cookie时通常会让你确定一次)

    下一篇文章,将正式讲解如何在应用中集成Spring Session。

    https://www.cnkirito.moe/spring-session-1/

    This guide describes how to configure Spring Session to use custom cookies with Java Configuration. The guide assumes you have already set up Spring Session in your project.

      You can find the completed guide in the Custom Cookie sample application.

    Once you have set up Spring Session, you can customize how the session cookie is written by exposing a CookieSerializer as a Spring bean. Spring Session comes with DefaultCookieSerializer. Exposing the DefaultCookieSerializer as a Spring bean augments the existing configuration when you use configurations like @EnableRedisHttpSession. The following example shows how to customize Spring Session’s cookie:

        @Bean
        public CookieSerializer cookieSerializer() {
            DefaultCookieSerializer serializer = new DefaultCookieSerializer();
            serializer.setCookieName("JSESSIONID"); 
            serializer.setCookiePath("/"); 
            serializer.setDomainNamePattern("^.+?\.(\w+\.[a-z]+)$"); 
            return serializer;
        }
      We customize the name of the cookie to be JSESSIONID.
      We customize the path of the cookie to be / (rather than the default of the context root).
      We customize the domain name pattern (a regular expression) to be ^.?\.(\w\.[a-z]+)$. This allows sharing a session across domains and applications. If the regular expression does not match, no domain is set and the existing domain is used. If the regular expression matches, the first grouping is used as the domain. This means that a request to https://child.example.com sets the domain to example.com. However, a request to http://localhost:8080/ or https://192.168.1.100:8080/ leaves the cookie unset and, thus, still works in development without any changes being necessary for production.

    https://docs.spring.io/spring-session/docs/current/reference/html5/guides/java-custom-cookie.html

    spring-session(一)揭秘

    前言

    在开始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整合,可谓威力无穷。

    https://www.cnblogs.com/lxyit/p/9672097.html

    spring-session(一)揭秘续篇

    上一篇文章中介绍了Spring-Session的核心原理,Filter,Session,Repository等等,传送门:spring-session(一)揭秘

    这篇继上一篇的原理逐渐深入Spring-Session中的事件机制原理的探索。众所周知,Servlet规范中有对HttpSession的事件的处理,如:HttpSessionEvent/HttpSessionIdListener/HttpSessionListener,可以查看Package javax.servlet

    在Spring-Session中也有相应的Session事件机制实现,包括Session创建/过期/删除事件。

    本文主要从以下方面探索Spring-Session中事件机制

    • Session事件的抽象
    • 事件的触发机制

    Note:
    这里的事件触发机制只介绍基于RedissSession的实现。基于内存Map实现的MapSession不支持Session事件机制。其他的Session实现这里也不做关注。

    一.Session事件的抽象

    先来看下Session事件抽象UML类图,整体掌握事件之间的依赖关系。

    Session Event最顶层是ApplicationEvent,即Spring上下文事件对象。由此可以看出Spring-Session的事件机制是基于Spring上下文事件实现。

    抽象的AbstractSessionEvent事件对象提供了获取Session(这里的是指Spring Session的对象)和SessionId。

    基于事件的类型,分类为:

    1. Session创建事件
    2. Session删除事件
    3. Session过期事件

    Tips:
    Session销毁事件只是删除和过期事件的统一,并无实际含义。

    事件对象只是对事件本身的抽象,描述事件的属性,如:

    1. 获取事件产生的源:getSource获取事件产生源
    2. 获取相应事件特性:getSession/getSessoinId获取时间关联的Session

    下面再深入探索以上的Session事件是如何触发,从事件源到事件监听器的链路分析事件流转过程。

    二.事件的触发机制

    阅读本节前,读者应该了解Redis的Pub/Sub和KeySpace Notification,如果还不是很了解,传送门Redis Keyspace NotificationsPub/Sub

    上节中也介绍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创建事件的触发
    1. 由RedisOperationSessionRepository向Redis指定通道${namespace}:event:created:${sessionId}发布一个message
    2. MessageListener的实现RedisOperationSessionRepository监听到Redis指定通道${namespace}:event:created:${sessionId}的消息
    3. 将其传播至ApplicationEventPublisher
    4. ApplicationEventPublisher发布SessionCreateEvent
    5. 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提交时——即返回客户端响应调用,上篇文章已经详解,这里不再赘述。关于RedisOperationSessionRepository实现MessageListener上述已经介绍,这里同样不再赘述。

    Note:
    这里有点绕。个人认为RedisOperationSessionRepository发布创建然后再本身监听,主要是考虑分布式或者集群环境中SessionCreateEvent事件的处理。

    2.Session删除事件的触发

    Tips:
    删除事件中使用到了Redis KeySpace Notification,建议先了解该技术。

    1. 由RedisOperationSessionRepository删除Redis键空间中的指定Session的过期键,Redis键空间会向**__keyevent@*:del**的channel发布删除事件消息
    2. MessageListener的实现RedisOperationSessionRepository监听到Redis指定通道**__keyevent@*:del**的消息
    3. 将其传播至ApplicationEventPublisher
    4. ApplicationEventPublisher发布SessionDeleteEvent
    5. 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());
        }
    }

    上篇中介绍了包装Spring Session为HttpSession,这里不再赘述。这里重点分析deleteById内容:

    @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键空间通知不能保证过期键的通知的及时性。

    1. RedisOperationsSessionRepository中有个定时任务方法每整分运行访问整分Session过期键集合中的过期sessionId,如:spring:session:expirations:1439245080000。触发Redis键空间会向**__keyevent@*:expired**的channel发布过期事件消息
    2. MessageListener的实现RedisOperationSessionRepository监听到Redis指定通道**__keyevent@*:expired**的消息
    3. 将其传播至ApplicationEventPublisher
    4. ApplicationEventPublisher发布SessionDeleteEvent
    5. 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);
    }

    总结

    至此Spring-Session的Session事件通知模块就已经很清晰:

    1. Redis键空间Session事件源:Session创建通道/Session删除通道/Session过期通道
    2. Spring-Session中的RedisOperationsSessionRepository消息监听器监听Redis的事件类型
    3. RedisOperationsSessionRepository负责将其传播至ApplicationEventPublisher
    4. ApplicationEventPublisher将其包装成ApplicationEvent类型的Session Event发布
    5. ApplicationListener监听Session Event,处理相应逻辑

    https://www.cnblogs.com/lxyit/p/9719542.html

    spring-session(二)与spring-boot整合实战

    没有设置这个,好奇怪:

    @EnableRedisHttpSession(maxInactiveIntervalInSeconds = 604800)

    前两篇介绍了spring-session的原理,这篇在理论的基础上再实战。
    spring-boot整合spring-session的自动配置可谓是开箱即用,极其简洁和方便。这篇文章即介绍spring-boot整合spring-session,这里只介绍基于RedisSession的实战。

    原理篇是基于spring-session v1.2.2版本,考虑到RedisSession模块与spring-session v2.0.6版本的差异很小,且能够与spring-boot v2.0.0兼容,所以实战篇是基于spring-boot v2.0.0基础上配置spring-session。

    源码请戮session-example

    实战

    搭建spring-boot工程这里飘过,传送门:https://start.spring.io/

    配置spring-session

    引入spring-session的pom配置,由于spring-boot包含spring-session的starter模块,所以pom中依赖:

    <dependency>
        <groupId>org.springframework.session</groupId>
        <artifactId>spring-session-data-redis</artifactId>
    </dependency>

    编写spring boot启动类SessionExampleApplication

    /**
     * 启动类
     *
     * @author huaijin
     */
    @SpringBootApplication
    public class SessionExampleApplication {
    
        public static void main(String[] args) {
            SpringApplication.run(SessionExampleApplication.class, args);
        }
    }

    配置application.yml

    spring:
      session:
        redis:
          flush-mode: on_save
          namespace: session.example
          cleanup-cron: 0 * * * * *
        store-type: redis
        timeout: 1800
      redis:
        host: localhost
        port: 6379
        jedis:
          pool:
            max-active: 100
            max-wait: 10
            max-idle: 10
            min-idle: 10
        database: 0
    除了配置spring.session.timeout外,还要配置:

     和

     配置cookieMaxAge后,前端浏览器都会有Max-Age和Expires数据




    编写controller

    编写登录控制器,登录时创建session,并将当前登录用户存储sesion中。登出时,使session失效。

    /**
     * 登录控制器
     *
     * @author huaijin
     */
    @RestController
    public class LoginController {
    
        private static final String CURRENT_USER = "currentUser";
    
        /**
         * 登录
         *
         * @param loginVo 登录信息
         *
         * @author huaijin
         */
        @PostMapping("/login.do")
        public String login(@RequestBody LoginVo loginVo, HttpServletRequest request) {
            UserVo userVo = UserVo.builder().userName(loginVo.getUserName())
                    .userPassword(loginVo.getUserPassword()).build();
            HttpSession session = request.getSession();
            session.setAttribute(CURRENT_USER, userVo);
            System.out.println("create session, sessionId is:" + session.getId());
            return "ok";
        }
    
        /**
         * 登出
         *
         * @author huaijin
         */
        @PostMapping("/logout.do")
        public String logout(HttpServletRequest request) {
            HttpSession session = request.getSession(false);
            session.invalidate();
            return "ok";
        }
    }

    编写查询控制器,在登录创建session后,使用将sessionId置于cookie中访问。如果没有session将返回错误。

    /**
     * 查询
     *
     * @author huaijin
     */
    @RestController
    @RequestMapping("/session")
    public class QuerySessionController {
    
        @GetMapping("/query.do")
        public String querySessionId(HttpServletRequest request) {
            HttpSession session = request.getSession(false);
            if (session == null) {
                return "error";
            }
            System.out.println("current's user is:" + session.getId() +  "in session");
            return "ok";
        }
    }
    编写Session删除事件监听器

    Session删除事件监听器用于监听登出时使session失效的事件源。

    /**
     * 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("invalid session's user:" + userVo.toString());
        }
    }
    验证测试

    编写spring-boot测试类,测试controller,验证spring-session是否生效。

    
    /**
     * 测试Spring-Session:
     * 1.登录时创建session
     * 2.使用sessionId能正常访问
     * 3.session过期销毁,能够监听销毁事件
     *
     * @author huaijin
     */
    @RunWith(SpringRunner.class)
    @SpringBootTest
    @AutoConfigureMockMvc
    public class SpringSessionTest {
    
        @Autowired
        private MockMvc mockMvc;
    
    
        @Test
        public void testLogin() throws Exception {
            LoginVo loginVo = new LoginVo();
            loginVo.setUserName("admin");
            loginVo.setUserPassword("admin@123");
            String content = JSON.toJSONString(loginVo);
    
            // mock登录
            ResultActions actions = this.mockMvc.perform(post("/login.do")
                    .content(content).contentType(MediaType.APPLICATION_JSON))
                    .andExpect(status().isOk()).andExpect(content().string("ok"));
            String sessionId = actions.andReturn()
                    .getResponse().getCookie("SESSION").getValue();
    
            // 使用登录的sessionId mock查询
            this.mockMvc.perform(get("/session/query.do")
                    .cookie(new Cookie("SESSION", sessionId)))
                    .andExpect(status().isOk()).andExpect(content().string("ok"));
    
            // mock登出
            this.mockMvc.perform(post("/logout.do")
                    .cookie(new Cookie("SESSION", sessionId)))
                    .andExpect(status().isOk()).andExpect(content().string("ok"));
        }
    }

    测试类执行结果:

    create session, sessionId is:429cb0d3-698a-475a-b3f1-09422acf2e9c
    current's user is:429cb0d3-698a-475a-b3f1-09422acf2e9cin session
    invalid session's user:UserVo{userName='admin', userPassword='admin@123'

    登录时创建Session,存储当前登录用户。然后在以登录响应返回的SessionId查询用户。最后再登出使Session过期。

    spring-boot整合spring-session自动配置原理

    前两篇文章介绍spring-session原理时,总结spring-session的核心模块。这节中探索spring-boot中自动配置如何初始化spring-session的各个核心模块。

    spring-boot-autoconfigure模块中包含了spinrg-session的自动配置。包org.springframework.boot.autoconfigure.session中包含了spring-session的所有自动配置项。

    其中RedisSession的核心配置项是RedisHttpSessionConfiguration类。

    @Configuration
    @ConditionalOnClass({ RedisTemplate.class, RedisOperationsSessionRepository.class })
    @ConditionalOnMissingBean(SessionRepository.class)
    @ConditionalOnBean(RedisConnectionFactory.class)
    @Conditional(ServletSessionCondition.class)
    @EnableConfigurationProperties(RedisSessionProperties.class)
    class RedisSessionConfiguration {
    
        @Configuration
        public static class SpringBootRedisHttpSessionConfiguration
                extends RedisHttpSessionConfiguration {
    
            // 加载application.yml或者application.properties中自定义的配置项:
            // 命名空间:用于作为session redis key的一部分
            // flushmode:session写入redis的模式
            // 定时任务时间:即访问redis过期键的定时任务的cron表达式
            @Autowired
            public void customize(SessionProperties sessionProperties,
                    RedisSessionProperties redisSessionProperties) {
                Duration timeout = sessionProperties.getTimeout();
                if (timeout != null) {
                    setMaxInactiveIntervalInSeconds((int) timeout.getSeconds());
                }
                setRedisNamespace(redisSessionProperties.getNamespace());
                setRedisFlushMode(redisSessionProperties.getFlushMode());
                setCleanupCron(redisSessionProperties.getCleanupCron());
            }
    
        }
    
    }

    RedisSessionConfiguration配置类中嵌套SpringBootRedisHttpSessionConfiguration继承了RedisHttpSessionConfiguration配置类。首先看下该配置类持有的成员。

    @Configuration
    @EnableScheduling
    public class RedisHttpSessionConfiguration extends SpringHttpSessionConfiguration
            implements BeanClassLoaderAware, EmbeddedValueResolverAware, ImportAware,
            SchedulingConfigurer {
    
    
        // 默认的cron表达式,application.yml可以自定义配置
        static final String DEFAULT_CLEANUP_CRON = "0 * * * * *";
    
        // session的有效最大时间间隔, application.yml可以自定义配置
        private Integer maxInactiveIntervalInSeconds = MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS;
    
        // session在redis中的命名空间,主要为了区分session,application.yml可以自定义配置
        private String redisNamespace = RedisOperationsSessionRepository.DEFAULT_NAMESPACE;
    
        // session写入Redis的模式,application.yml可以自定义配置
        private RedisFlushMode redisFlushMode = RedisFlushMode.ON_SAVE;
    
        // 访问过期Session集合的定时任务的定时时间,默认是每整分运行任务
        private String cleanupCron = DEFAULT_CLEANUP_CRON;
    
        private ConfigureRedisAction configureRedisAction = new ConfigureNotifyKeyspaceEventsAction();
    
        // spring-data-redis的redis连接工厂
        private RedisConnectionFactory redisConnectionFactory;
    
        // spring-data-redis的RedisSerializer,用于序列化session中存储的attributes
        private RedisSerializer<Object> defaultRedisSerializer;
    
        // session时间发布者,默认注入的是AppliationContext实例
        private ApplicationEventPublisher applicationEventPublisher;
    
        // 访问过期session键的定时任务的调度器
        private Executor redisTaskExecutor;
    
        private Executor redisSubscriptionExecutor;
    
        private ClassLoader classLoader;
    
        private StringValueResolver embeddedValueResolver;
    }

    该配置类中初始化了RedisSession的最为核心模块之一RedisOperationsSessionRepository。

    @Bean
    public RedisOperationsSessionRepository sessionRepository() {
        // 创建RedisOperationsSessionRepository
        RedisTemplate<Object, Object> redisTemplate = createRedisTemplate();
        RedisOperationsSessionRepository sessionRepository = new RedisOperationsSessionRepository(
                redisTemplate);
        // 设置Session Event发布者。如果对此迷惑,传送门:https://www.cnblogs.com/lxyit/p/9719542.html
        sessionRepository.setApplicationEventPublisher(this.applicationEventPublisher);
        if (this.defaultRedisSerializer != null) {
            sessionRepository.setDefaultSerializer(this.defaultRedisSerializer);
        }
        // 设置默认的Session最大有效期间隔
        sessionRepository
                .setDefaultMaxInactiveInterval(this.maxInactiveIntervalInSeconds);
        // 设置命名空间
        if (StringUtils.hasText(this.redisNamespace)) {
            sessionRepository.setRedisKeyNamespace(this.redisNamespace);
        }
        // 设置写redis的模式
        sessionRepository.setRedisFlushMode(this.redisFlushMode);
        return sessionRepository;
    }

    同时也初始化了Session事件监听器MessageListener模块

    @Bean
    public RedisMessageListenerContainer redisMessageListenerContainer() {
        // 创建MessageListener容器,这属于spring-data-redis范畴,略过
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(this.redisConnectionFactory);
        if (this.redisTaskExecutor != null) {
            container.setTaskExecutor(this.redisTaskExecutor);
        }
        if (this.redisSubscriptionExecutor != null) {
            container.setSubscriptionExecutor(this.redisSubscriptionExecutor);
        }
        // 模式订阅redis的__keyevent@*:expired和__keyevent@*:del通道,
        // 获取redis的键过期和删除事件通知
        container.addMessageListener(sessionRepository(),
                Arrays.asList(new PatternTopic("__keyevent@*:del"),
                        new PatternTopic("__keyevent@*:expired")));
        // 模式订阅redis的${namespace}:event:created:*通道,当该向该通道发布消息,
        // 则MessageListener消费消息并处理
        container.addMessageListener(sessionRepository(),
                Collections.singletonList(new PatternTopic(
                        sessionRepository().getSessionCreatedChannelPrefix() + "*")));
        return container;
    }

    上篇文章中介绍到的spring-session event事件原理,spring-session在启动时监听Redis的channel,使用Redis的键空间通知处理Session的删除和过期事件和使用Pub/Sub模式处理Session创建事件。

    关于RedisSession的存储管理部分已经初始化,但是spring-session的另一个基础设施模块SessionRepositoryFilter是在RedisHttpSessionConfiguration父类SpringHttpSessionConfiguration中初始化。

    @Bean
    public <S extends Session> SessionRepositoryFilter<? extends Session> springSessionRepositoryFilter(
            SessionRepository<S> sessionRepository) {
        SessionRepositoryFilter<S> sessionRepositoryFilter = new SessionRepositoryFilter<>(
                sessionRepository);
        sessionRepositoryFilter.setServletContext(this.servletContext);
        sessionRepositoryFilter.setHttpSessionIdResolver(this.httpSessionIdResolver);
        return sessionRepositoryFilter;
    }

    spring-boot整合spring-session配置的层次:

    RedisSessionConfiguration
        |_ _ SpringBootRedisHttpSessionConfiguration
                |_ _ RedisHttpSessionConfiguration
                        |_ _ SpringHttpSessionConfiguration

    回顾思考spring-boot自动配置spring-session,非常合理。

    • SpringHttpSessionConfiguration是spring-session本身的配置类,与spring-boot无关,毕竟spring-session也可以整合单纯的spring项目,只需要使用该spring-session的配置类即可。
    • RedisHttpSessionConfiguration用于配置spring-session的Redission,毕竟spring-session还支持其他的各种session:Map/JDBC/MogonDB等,将其从SpringHttpSessionConfiguration隔离开来,遵循开闭原则和接口隔离原则。但是其必须依赖基础的SpringHttpSessionConfiguration,所以使用了继承。RedisHttpSessionConfiguration是spring-session和spring-data-redis整合配置,需要依赖spring-data-redis。
    • SpringBootRedisHttpSessionConfiguration才是spring-boot中关键配置
    • RedisSessionConfiguration主要用于处理自定义配置,将application.yml或者application.properties的配置载入。

    Tips:
    配置类也有相当强的设计模式。遵循开闭原则:对修改关闭,对扩展开放。遵循接口隔离原则:变化的就要单独分离,使用不同的接口隔离。SpringHttpSessionConfiguration和RedisHttpSessionConfiguration的设计深深体现这两大原则。

    参考

    Spring Session

    https://www.cnblogs.com/lxyit/p/9720159.html

  • 相关阅读:
    sqlserver求标准偏差,oracle求标准偏差
    遇到 oracle 错误904 "maxsize" 标识符无效
    oracle添加自增字段
    oracle误删数据之表闪回
    浅淡ToString与Covert.ToString方法
    一段能用来统计ip访问的代码(自用)包括所在地
    Asp.Net 学习资源列表
    【META httpequiv="ContentType" Content="text/html; Charset=*】意义详解
    FreeTextBox treeview menu显示问题解决方法
    双边可关闭对联广告!
  • 原文地址:https://www.cnblogs.com/softidea/p/10323281.html
Copyright © 2020-2023  润新知