帥氣的 Spring Session 功能,基於 Redis 實現分佈式會話,還可以整合 Spring Security!...

點擊上方“芋道源碼”,選擇“設爲星標

做積極的人,而不是積極廢人!

源碼精品專欄

 

摘要: 原創出處 http://www.iocoder.cn/Spring-Boot/Distributed-Session/ 「芋道源碼」歡迎轉載,保留摘要,謝謝!

  • 1. 概述

  • 2. Spring Session

  • 3. 快速入門 Spring Session + Redis

  • 4. 快速入門 Spring Session + MongoDB

  • 5. 整合 Spring Security

  • 6. 整合 Shiro

  • 7. 自定義 sessionid

  • 666. 彩蛋


本文在提供完整代碼示例,可見 https://github.com/YunaiV/SpringBoot-Labs 的 lab-26 目錄。

原創不易,給點個 Star 嘿,一起衝鴨!

1. 概述

艿艿信奉的話很多,其中很重要的一條:在考慮高性能之前,一定要做高可用。很多時候,我們常常陷入追求一個功能或者系統的高性能,卻忽略了高可用。

爲什麼在這篇文章的開頭提到這個段呢?對於任何系統,無論多小的訪問量,一定要做系統的高可用。那麼在我們部署生產環境下的 Tomcat 等 Web 容器的時候,一定是需要部署多個節點。此時,Session 的一致性就成爲一個問題。爲什麼呢?

Session 的一致性,簡單來理解,就是相同 sessionid 在多個 Web 容器下,Session 的數據要一致。

我們先以用戶使用瀏覽器,Web 服務器爲單臺 TomcatA 舉例子。

  • 瀏覽器在第一次訪問 Web 服務器 TomcatA 時,TomcatA 會發現請求的 Cookie 中存在 sessionid ,所以創建一個 sessionid 爲 X 的 Session ,同時將該 sessionid 寫回給瀏覽器的 Cookie 中。

  • 瀏覽器在下一次訪問 Web 服務器 TomcatA 時,TomcatA 會發現請求的 Cookie 中存在 sessionid 爲 X ,則直接獲得 X 對應的 Session 。

友情提示,Tomcat 產生的 sessionid 爲 jsessionid 。

如果胖友對 Cookie 和 Session 的概念不是很清晰,建議可以先看下 《徹底理解 Cookie、Session、Token》 文章。

我們再以用戶使用瀏覽器,Web 服務器爲兩臺 TomcatA、TomcatB 舉例子。

  • 接上述例子,瀏覽器已經訪問 TomcatA ,獲得 sessionid 爲 X 。同時,在多臺 Tomcat 的情況下,我們需要採用 Nginx 做負載均衡。

  • 瀏覽器又發起一次請求訪問 Web 服務器,Nginx 負載均衡轉發請求到 TomcatB 上。TomcatB 會發現請求的 Cookie 中存在 sessionid 爲 X ,則直接獲得 X 對應的 Session 。結果呢,找不到 X 對應的 Session ,只好創建一個 sessionid 爲 X 的 Session 。

  • 此時,雖然說瀏覽器的 sessionid 是 X ,但是對應到兩個 Tomcat 中兩個 Session 。那麼,如果在 TomcatA 上做的 Session 修改,TomcatB 的 Session 還是原樣,這樣就會出現 Session 不一致的問題。

既然會出現 Session 不一致的問題,我們就要想辦法讓它們一致。一般來說,有三種方案。

第一種,Session 黏連

使用 Nginx 實現會話黏連,將相同 sessionid 的瀏覽器所發起的請求,轉發到同一臺服務器。這樣,就不會存在多個 Web 服務器創建多個 Session 的情況,也就不會發生 Session 不一致的問題。

不過,這種方式目前基本不被採用。因爲,如果一臺服務器重啓,那麼會導致轉發到這個服務器上的 Session 全部丟失。

具體怎麼實現這種方式,可以看看 《Nginx 第三方模塊 —— nginx-sticky-module 的使用(基於cookie的會話保持)》 文章。

第二種,Session 複製

Web 服務器之間,進行 Session 複製同步。僅僅適用於實現 Session 複製的 Web 容器,例如說 Tomcat 、Weblogic 等等。

不過,這種方式目前基本也不被採用。試想一下,如果我們有 5 臺 Web 服務器,所有的 Session 都要同步到每一個節點上,一個是效率低,一個是浪費內存。

具體怎麼實現這種方式,可以看看 [《Session 共享 —— Tomcat 集羣 Session 複製》](session 共享-tomcat集羣session複製) 文章。

第三種,Session 外部化存儲

不同於上述的兩種方案,Session 外部化存儲,考慮不再採用 Web 容器的內存中存儲 Session ,而是將 Session 存儲外部化,持久化到 MySQL、Redis、MongoDB 等等數據庫中。這樣,Tomcat 就可以無狀態化,專注提供 Web 服務或者 API 接口,未來拓展擴容也變得更加容易。

而實現 Session 外部化存儲也有兩種方式:

① 基於 Tomcat、Jetty 等 Web 容器自帶的拓展,使用讀取外部存儲器的 Session 管理器。例如說:

  • 《Redisson Tomcat會話管理器(Tomcat Session Manager)》 ,實現將 Tomcat 使用 Redis 存儲 Session 。

  • 《Jetty 集羣配置 Session 存儲到 MySQL、MongoDB》 ,實現 Jetty 使用 MySQL、MongoDB 存儲 Session 。

② 基於應用層封裝 HttpServletRequest 請求對象,包裝成自己的 RequestWrapper 對象,從而讓實現調用 HttpServletRequest#getSession() 方法時,獲得讀寫外部存儲器的 SessionWrapper 對象。例如說,稍後我們會看到的本文的主角 Spring Session 。

  • Spring Session 提供了 SessionRepositoryFilter 過濾器,它會過濾請求時,將請求 HttpServletRequest 對象包裝成 SessionRepositoryRequestWrapper 對象。代碼如下:

    // SessionRepositoryFilter.java
    
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // sessionRepository 是訪問外部數據源的操作類,例如說訪問 Redis、MySQL 等等
        request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);
        
    
        // 將請求和響應進行包裝成 SessionRepositoryRequestWrapper 和 SessionRepositoryResponseWrapper 對象
        SessionRepositoryFilter<S>.SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryFilter.SessionRepositoryRequestWrapper(request, response, this.servletContext);
        SessionRepositoryFilter.SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryFilter.SessionRepositoryResponseWrapper(wrappedRequest, response);
    
        // 繼續執行下一個過濾器
        try {
            filterChain.doFilter(wrappedRequest, wrappedResponse);
        } finally {
            // 請求結束,提交 Session 到外部數據源
            wrappedRequest.commitSession();
        }
    
    }
    
  • 調用 SessionRepositoryRequestWrapper#getSession() 方法時,返回的是自己封裝的 HttpSessionWrapper 對象。代碼如下:

    // SessionRepositoryFilter#SessionRepositoryRequestWrapper.java
    
     @Override
     public HttpSessionWrapper getSession() {
      return getSession(true);
     }
    
  • 後續,我們調用 HttpSessionWrapper 的方法,例如說 HttpSessionWrapper#setAttribute(String name, Object value) 方法,訪問的就是外部數據源,而不是內存中。

當然 ① 和 ② 兩種方案思路是類似且一致的,只是說拓展的提供者和位置不同。???? 相比來說,② 會比 ① 更加通用一些。

2. Spring Session

可能很多胖友都不太瞭解 Spring Session ,畢竟在 2015 年才誕生。而這個時候,每個公司或者架構師,已經有了自己的 Session 共享解決方案,這個我們在 「666. 彩蛋」 中細聊,畢竟咱不能搶主角 Spring Session 的戲份。

我們看看 https://spring.io/projects/spring-session 官方文檔對 Spring Session 的介紹。

Spring Session provides an API and implementations for managing a user’s session information.

Spring Session 提供了用於管理用戶會話信息的 API 和實現。

Features

Spring Session makes it trivial to support clustered sessions without being tied to an application container specific solution. It also provides transparent integration with:

  • HttpSession - allows replacing the HttpSession in an application container (i.e. Tomcat) neutral way, with support for providing session IDs in headers to work with RESTful APIs

  • WebSocket - provides the ability to keep the HttpSession alive when receiving WebSocket messages

  • WebSession - allows replacing the Spring WebFlux’s WebSession in an application container neutral way

Spring Session 使支持集羣會話變得非常簡單,無需綁定到特定於應用程序容器的解決方案。它還提供了透明的集成:

  • HttpSession - 允許以中立通用的方式替換應用程序容器(即 Tomcat)中的 HttpSession ,並支持在請求頭(Header)中提供 sessionid ,方便提供 RESTful API 。

  • WebSocket - 提供在接收 WebSocket 消息時保持 HttpSession 活躍的能力。???? 不然,HttpSession 就過期失效了。

  • WebSession - 允許以與應用程序容器無關的方式替換 Spring WebFlux 的 WebSession 。

Modules

Spring Session consists of the following modules:

  • Spring Session Core - provides core Spring Session functionalities and APIs

  • Spring Session Data Redis - provides SessionRepository and ReactiveSessionRepository implementation backed by Redis and configuration support

  • Spring Session JDBC - provides SessionRepository implementation backed by a relational database and configuration support

  • Spring Session Hazelcast - provides SessionRepository implementation backed by Hazelcast and configuration support

  • 可以使用 Redis、JDBC(訪問 MySQL、Oracle 等數據庫)、Hazelcast 作爲 Session 存儲的數據源。

  • 同時 Spring Session 也另外提供了 Spring Session MongoDB ,實現使用 MongoDB 作爲 Session 存儲的數據源。

3. 快速入門 Spring Session + Redis

示例代碼對應倉庫:lab-26-distributed-session-01 。

「Talk is cheap. Show me the code.」讓我們一起來一起入門 Spring Session 的門。本小節,我們會使用 Redis 作爲 Spring Session 的存儲器,這也是生產環境下,主流的選擇。

不過這個示例會比較簡單,瓜子和板凳就不用準備了,直接打開 IDEA ,一起跟着做即可。

3.1 引入依賴

pom.xml 文件中,引入相關依賴。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.1.10.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>lab-26-distributed-session-01</artifactId>

    <dependencies>
        <!-- 實現對 Spring MVC 的自動化配置 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- 實現對 Spring Session 使用 Redis 作爲數據源的自動化配置 -->
        <dependency>
            <groupId>org.springframework.session</groupId>
            <artifactId>spring-session-data-redis</artifactId>
        </dependency>

        <!-- 實現對 Spring Data Redis 的自動化配置 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
            <exclusions>
                <!-- 去掉對 Lettuce 的依賴,因爲 Spring Boot 優先使用 Lettuce 作爲 Redis 客戶端 -->
                <exclusion>
                    <groupId>io.lettuce</groupId>
                    <artifactId>lettuce-core</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <!-- 引入 Jedis 的依賴,這樣 Spring Boot 實現對 Jedis 的自動化配置 -->
        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
        </dependency>

    </dependencies>

</project>
  • 具體每個依賴的作用,胖友自己認真看下艿艿添加的所有註釋噢。

  • 在使用 Spring Data Redis 時,艿艿推薦使用 Jedis 作爲 Redis 操作的客戶端,所以這裏做了依賴的修改。本文關於 Spring Data Redis 的內容,就不做贅述了,胖友可以去看看 《芋道 Spring Boot Redis 入門》 文章。當然,現在不看,也不會對本文閱讀產生影響。

3.2 應用配置文件

resources 目錄下,創建 application.yaml 配置文件。配置如下:

spring:
  # 對應 RedisProperties 類
  redis:
    host: 127.0.0.1
    port: 6379
    password: # Redis 服務器密碼,默認爲空。生產中,一定要設置 Redis 密碼!
    database: 0 # Redis 數據庫號,默認爲 0 。
    timeout: 0 # Redis 連接超時時間,單位:毫秒。
    # 對應 RedisProperties.Jedis 內部類
    jedis:
      pool:
        max-active: 8 # 連接池最大連接數,默認爲 8 。使用負數表示沒有限制。
        max-idle: 8 # 默認連接數最大空閒的連接數,默認爲 8 。使用負數表示沒有限制。
        min-idle: 0 # 默認連接池最小空閒的連接數,默認爲 0 。允許設置 0 和 正數。
        max-wait: -1 # 連接池最大阻塞等待時間,單位:毫秒。默認爲 -1 ,表示不限制。

具體每個參數的作用,胖友自己認真看下艿艿添加的所有註釋噢。

3.3 SessionConfiguration

cn.iocoder.springboot.lab26.distributedsession.config 包路徑下,創建 SessionConfiguration 類,自定義 Spring Session Redis 的配置。代碼如下:

// SessionConfiguration.java

@Configuration
@EnableRedisHttpSession // 自動化配置 Spring Session 使用 Redis 作爲數據源
public class SessionConfiguration {

    /**
     * 創建 {@link RedisOperationsSessionRepository} 使用的 RedisSerializer Bean 。
     *
     * 具體可以看看 {@link RedisHttpSessionConfiguration#setDefaultRedisSerializer(RedisSerializer)} 方法,
     * 它會引入名字爲 "springSessionDefaultRedisSerializer" 的 Bean 。
     *
     * @return RedisSerializer Bean
     */
    @Bean(name = "springSessionDefaultRedisSerializer")
    public RedisSerializer springSessionDefaultRedisSerializer() {
        return RedisSerializer.json();
    }

}
  • 在類上,添加 @EnableRedisHttpSession 註解,開啓自動化配置 Spring Session 使用 Redis 作爲數據源。該註解有如下屬性:

    • RedisFlushMode.ON_SAVE ,在請求執行完成時,統一寫入 Redis 存儲。

    • RedisFlushMode.IMMEDIATE ,在每次修改 Session 時,立即寫入 Redis 存儲。

    • maxInactiveIntervalInSeconds 屬性,Session 不活躍後的過期時間,默認爲 1800 秒。

    • redisNamespace 屬性,在 Redis 的 key 的統一前綴,默認爲 "spring:session"

    • redisFlushMode 屬性,Redis 會話刷新模式(RedisFlushMode)。目前有兩種,默認爲 RedisFlushMode.ON_SAVE

    • cleanupCron 屬性,清理 Redis Session 會話過期的任務執行 CRON 表達式,默認爲 "0 * * * * *" 每分鐘執行一次。雖然說,Redis 自帶了 key 的過期,但是惰性刪除策略,實際過期的 Session 還在 Redis 中佔用內存。所以,Spring Session 通過定時任務,刪除 Redis 中過期的 Session ,儘快釋放 Redis 的內存。不瞭解 Redis 的刪除過期 key 的策略的胖友,可以看看 《Redis 中刪除過期 Key 的三種策略》 文章。

  • #springSessionDefaultRedisSerializer() 方法,定義了一個 Bean 名字爲 "springSessionDefaultRedisSerializer" 的 RedisSerializer Bean ,採用 JSON 序列化方式。因爲默認情況下,採用 Java 自帶的序列化方式 ,可讀性很差,所以進行替換。

3.4 Application

創建 Application.java 類,配置 @SpringBootApplication 註解即可。代碼如下:

// Application.java

@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

}

這裏,我們先不要啓動項目,等到我們添加好相應的測試類。

3.5 簡單測試

在本小節,我們會創建兩個接口,用於測試。

  • session/get_all 接口,返回 Session 中的內容。

  • session/set 接口,設置 key-value 鍵值對到 Session 中。

cn.iocoder.springboot.lab26.distributedsession.controller 包路徑下,創建 SessionController 類,提上述的兩個接口。代碼如下:

// SessionController.java

@RestController
@RequestMapping("/session")
public class SessionController {

    @GetMapping("/set") // 其實 PostMapping 更合適,單純爲了方便
    public void set(HttpSession session,
                    @RequestParam("key") String key,
                    @RequestParam("value") String value) {
        session.setAttribute(key, value);
    }

    @GetMapping("/get_all")
    public Map<String, Object> getAll(HttpSession session) {
        Map<String, Object> result = new HashMap<>();
        // 遍歷
        for (Enumeration<String> enumeration = session.getAttributeNames();
             enumeration.hasMoreElements();) {
            String key = enumeration.nextElement();
            Object value = session.getAttribute(key);
            result.put(key, value);
        }
        // 返回
        return result;
    }

}
  • 代碼比較簡單,胖友自己 10 秒鐘看懂。看不懂的,去角落自閉下。????

下面,開始開始我們的測試。

① 在瀏覽器中,訪問 "http://127.0.0.1:8080/session/get" 接口,返回目前的 Session 的內容。響應結果如下:

{}
  • 空空的,這也符合期望。

終端執行 redis-cli 命令,連接到 Redis 中,查看是否創建了一個 Session 。過程如下:

# 假設,我們已經在 redis-cli 中

127.0.0.1:6379> scan 0
1) "0"
2) 1) "spring:session:sessions:expires:bea153af-3b36-451e-bbc9-2637bcdc5b37"
   2) "spring:session:expirations:1574493180000"
   3) "spring:session:sessions:bea153af-3b36-451e-bbc9-2637bcdc5b37"
  • 每一個 Session 對應 Redis 三個 key-value 鍵值對。

    • 開頭:以 spring:session 開頭,可以通過 @EnableRedisHttpSession 註解的 redisNamespace 屬性配置。

    • 結尾:以對應 Session 的 sessionid 結尾。

    • 中間:中間分別是 "session""expirations"sessions:expires 。**一般情況下,我們只需要關注中間爲 "session" 的 key-value 鍵值對即可,它負責真正存儲 Session 數據。**對於中間爲 "sessions:expires""expirations" 的兩個來說,主要爲了實現主動刪除 Redis 過期的 Session 會話,解決 Redis 惰性刪除的“問題”。具體的實現原理,本文就不贅述,感興趣的胖友,可以看看 《從 Spring-Session 源碼看 Session 機制的實現細節》 文章。

艿艿:這裏表述有個錯誤,相信大家可以理解,先不修改了。

每一個 Session 對應 Redis 二個 key-value 鍵值對。而 "spring:session:expirations:{時間戳}" ,是爲了獲得每分鐘需要過期的 sessionid 集合,即 {時間戳} 是每分鐘的時間戳。

我們查看下 "spring:session:sessions:bea153af-3b36-451e-bbc9-2637bcdc5b37" 的內容。它是一個 Redis hash 數據結構。結果如下:

127.0.0.1:6379> HGETALL spring:session:sessions:bea153af-3b36-451e-bbc9-2637bcdc5b37
1) "lastAccessedTime" # 最後訪問時間
2) "1574491333155"
3) "maxInactiveInterval" # Session 允許最大不活躍時長,單位:秒。
4) "1800"
5) "creationTime" # 創建時間
6) "1574491333107"

127.0.0.1:6379> ttl spring:session:sessions:bea153af-3b36-451e-bbc9-2637bcdc5b37
(integer) 1089 # 雖然說,Spring Session Redis 實現了主動刪除,但是並不妨礙這裏也使用 Redis 自動過期策略。
  • 比較簡單,注意看下艿艿添加的註釋噢。

② 在瀏覽器中,訪問 "http://127.0.0.1:8080/session/set?key=who&value=yudaoyuanma""http://127.0.0.1:8080/session/set?key=author&value=nainai" 接口,設置兩個 key-value 鍵值對。

我們再查看下 "spring:session:sessions:bea153af-3b36-451e-bbc9-2637bcdc5b37" 的內容。結果如下:

127.0.0.1:6379> HGETALL spring:session:sessions:bea153af-3b36-451e-bbc9-2637bcdc5b37
 1) "lastAccessedTime"
 2) "1574492555921"
 3) "maxInactiveInterval"
 4) "1800"
 5) "creationTime"
 6) "1574491333107"
 7) "sessionAttr:who" # key 爲 who
 8) "\"yudaoyuanma\""
 9) "sessionAttr:author" # key 爲 author
10) "\"nainai\""
  • 我們調用 HttpSession#setAttribute(String name, Object value) 方法,設置的每一個 key-value 鍵值對,對應到 Redis hash 數據結構中的一個 key 。考慮到畢竟 key 衝突,使用 "sessionAttr:" 開頭。

至此,我們已經完成了 Spring Session Redis 的簡單入門。因爲考慮到讓示例的入門更加簡單,我們並沒有搭建多個 Spring Boot 節點。想要嘗試的胖友,可以自己弄下,嘿嘿。

4. 快速入門 Spring Session + MongoDB

示例代碼對應倉庫:lab-26-distributed-session-02 。

萬分好奇,Spring Session 會把 Session 如何存儲在 MongoDB 中,所以就有了本小節的入門。雖然說,MongoDB 提供了非常好的讀寫性能,但是相比 Redis 來說,還是略有的差距。感興趣的,可以看看它們的性能基準測試:

  • 《性能測試 —— MongoDB 基準測試》

  • 《性能測試 —— Redis 基準測試》

能夠想到使用 MongoDB 作爲 Session 的存儲,可能是項目中使用了 MongoDB ,而沒有使用 Redis 。恰好,我們又有分佈式 Session 的訴求,那麼使用 MongoDB 作爲 Session 的存儲,可以減少 Redis 節點的維護。畢竟,沒多引入一個組件,就多一份維護成本。

下面,讓們來嚐鮮嘗新,有點點小激動 ????。

友情提示:如果胖友沒有安裝 MongoDB 數據庫,可以參考 《芋道 MongoDB 極簡入門》 文章。

4.1 引入依賴

pom.xml 文件中,引入相關依賴。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.1.10.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>lab-26-distributed-session-02</artifactId>

    <dependencies>
        <!-- 實現對 Spring MVC 的自動化配置 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- 實現對 Spring Session 使用 MongoDB 作爲數據源的自動化配置 -->
        <dependency>
            <groupId>org.springframework.session</groupId>
            <artifactId>spring-session-data-mongodb</artifactId>
        </dependency>

        <!-- 自動化配置 Spring Data Mongodb -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-mongodb</artifactId>
        </dependency>

    </dependencies>

</project>
  • 具體每個依賴的作用,胖友自己認真看下艿艿添加的所有註釋噢。

  • Spring Data MongoDB 的入門,艿艿也寫了一篇,胖友可以去看看 《芋道 Spring Boot MongoDB 入門》 文章。當然,現在不看,也不會對本文閱讀產生影響。

4.2 應用配置文件

resources 目錄下,創建 application.yaml 配置文件。配置如下:

spring:
  data:
    # MongoDB 配置項,對應 MongoProperties 類
    mongodb:
      host: 127.0.0.1
      port: 27017
      database: yourdatabase
      username: test01
      password: password01
      # 上述屬性,也可以只配置 uri

logging:
  level:
    org:
      springframework:
        data:
          mongodb:
            core: DEBUG # 打印 mongodb 操作的具體語句。生產環境下,不建議開啓。

具體每個參數的作用,胖友自己認真看下艿艿添加的所有註釋噢。

4.3 SessionConfiguration

cn.iocoder.springboot.lab26.distributedsession.config 包路徑下,創建 SessionConfiguration 類,開啓 Spring Session MongoDB 的配置。代碼如下:

@Configuration
@EnableMongoHttpSession // 自動化配置 Spring Session 使用 MongoDB 作爲數據源
public class SessionConfiguration {

    @Bean
    public AbstractMongoSessionConverter mongoSessionConverter() {
        return new JacksonMongoSessionConverter();
    }

}
  • 在類上,添加 [@EnableMongoHttpSession]https://github.com/spring-projects/spring-session-data-mongodb/blob/master/src/main/java/org/springframework/session/data/mongo/config/annotation/web/http/EnableMongoHttpSession.java) 註解,開啓自動化配置 Spring Session 使用 MongoDB 作爲數據源。該註解有如下屬性:

    • maxInactiveIntervalInSeconds 屬性,Session 不活躍後的過期時間,默認爲 1800 秒。

    • collectionName 屬性,在 MongoDB 中,存儲 Session 的集合名,默認爲 "sessions"

  • #mongoSessionConverter() 方法,創建了 JacksonMongoSessionConverter Bean 對象,採用 JSON 序列化。因爲默認情況下,採用使用 JdkMongoSessionConverter 是 Java 自帶的序列化方式 ,可讀性很差,所以進行替換。

目前,Spring Session MongoDB 基於 MongoDB 自動過期刪除過期數據的機制,實現 Session 的自動過期。因爲 MongoDB 的自動過期機制,並不是像 Redis 是惰性刪除,所以無需實現定時任務,主動刪除來釋放內存。不瞭解 MongoDB 該機制的胖友,可以看看 《MongoDB 自動刪除過期數據 —— TTL 索引》 文章。

4.4 Application

創建 Application.java 類,配置 @SpringBootApplication 註解即可。代碼如下:

// Application.java

@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

}

這裏,我們先不要啓動項目,等到我們添加好相應的測試類。

4.5 簡單測試

在本小節,我們會創建兩個接口,用於測試。

  • session/get_all 接口,返回 Session 中的內容。

  • session/set 接口,設置 key-value 鍵值對到 Session 中。

cn.iocoder.springboot.lab26.distributedsession.controller 包路徑下,創建 SessionController 類,提上述的兩個接口。代碼如下:

// SessionController.java

@RestController
@RequestMapping("/session")
public class SessionController {

    @GetMapping("/set") // 其實 PostMapping 更合適,單純爲了方便
    public void set(HttpSession session,
                    @RequestParam("key") String key,
                    @RequestParam("value") String value) {
        session.setAttribute(key, value);
    }

    @GetMapping("/get_all")
    public Map<String, Object> getAll(HttpSession session) {
        Map<String, Object> result = new HashMap<>();
        // 遍歷
        for (Enumeration<String> enumeration = session.getAttributeNames();
             enumeration.hasMoreElements();) {
            String key = enumeration.nextElement();
            Object value = session.getAttribute(key);
            result.put(key, value);
        }
        // 返回
        return result;
    }

}
  • 代碼比較簡單,胖友自己 10 秒鐘看懂。看不懂的,去角落自閉下。????

下面,開始開始我們的測試。

① 在瀏覽器中,訪問 "http://127.0.0.1:8080/session/get" 接口,返回目前的 Session 的內容。響應結果如下:

{}
  • 空空的,這也符合期望。

使用 MongoDB 查詢 MongoDB 的數據。因爲此時我們還沒有 sessions 集合插入數據,所以 sessions 集合並未自動創建,因此數據爲空。

② 在瀏覽器中,訪問 "http://127.0.0.1:8080/session/set?key=who&value=yudaoyuanma""http://127.0.0.1:8080/session/set?key=author&value=nainai" 接口,設置兩個 key-value 鍵值對。

我們再查看下 sessions 集合的內容。結果如下:

{ 
    "_id" : "5a6e9b28-7740-422e-8d1c-50a4b2781d7a", 
    "@class" : "org.springframework.session.data.mongo.MongoSession", 
    "createdMillis" : NumberLong(1574501001259), 
    "accessedMillis" : NumberLong(1574501010539), 
    "intervalSeconds" : NumberInt(1800), 
    "expireAt" : ISODate("2019-11-23T17:53:30.539+0800"), 
    "attrs" : {
        "@class" : "java.util.HashMap", 
        "author" : "nainai", 
        "who" : "yudaoyuanma"
    }, 
    "principal" : null
}
  • 每個 Session 對應一條 sessions 集合的記錄。對應到實體類是 MongoSession 。代碼如下:

    // MongoSession.java
    public class MongoSession implements Session {
    
        private String id;
        private long createdMillis;
        private long accessedMillis;
        private long intervalSeconds;
        private Date expireAt;
        private Map<String, Object> attrs;
        
        // ... 省略方法
        
    }
    
  • _id 字段,sessionid 。

  • @class 字段,記錄對應的實體類,就是我們提到的 MongoSession 。

  • createdMillis 字段,Session 創建時間戳。

  • accessedMillis 字段,Session 最後訪問時間戳。

  • intervalSeconds 字段,Session 允許最大不活躍時長,單位:秒。

  • expireAt 字段,Session 過期時間。在該字段上,我們創建了 expireAt 所以, 用於實現過期 Session 記錄的自動刪除。索引信息如下:

    { 
        "v" : 2, 
        "name" : "expireAt", 
        "ns" : "yourdatabase.sessions", 
        "expireAfterSeconds" : 0
    }
    
  • attrs 字段,Session 的 attribute 屬性們。

  • principal 字段,用戶主體,和用戶身份認證相關。

至此,我們已經完成了 Spring Session MongoDB 的簡單入門。如果胖友是充滿好奇的小胖子,可以自己去嘗試下 Spring Session JDBC 。

5. 整合 Spring Security

示例代碼對應倉庫:lab-26-distributed-session-springsecurity 。

考慮到很多胖友,使用 Spring Security 作爲安全框架,所以我們來一起整合下 Spring Session + Spring Security 。整個過程,會和 「2. 快速入門 Spring Session + Redis」 小節,比較接近。

5.1  引入依賴

pom.xml 文件中,引入相關依賴。

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.1.10.RELEASE</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>lab-26-distributed-session-springsecurity</artifactId>

    <dependencies>
        <!-- 實現對 Spring MVC 的自動化配置 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- 實現對 Spring Session 使用 Redis 作爲數據源的自動化配置 -->
        <dependency>
            <groupId>org.springframework.session</groupId>
            <artifactId>spring-session-data-redis</artifactId>
        </dependency>

        <!-- 實現對 Spring Data Redis 的自動化配置 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
            <exclusions>
                <!-- 去掉對 Lettuce 的依賴,因爲 Spring Boot 優先使用 Lettuce 作爲 Redis 客戶端 -->
                <exclusion>
                    <groupId>io.lettuce</groupId>
                    <artifactId>lettuce-core</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <!-- 引入 Jedis 的依賴,這樣 Spring Boot 實現對 Jedis 的自動化配置 -->
        <dependency>
            <groupId>redis.clients</groupId>
            <artifactId>jedis</artifactId>
        </dependency>

        <!-- 實現對 Spring Security 的自動化配置 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        
    </dependencies>

</project>
  • 相比 「3.1 引入依賴」 來說,額外引入了 spring-boot-starter-security 依賴,實現對 Spring Security 的自動化配置。

5.2 應用配置文件

resources 目錄下,創建 application.yaml 配置文件。配置如下:

spring:
  # 對應 RedisProperties 類
  redis:
    host: 127.0.0.1
    port: 6379
    password: # Redis 服務器密碼,默認爲空。生產中,一定要設置 Redis 密碼!
    database: 0 # Redis 數據庫號,默認爲 0 。
    timeout: 0 # Redis 連接超時時間,單位:毫秒。
    # 對應 RedisProperties.Jedis 內部類
    jedis:
      pool:
        max-active: 8 # 連接池最大連接數,默認爲 8 。使用負數表示沒有限制。
        max-idle: 8 # 默認連接數最大空閒的連接數,默認爲 8 。使用負數表示沒有限制。
        min-idle: 0 # 默認連接池最小空閒的連接數,默認爲 0 。允許設置 0 和 正數。
        max-wait: -1 # 連接池最大阻塞等待時間,單位:毫秒。默認爲 -1 ,表示不限制。
  # 對應 SecurityProperties 類
  security:
    user: # 配置內存中,可登陸的用戶名和密碼
      name: yudaoyuanma
      password: nicai
  • 相比 「3.2 應用配置文件」 來說,增加了 spring.security 配置項,用於配置 Spring Security 內存中,可登陸的用戶名和密碼。

5.3 SessionConfiguration

cn.iocoder.springboot.lab26.distributedsession.config 包路徑下,創建 SessionConfiguration 類,自動化配置 Spring Session 使用 Redis 作爲數據源。代碼如下:

// SessionConfiguration.java

@Configuration
@EnableRedisHttpSession // 自動化配置 Spring Session 使用 Redis 作爲數據源
public class SessionConfiguration {

}
  • 相比 「3.3 SessionConfiguration」 來說,去掉了自定義的 JSON RedisSerializer Bean 的配置。原因是,HttpSession 的 attributes 屬性,是 Map<String, Object> 類型。

    • 在序列化 Session 到 Redis 中時,不存在問題。

    • 在反序列化 Redis 的 key-value 鍵值對成 Session 時,如果 attributes 的 value 存在 POJO 對象的時候,因爲不知道該 value 是什麼 POJO 對象,導致無法反序列化錯誤。

關於這個問題,胖友可以自己測試下,感受會更加明顯。目前,艿艿暫時找不到特別合適的解決方案,所以就換回 Java 序列化方式。也因此,在使用 Spring Session 時,先老實使用 Java 序列化方式吧

5.4 Application

創建 Application.java 類,配置 @SpringBootApplication 註解即可。代碼如下:

// Application.java

@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

}
  • 和 「3.4 Application」 一致。

這裏,我們先不要啓動項目,等到我們添加好相應的測試類。

5.5 簡單測試

本小節,我們來測試下登陸的流程,並觀察整個流程中的 Session 變化。下面,我們開始測試。

① 在瀏覽器中,訪問 "http://127.0.0.1:8080/" 地址,因爲 使用了 Spring Security ,所以會被自動攔截重定向到 "http://127.0.0.1:8080/login" 登陸地址。界面如下:

  • Spring Security 內置該登陸界面。

終端執行 redis-cli 命令,連接到 Redis 中,查看是否創建了一個 Session 。過程如下:

# 確實創建了一個 sessionid 爲 f65a475e-8550-49a4-b71d-0320c8cf887f 的 Session 。
127.0.0.1:6379> scan 0
1) "0"
2) 1) "spring:session:sessions:f65a475e-8550-49a4-b71d-0320c8cf887f"
   2) "spring:session:sessions:expires:f65a475e-8550-49a4-b71d-0320c8cf887f"
   3) "spring:session:expirations:1574437980000"
   
# 查看 spring:session:sessions:f65a475e-8550-49a4-b71d-0320c8cf887f 存儲的 value 。
# 因爲使用 Java 序列化方式,所以 hash 中的每個 key 對應的 value ,都無法直接讀懂。不過大概啥意思,我們應該是明白的。
127.0.0.1:6379> HGETALL spring:session:sessions:f65a475e-8550-49a4-b71d-0320c8cf887f
 1) "lastAccessedTime"
 2) "\xac\xed\x00\x05sr\x00\x0ejava.lang.Long;\x8b\xe4\x90\xcc\x8f#\xdf\x02\x00\x01J\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\x01n\x93\xb5\x80\xf6"
 3) "creationTime"
 4) "\xac\xed\x00\x05sr\x00\x0ejava.lang.Long;\x8b\xe4\x90\xcc\x8f#\xdf\x02\x00\x01J\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\x01n\x93\xb5\x80x"
 5) "sessionAttr:SPRING_SECURITY_SAVED_REQUEST"
 6) "\xac\xed\x00\x05sr\x00Aorg.springframework.security.web.savedrequest.DefaultSavedRequest\x1e@HD\xf96d\x94\x02\x00\x0eI\x00\nserverPortL\x00\x0bcontextPatht\x00\x12Ljava/lang/String;L\x00\acookiest\x00\x15Ljava/util/ArrayList;L\x00\aheaderst\x00\x0fLjava/util/Map;L\x00\alocalesq\x00~\x00\x02L\x00\x06methodq\x00~\x00\x01L\x00\nparametersq\x00~\x00\x03L\x00\bpathInfoq\x00~\x00\x01L\x00\x0bqueryStringq\x00~\x00\x01L\x00\nrequestURIq\x00~\x00\x01L\x00\nrequestURLq\x00~\x00\x01L\x00\x06schemeq\x00~\x00\x01L\x00\nserverNameq\x00~\x00\x01L\x00\x0bservletPathq\x00~\x00\x01xp\x00\x00\x1f\x90t\x00\x00sr\x00\x13java.util.ArrayListx\x81\xd2\x1d\x99\xc7a\x9d\x03\x00\x01I\x00\x04sizexp\x00\x00\x00\x01w\x04\x00\x00\x00\x01sr\x009org.springframework.security.web.savedrequest.SavedCookie\x03@+\x82\x9f\xc0\xb4f\x02\x00\bI\x00\x06maxAgeZ\x00\x06secureI\x00\aversionL\x00\acommentq\x00~\x00\x01L\x00\x06domainq\x00~\x00\x01L\x00\x04nameq\x00~\x00\x01L\x00\x04pathq\x00~\x00\x01L\x00\x05valueq\x00~\x00\x01xp\xff\xff\xff\xff\x00\x00\x00\x00\x00ppt\x00\aSESSIONpt\x000MDZhYjJiYTUtYzA2Ny00YmNhLWFiNWItODUyYmRmM2QwMGE1xsr\x00\x11java.util.TreeMap\x0c\xc1\xf6>-%j\xe6\x03\x00\x01L\x00\ncomparatort\x00\x16Ljava/util/Comparator;xpsr\x00*java.lang.String$CaseInsensitiveComparatorw\x03\\}\\P\xe5\xce\x02\x00\x00xpw\x04\x00\x00\x00\x0bt\x00\x06acceptsq\x00~\x00\x06\x00\x00\x00\x01w\x04\x00\x00\x00\x01t\x00vtext/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3xt\x00\x0faccept-encodingsq\x00~\x00\x06\x00\x00\x00\x01w\x04\x00\x00\x00\x01t\x00\x11gzip, deflate, brxt\x00\x0faccept-languagesq\x00~\x00\x06\x00\x00\x00\x01w\x04\x00\x00\x00\x01t\x00#zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7xt\x00\nconnectionsq\x00~\x00\x06\x00\x00\x00\x01w\x04\x00\x00\x00\x01t\x00\nkeep-alivext\x00\x06cookiesq\x00~\x00\x06\x00\x00\x00\x01w\x04\x00\x00\x00\x01t\x008SESSION=MDZhYjJiYTUtYzA2Ny00YmNhLWFiNWItODUyYmRmM2QwMGE1xt\x00\x04hostsq\x00~\x00\x06\x00\x00\x00\x01w\x04\x00\x00\x00\x01t\x00\x0e127.0.0.1:8080xt\x00\x0esec-fetch-modesq\x00~\x00\x06\x00\x00\x00\x01w\x04\x00\x00\x00\x01t\x00\bnavigatext\x00\x0esec-fetch-sitesq\x00~\x00\x06\x00\x00\x00\x01w\x04\x00\x00\x00\x01t\x00\x04nonext\x00\x0esec-fetch-usersq\x00~\x00\x06\x00\x00\x00\x01w\x04\x00\x00\x00\x01t\x00\x02?1xt\x00\x19upgrade-insecure-requestssq\x00~\x00\x06\x00\x00\x00\x01w\x04\x00\x00\x00\x01t\x00\x011xt\x00\nuser-agentsq\x00~\x00\x06\x00\x00\x00\x01w\x04\x00\x00\x00\x01t\x00xMozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.97 Safari/537.36xxsq\x00~\x00\x06\x00\x00\x00\x04w\x04\x00\x00\x00\x04sr\x00\x10java.util.Locale~\xf8\x11`\x9c0\xf9\xec\x03\x00\x06I\x00\bhashcodeL\x00\acountryq\x00~\x00\x01L\x00\nextensionsq\x00~\x00\x01L\x00\blanguageq\x00~\x00\x01L\x00\x06scriptq\x00~\x00\x01L\x00\avariantq\x00~\x00\x01xp\xff\xff\xff\xfft\x00\x02CNq\x00~\x00\x05t\x00\x02zhq\x00~\x00\x05q\x00~\x00\x05xsq\x00~\x003\xff\xff\xff\xffq\x00~\x00\x05q\x00~\x00\x05q\x00~\x006q\x00~\x00\x05q\x00~\x00\x05xsq\x00~\x003\xff\xff\xff\xfft\x00\x02USq\x00~\x00\x05t\x00\x02enq\x00~\x00\x05q\x00~\x00\x05xsq\x00~\x003\xff\xff\xff\xffq\x00~\x00\x05q\x00~\x00\x05q\x00~\x00:q\x00~\x00\x05q\x00~\x00\x05xxt\x00\x03GETsq\x00~\x00\x0cpw\x04\x00\x00\x00\x00xppt\x00\x01/t\x00\x16http://127.0.0.1:8080/t\x00\x04httpt\x00\t127.0.0.1t\x00\x01/"
 7) "maxInactiveInterval"
 8) "\xac\xed\x00\x05sr\x00\x11java.lang.Integer\x12\xe2\xa0\xa4\xf7\x81\x878\x02\x00\x01I\x00\x05valuexr\x00\x10java.lang.Number\x86\xac\x95\x1d\x0b\x94\xe0\x8b\x02\x00\x00xp\x00\x00\a\b"
 9) "sessionAttr:org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository.CSRF_TOKEN"
10) "\xac\xed\x00\x05sr\x006org.springframework.security.web.csrf.DefaultCsrfTokenZ\xef\xb7\xc8/\xa2\xfb\xd5\x02\x00\x03L\x00\nheaderNamet\x00\x12Ljava/lang/String;L\x00\rparameterNameq\x00~\x00\x01L\x00\x05tokenq\x00~\x00\x01xpt\x00\x0cX-CSRF-TOKENt\x00\x05_csrft\x00$582ef040-9a9f-43be-9708-e5b94394e338"
  • 看下艿艿添加的註釋喲。

② 在瀏覽器中,輸入賬號密碼,並點擊「Sign in」按鈕,進行登錄。登錄成功後,跳轉回 "http://127.0.0.1:8080/" 地址。因爲我們未提供該地址的 Controller 的接口,所以是 404 界面。不過,這並不影響我們的測試。

在 Redis 的終端中,查看此時該 Session 的變化。過程如下:

# 相比來說,多了一條 "spring:session:index:org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME:yudaoyuanma"
# 它代表的,就是我們剛登陸的用戶名,注意結尾是 "yudaoyuanma" 。

# 並且,比較神奇的是,原 sessionid="f65a475e-8550-49a4-b71d-0320c8cf887f" 的 Session 被刪除。
# 同時,一個新的 sessionid="127c5c93-1e57-4984-9780-5530a4bf9d0b" 的 Session 被創建
# 原因可見 https://www.jianshu.com/p/057fcf061b94 文章的「Spring Security 固定 Session 的保護」。
127.0.0.1:6379> scan 0
1) "0"
2) 1) "spring:session:sessions:expires:127c5c93-1e57-4984-9780-5530a4bf9d0b"
   2) "spring:session:expirations:1574438340000"
   3) "spring:session:index:org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME:yudaoyuanma"
   4) "spring:session:sessions:127c5c93-1e57-4984-9780-5530a4bf9d0b"
  
# 該 key 是 Redis Set 數據結構,存儲了該用戶名對應的所有 sessionid 集合。
# 所以,通過該 key ,我們可以獲取到指定用戶名,登陸的所有 sessionid 集合
# 具體怎麼操作,我們在 「5.6 FindByIndexNameSessionRepository」小節來看。
127.0.0.1:6379> SMEMBERS spring:session:index:org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME:yudaoyuanma
1) "\xac\xed\x00\x05t\x00$127c5c93-1e57-4984-9780-5530a4bf9d0b"   
  • 看下艿艿添加的註釋喲。

  • Spring Security 對 Session 的保護,真的是牛逼,學習到新知識了。

③ 在瀏覽器中,訪問 "http://127.0.0.1:8080/logout" 地址,因爲使用了 Spring Security ,所以內置了該登出(退出)界面。界面如下:

點擊「Log Out」按鈕,完成用戶的登出操作。完成後,自動重定向到 "http://127.0.0.1:8080/login" 登陸地址。

在 Redis 的終端中,查看此時該 Session 的變化。過程如下:

# 用戶退出後,"spring:session:index:org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME:yudaoyuanma" 被刪除
# 因爲,該用戶已經退出。

# 用戶退出後,新的 sessionid="1288372b-1ed3-417f-86ed-10c113a10783" 的 Session 被創建。

# 神奇的是,老的 sessionid="127c5c93-1e57-4984-9780-5530a4bf9d0b" 的 Session 並未被刪除,這是爲什麼呢?
# Spring Session 在失效刪除 Session 時,會保留該 sessionid 300 秒。對應源碼爲 RedisSessionExpirationPolicy#onExpirationUpdated(...) 方法的最後一行。
# 具體原因,是爲了實現 Session 過期的通知。
# 詳細的,可以看 《從 Spring-Session 源碼看 Session 機制的實現細節》 http://www.iocoder.cn/Spring-Session/laoxu/spring-session-4/?self
127.0.0.1:6379> scan 0
1) "0"
2) 1) "spring:session:expirations:1574439000000"
   2) "spring:session:expirations:1574437140000"
   3) "spring:session:expirations:1574438340000"
   4) "spring:session:sessions:expires:1288372b-1ed3-417f-86ed-10c113a10783"
   5) "spring:session:sessions:1288372b-1ed3-417f-86ed-10c113a10783"
   6) "spring:session:expirations:1574438940000"
   7) "spring:session:sessions:127c5c93-1e57-4984-9780-5530a4bf9d0b"
  • 看下艿艿添加的註釋喲。

至此,我們已經完成 Spring Session + Spring Security 的整合。整個過程,胖友最好自己操作一遍,也要看看 Redis 裏的 Session 變化噢。

5.6 FindByIndexNameSessionRepository

Spring Session 定義了 org.springframework.session.FindByIndexNameSessionRepository 接口,定義了根據用戶名,查詢登陸的所有 Session 信息。代碼如下:

// FindByIndexNameSessionRepository.java

public interface FindByIndexNameSessionRepository<S extends Session>
  extends SessionRepository<S> {

 String PRINCIPAL_NAME_INDEX_NAME = FindByIndexNameSessionRepository.class.getName()
   .concat(".PRINCIPAL_NAME_INDEX_NAME");

 Map<String, S> findByIndexNameAndIndexValue(String indexName, String indexValue);

 default Map<String, S> findByPrincipalName(String principalName) {
  return findByIndexNameAndIndexValue(PRINCIPAL_NAME_INDEX_NAME, principalName);
 }

}
  • #findByPrincipalName(principalName) 方法,根據用戶名,查詢登陸的所有 Session 信息。因爲用戶可以多點登陸,所以一個用戶名會對應多個 Session 信息。而返回的 Map 對象,其 key 爲 sessionid 。

  • #findByPrincipalName(String principalName) 方法的內部,會調用 #findByIndexNameAndIndexValue(indexName, indexValue) 方法,查詢指定 indexName 的 value 等於 indexValue 的所有 Session 信息。這裏,傳入的是 indexNamePRINCIPAL_NAME_INDEX_NAMEindexValue 爲用戶名。是不是有點懵逼?讓我們回過頭看看 "spring:session:index:org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME:yudaoyuanma" 這個 Redis key 。分解如下:

    • 第一部分,"spring:session:index" 爲 index 固定前綴。

    • 第二部分,"org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME"indexName

    • 第三部分,"yudaoyuanma"indexValue 用戶名。

    • 這樣,在 #findByIndexNameAndIndexValue(indexName, indexValue) 方法的內部實現,會將三個部分拼接在一起,成爲 "spring:session:index:org.springframework.session.FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME:yudaoyuanma" 字符串,最終去 Redis 查詢。也就是說,"spring:session:index" 的目的,是實現索引。在這裏,就是索引用戶登陸的多個 sessionid ,從而最終查詢到所有 Session 信息。

cn.iocoder.springboot.lab26.distributedsession.controller 包路徑下,創建 SessionController 類,添加查詢根據用戶名查詢所有 Session 信息的 API 接口。代碼如下:

// SessionController.java

@RestController
@RequestMapping("/session")
public class SessionController {

    @Autowired
    private FindByIndexNameSessionRepository sessionRepository;

    @GetMapping("/list")
    public Map<String, ? extends Session> list(@RequestParam("username") String username) {
        return sessionRepository.findByPrincipalName(username);
    }

}
  • 代碼比較簡單,胖友自己瞅瞅哈~

首先,在兩個瀏覽器中,登陸用戶名爲 "yudaoyuanma" 的用戶。
然後,瀏覽器訪問 "http://127.0.0.1:8080/session/list?username=yudaoyuanma" 接口,查詢用戶名爲 "yudaoyuanma" 的所有 Session 信息。響應結果如下:

{
   "f8ef73b0-a911-4921-9df9-056b4e18ce09":{
      "lastAccessedTime":"2019-11-22T17:37:36.331Z",
      "expired":false,
      "maxInactiveInterval":"PT30M",
      "new":false,
      "creationTime":"2019-11-22T17:09:01.157Z",
      "attributeNames":[
         "SPRING_SECURITY_CONTEXT"
      ],
      "id":"f8ef73b0-a911-4921-9df9-056b4e18ce09"
   },
   "ea037f98-112a-43c2-a337-d0d3d5a74a16":{
      "lastAccessedTime":"2019-11-22T17:37:27.631Z",
      "expired":false,
      "maxInactiveInterval":"PT30M",
      "new":false,
      "creationTime":"2019-11-22T17:37:21.915Z",
      "attributeNames":[
         "SPRING_SECURITY_CONTEXT"
      ],
      "id":"ea037f98-112a-43c2-a337-d0d3d5a74a16"
   }
}
  • 可以看到我們在兩個瀏覽器上,登陸的兩個 Session 。

6. 整合 Shiro

暫時偷懶下,這裏僅僅是立個 Flag 。至於什麼時候完成,???? 艿艿也不確定,嘿嘿。

???? 如果有對寫這個小節感興趣的胖友,可以寫完給艿艿投稿喲。

7. 自定義 sessionid

在 Spring Session 中,我們可以通過自定義 HttpSessionIdResolver Bean ,設置 sessionid 請求和響應時所在的地方。目前有兩種實現,也就是說提供兩種策略:

  • CookieHttpSessionIdResolver ,sessionid 存放在 Cookie 之中。

  • HeaderHttpSessionIdResolver ,sessionid 存放在 Header 之中。

7.1 CookieHttpSessionIdResolver

我們來看看瀏覽器中,sessionid 在 Cookie 中,是長什麼樣的?如下圖所示:

我們會看到,默認情況下,Spring Session 產生的 sessionid 的 KEY 爲 "SESSION" 。這是因爲 sessionid 在返回給前端時,使用 DefaultCookieSerializer 寫回 Cookie 給瀏覽器,在未自定義 sessionid 的 Cookie 名字的情況下,使用 "SESSION"

比較神奇的是,sessionid 的 VALUE 竟然看起來是一串加密的字符串?!???? 在 DefaultCookieSerializer 寫回 Cookie 給前端時,會將 sessionid 先 BASE64 編碼一下,然後再寫回 Cookie 給瀏覽器。

那麼,如果我們想自定義 sessionid 在 Cookie 中,使用別的 KEY 呢,例如說 "JSESSIONID" 。我們可以通過自定義 CookieHttpSessionIdResolver Bean 來實現。代碼如下:

// SessionConfiguration.java

@Bean
public CookieHttpSessionIdResolver sessionIdResolver() {
    // 創建 CookieHttpSessionIdResolver 對象
    CookieHttpSessionIdResolver sessionIdResolver = new CookieHttpSessionIdResolver();

    // 創建 DefaultCookieSerializer 對象
    DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
    sessionIdResolver.setCookieSerializer(cookieSerializer); // 設置到 sessionIdResolver 中
    cookieSerializer.setCookieName("JSESSIONID");

    return sessionIdResolver;
}
  • 我們可以看到,DefaultCookieSerializer 是 CookieHttpSessionIdResolver 的一個屬性,通過它來完成在 Cookie 中的 sessionid 的讀取和寫入。

刷新瀏覽器,我們再來看看 sessionid 在 Cookie 中,變成什麼樣了?如下圖所示:

當然,DefaultCookieSerializer 還提供了 Cookie 很多其它的配置選項,胖友可以點擊 傳送門 查看,例如說:cookieMaxAge 存活時長,domainName 所屬域。

7.2 HeaderHttpSessionIdResolver

當我們希望 session 存放在 Header 之中時,我們可以通過自定義 CookieHttpSessionIdResolver Bean 來實現。代碼如下:

// SessionConfiguration.java

@Bean
public HeaderHttpSessionIdResolver sessionIdResolver() {
//        return HeaderHttpSessionIdResolver.xAuthToken();
//        return HeaderHttpSessionIdResolver.authenticationInfo();
    return new HeaderHttpSessionIdResolver("token");
}
  • 創建 HeaderHttpSessionIdResolver 對象,傳入 headerName 請求頭名。例如說,這裏艿艿傳入了 "token"

隨便請求一個 API 接口,我們來看看響應的 Header 中,是不是有 "token" 的返回。如下圖所示:

  • 在紅圈處,我們可以看到確實響應的 Header 中有 "token" 的返回。

後續,前端在請求後端 API 接口時,也需要在請求的 Header 爲 "token" 上,帶上該 sessionid 值。

666. 彩蛋

實際項目中,相信大多數胖友基本沒有使用過 Spring Session 。艿艿自己也去問了一些老友,大概 10 來個,只有一個在很久以前的項目中使用過。

那麼,如果我們拋開 Spring Session ,而單純談論 Session ,是否大家項目中有在使用 Session 呢?艿艿又去問了這圈老友,估計要被我煩死了,哈哈哈哈,貌似使用 Session 的人也並不多。

又去知乎上搜了和 Session 相關的討論,發現了 《Session 正在被淘汰嗎?》 文章,發現確實大家的觀點比較接近,Session 慢慢不再被項目中採用。這是爲什麼呢?艿艿也來談談自己的觀點。

在過去,客戶端一般來說主要就是瀏覽器,而現在客戶端可以是 PC 瀏覽器、Mobile 瀏覽器、微信小程序、iOS 又或者 Android 客戶端。而 sessionid 的機制,是將當前客戶端和服務端的 Session 會話進行綁定。現在,用戶會使用多個客戶端,這個是目的非常常見的情況。那麼,一個用戶在多個客戶端,會有多個 sessionid ,和服務端的多個 Session 會話進行綁定。

我們以購物車的場景舉例子,畢竟大家都愛買買買。用戶在 PC 瀏覽器上,添加商品到購物車中,我們選擇將該信息存儲到 Session 中。那麼,如果此時用戶在微信小程序中打開購物車,是看不到這個商品的。因爲,該用戶的 PC 瀏覽器和微信小程序對應兩個不同的 Session 。

顯然,這個現象在這個年代,是無法被接受的,測試小姐姐跳起來就給你一個 BUG 。不過,如果胖友對很久以前的電商的購物車功能有印象的話,這個現象非常常見。

這個時候,我們的選擇,一般是將添加商品到購物車的信息緩存到 Redis 中。這樣,用戶在多個客戶端就能查看到這個商品了。

現在,基本絕大多數會話信息,都是希望做到用戶級別的共享,那麼 Session 的定位就非常尷尬,慢慢的都被拆分到 Redis 緩存中。所以,也就慢慢出現了 《Session 正在被淘汰嗎?》 的聲音。

同時,因爲 sessionid 可以和服務端的 Session 會話進行綁定,所以用戶在登陸之後,sessionid 我們就可以作爲用戶的身份標識。而現在,OAuth2 和 JWT 慢慢普及開來,大家越來越傾向使用這兩者取代 sessionid 。

艿艿的話,傾向功能強大的 OAuth2 。爲什麼呢?現在都是長會話,用戶登陸後,保持 30 天有效。

  • 那麼 OAuth2 相比 sessionid 來說,每次請求帶的是 accessToken 訪問令牌,過期時間是 2 小時,萬一泄露也最多是 2 小時。而 sessionid 泄露是“永久”,因爲可以不斷活躍,刷新會話的過期時間。???? 當然,機智的胖友可能會問,OAuth2 萬一泄露的是 refreshToken 刷新令牌呢?那可能有點危險,不過因爲 refreshToken 並不會每次請求都帶着,所以泄露的機率會大大降低。

  • 那麼 OAuth2 相比 JWT 來說,因爲 JWT 是無狀態的 Token ,所以無法實現服務端級的嚴謹的 Token 過期策略。例如說,用戶登出 Token 失效,又或者用戶修改密碼 Token 失效。因此,雖然說 OAuth2 需要引入藉助外部存儲器來存儲狀態,但是帶來的好處不言而喻。同時,OAuth2 提供了四種認證方式,也爲未來的業務拓展提供了更多的可能性。

不過呢,也不能說 Session 還是有很多使用場景的。

  • 對於管理後臺來說,一般只有 PC 端,基本不太存在多 Session 共享的煩惱。

  • 也可以只使用 sessionid 作爲身份標識,而會話信息存儲到 Redis 當中。這樣,sessionid 就有點像是一個單純的 Token 。

???? 關於這塊的觀點,歡迎一起討論。

艿艿:還有兩點可以補充下,這裏就簡單說下。

前後端分離後,一些臨時狀態交給前端處理即可。例如說,登陸後的回調地址,無需在放在 Session 之中了。

微服務拆分之後,如果在 Session 存儲會話信息,就顯得太重,因爲一些信息並不是所有服務都需要,所以需要經過一定拆分,存儲到 Redis 當中。

另外,可能有同學會想問 Spring Session 是否能夠和 Spring Security OAuth2 做集成呢?目前看下來,是暫時不支持,可以看看 Spring Session 下的 https://github.com/spring-projects/spring-session/issues/149 討論。



歡迎加入我的知識星球,一起探討架構,交流源碼。加入方式,長按下方二維碼噢

已在知識星球更新源碼解析如下:

最近更新《芋道 SpringBoot 2.X 入門》系列,已經 20 餘篇,覆蓋了 MyBatis、Redis、MongoDB、ES、分庫分表、讀寫分離、SpringMVC、Webflux、權限、WebSocket、Dubbo、RabbitMQ、RocketMQ、Kafka、性能測試等等內容。

提供近 3W 行代碼的 SpringBoot 示例,以及超 4W 行代碼的電商微服務項目。

獲取方式:點“在看”,關注公衆號並回復 666 領取,更多內容陸續奉上。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章