1. 程式人生 > >Spring系列.@EnableRedisHttpSession原理簡析

Spring系列.@EnableRedisHttpSession原理簡析


在集群系統中,經常會需要將Session進行共享。不然會出現這樣一個問題:使用者在系統A上登陸以後,假如後續的一些操作被負載均衡到系統B上面,系統B發現本機上沒有這個使用者的Session,會強制讓使用者重新登陸。此時使用者會很疑惑,自己明明登陸過了,為什麼還要自己重新登陸。


什麼是Session

這邊再普及下Session的概念:Session是伺服器端的一個key-value的資料結構,經常被使用者和cookie配合,保持使用者的登陸回話。客戶端在第一次訪問服務端的時候,服務端會響應一個sessionId並且將它存入到本地cookie中,在之後的訪問會將cookie中的sessionId放入到請求頭中去訪問伺服器,如果通過這個sessionid沒有找到對應的資料那麼伺服器會建立一個新的sessionid並且響應給客戶端。

分散式Session的解決方案

  • 使用cookie來完成(很明顯這種不安全的操作並不可靠)
  • 使用Nginx中的ip繫結策略,同一個ip只能在指定的同一個機器訪問(不支援負載均衡)
  • 利用資料庫同步session(效率不高)
  • 使用tomcat內建的session同步(同步可能會產生延遲)
  • 使用token代替session
  • 我們使用spring-session以及整合好的解決方案,存放在Redis中

最後一種方案是本文要介紹的重點。

Spring Session使用方式

新增依賴

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

添加註解@EnableRedisHttpSession

@Configuration
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 86400*30)
public class RedisSessionConfig {
}

maxInactiveIntervalInSeconds: 設定 Session 失效時間,使用 Redis Session 之後,原 Spring Boot 的 server.session.timeout 屬性不再生效。

經過上面的配置後,Session呼叫就會自動去Redis存取。另外,想要達到Session共享的目的,只需要在其他的系統上做同樣的配置即可。

4. Spring Session Redis的原理簡析

看了上面的配置,我們知道開啟Redis Session的“祕密”在@EnableRedisHttpSession這個註解上。開啟@EnableRedisHttpSession的原始碼:

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
@Documented
@Import(RedisHttpSessionConfiguration.class)
@Configuration
public @interface EnableRedisHttpSession {
    //Session預設過期時間,秒為單位,預設30分鐘
    int maxInactiveIntervalInSeconds() default MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS;
    //配置key的namespace,預設的是spring:session,如果不同的應用共用一個redis,應該為應用配置不同的namespace,這樣才能區分這個Session是來自哪個應用的
    String redisNamespace() default RedisOperationsSessionRepository.DEFAULT_NAMESPACE;
    //配置重新整理Redis中Session的方式,預設是ON_SAVE模式,只有當Response提交後才會將Session提交到Redis
    //這個模式也可以配置成IMMEDIATE模式,這樣的話所有對Session的更改會立即更新到Redis
    RedisFlushMode redisFlushMode() default RedisFlushMode.ON_SAVE;
    //清理過期Session的定時任務預設一分鐘一次。
    String cleanupCron() default RedisHttpSessionConfiguration.DEFAULT_CLEANUP_CRON;
}

這個註解的主要作用是註冊一個SessionRepositoryFilter,這個Filter會攔截到所有的請求,對Session進行操作,具體的操作細節會在後面講解,這邊主要了解這個註解的作用是註冊SessionRepositoryFilter就行了。注入SessionRepositoryFilter的程式碼在RedisHttpSessionConfiguration這個類中。

@Configuration
@EnableScheduling
public class RedisHttpSessionConfiguration extends SpringHttpSessionConfiguration
        implements BeanClassLoaderAware, EmbeddedValueResolverAware, ImportAware,
        SchedulingConfigurer {
            ...
        }

RedisHttpSessionConfiguration繼承了SpringHttpSessionConfiguration,SpringHttpSessionConfiguration中註冊了SessionRepositoryFilter。見下面程式碼。

@Configuration
public class SpringHttpSessionConfiguration implements ApplicationContextAware {
    ...
     @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;
    }
     ...
}

我們發現註冊SessionRepositoryFilter時需要一個SessionRepository引數,這個引數是在RedisHttpSessionConfiguration中被注入進入的。

@Configuration
@EnableScheduling
public class RedisHttpSessionConfiguration extends SpringHttpSessionConfiguration
        implements BeanClassLoaderAware, EmbeddedValueResolverAware, ImportAware,
        SchedulingConfigurer {
            ...
            @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);
        int database = resolveDatabase();
        sessionRepository.setDatabase(database);
        return sessionRepository;
    }    
          ...
        }

請求進來的時候攔截器會先將request和response攔截住,然後將這兩個物件轉換成Spring內部的包裝類SessionRepositoryRequestWrapper和SessionRepositoryResponseWrapper物件。SessionRepositoryRequestWrapper類重寫了原生的getSession方法。程式碼如下:

    @Override
        public HttpSessionWrapper getSession(boolean create) {
             //通過request的getAttribue方法查詢CURRENT_SESSION屬性,有直接返回
            HttpSessionWrapper currentSession = getCurrentSession();
            if (currentSession != null) {
                return currentSession;
            }
             //查詢客戶端中一個叫SESSION的cookie,通過sessionRepository物件根據SESSIONID去Redis中查詢Session
            S requestedSession = getRequestedSession();
            if (requestedSession != null) {
                if (getAttribute(INVALID_SESSION_ID_ATTR) == null) {
                    requestedSession.setLastAccessedTime(Instant.now());
                    this.requestedSessionIdValid = true;
                    currentSession = new HttpSessionWrapper(requestedSession, getServletContext());
                    currentSession.setNew(false);
                      //將Session設定到request屬性中
                    setCurrentSession(currentSession);
                      //返回Session
                    return currentSession;
                }
            }
            else {
                // This is an invalid session id. No need to ask again if
                // request.getSession is invoked for the duration of this request
                if (SESSION_LOGGER.isDebugEnabled()) {
                    SESSION_LOGGER.debug(
                            "No session found by id: Caching result for getSession(false) for this HttpServletRequest.");
                }
                setAttribute(INVALID_SESSION_ID_ATTR, "true");
            }
             //不建立Session就直接返回null
            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建立RedisSession這個物件,可以看下這個類的原始碼,如果
             //@EnableRedisHttpSession這個註解中的redisFlushMode模式配置為IMMEDIATE模式,會立即
             //將建立的RedisSession同步到Redis中去。預設是不會立即同步的。
            S session = SessionRepositoryFilter.this.sessionRepository.createSession();
            session.setLastAccessedTime(Instant.now());
            currentSession = new HttpSessionWrapper(session, getServletContext());
            setCurrentSession(currentSession);
            return currentSession;
        }

當呼叫SessionRepositoryRequestWrapper物件的getSession方法拿Session的時候,會先從當前請求的屬性中查詢.CURRENT_SESSION屬性,如果能拿到直接返回,這樣操作能減少Redis操作,提升效能。

到現在為止我們發現如果redisFlushMode配置為ON_SAVE模式的話,Session資訊還沒被儲存到Redis中,那麼這個同步操作到底是在哪裡執行的呢?我們發現SessionRepositoryFilter的doFilterInternal方法最後有一個finally程式碼塊,這個程式碼塊的功能就是將Session同步到Redis。

    @Override
    protected void doFilterInternal(HttpServletRequest request,
            HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);

        SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryRequestWrapper(
                request, response, this.servletContext);
        SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryResponseWrapper(
                wrappedRequest, response);

        try {
            filterChain.doFilter(wrappedRequest, wrappedResponse);
        }
        finally {
             //將Session同步到Redis,同時這個方法還會將當前的SESSIONID寫到cookie中去,同時還會發布一
             //SESSION建立事件到佇列裡面去
            wrappedRequest.commitSession();
        }
    }

總結

主要的核心類有:

  • @EnableRedisHttpSession:開啟Session共享功能
  • RedisHttpSessionConfiguration:配置類,一般不需要我們自己配置。主要功能是配置SessionRepositoryFilter和RedisOperationsSessionRepository這兩個Bean
  • SessionRepositoryFilter:攔截器
  • RedisOperationsSessionRepository:可以認為是一個Redis操作的客戶端,有在Redis中增刪改查Session的功能
  • SessionRepositoryRequestWrapper:Request的包裝類,主要是重寫了getSession方法
  • SessionRepositoryResponseWrapper:Response的包裝類。

原理簡要總結:

當請求進來的時候,SessionRepositoryFilter會先攔截到請求,將request和Response物件轉換成SessionRepositoryRequestWrapper和SessionRepositoryResponseWrapper。後續當第一次呼叫request的getSession方法時,會呼叫到SessionRepositoryRequestWrapper的getSession方法。這個方法的邏輯是先從request的屬性中查詢,如果找不到;再查詢一個key值是"SESSION"的cookie,通過這個cookie拿到sessionId去redis中查詢,如果查不到,就直接建立一個RedisSession物件,同步到Redis中(同步的時機根據配置來)。

遺留問題

  • 什麼時候寫的cookie
  • 清理過期Session的功能怎麼實現的
  • 自定義HttpSessionStrategy

參考

  • https://www.cnblogs.com/SimpleWu/p/10118674.html