1. 程式人生 > >詳解 Spring Session 架構與設計

詳解 Spring Session 架構與設計

購物車 定義 能夠 響應頭 實現類 servle .cn 模塊 唯一標識

前言

開始進行 Web 開發時,您可能在使用 Session 時會碰到 Cookie 和 LocalStorage,被它們所幹擾。因為他們都可以存儲數據,有過期時間,不需要在使用時重新請求。您還會遇到這樣的情況,Web 容器(例如 Tomcat、Jetty)包含 Session 的實現,當服務器重啟之後,之前的登錄狀態會失效需要重新登錄。

為什麽需要 Spring Session
HTTP 協議

我們先從 HTTP 協議說起。HTTP 協議有個特點,是無狀態的,意味著請求與請求是沒有關系的。早期的 HTTP 協議只是用來簡單地瀏覽網頁,沒有其他需求,因此請求與請求之間不需要關聯。但現代的 Web 應用功能非常豐富,可以網上購物、支付、遊戲、聽音樂等等。如果請求與請求之間沒有關聯,就會出現一個很尷尬的問題:Web 應用不知道您是誰。例如,用戶登錄之後在購物車中添加了三件商品到購物車,刷新一下網頁,用戶仍然處於未登錄的狀態,購物車裏空空如也。很顯然這種情況是不可接受的。為此 HTTP 協議急需一種技術讓請求與請求之間建立起聯系來標識用戶。於是出現了 Cookie 技術。

Cookie 技術

Cookie 是 HTTP 報文的一個請求頭,Web 應用可以將用戶的標識信息或者其他一些信息(用戶名等等)存儲在 Cookie 中。用戶經過驗證之後,每次 HTTP 請求報文中都包含 Cookie;當然服務端為了標識用戶,即使不經過登錄驗證,也可以存放一個唯一的字符串用來標識用戶。采用 Cookie 就解決了用戶標識的問題,同時 Cookie 中包含有用戶的其他信息。Cookie 本質上就是一份存儲在用戶本地的文件,裏面包含了需要在每次請求中傳遞的信息。但 Cookie 存在以下缺點:

Cookie 具有時效性,超過過期時間就會失效。

服務提供商利用 cookie 惡意搜集用戶信息,例如用戶在未登錄的情況下去商城瀏覽了商品,商城就會把廣告公司的 Cookie 加入到用戶的瀏覽器中,每當用戶瀏覽和廣告公司合作的網站時,都會看到之前在商城瀏覽過的類似商品。

每次 Cookie 都會把除用戶標識之外的其他用戶信息也在 Cookie 中傳遞,增加了請求的流量開銷。

Session 技術

Cookie 以明文的方式存儲了用戶信息,造成了非常大的安全隱患,而 Session 的出現解決這個問題。用戶信息可以以 Session 的形式存儲在後端。這樣當用戶請求到來時,請求可以和 Session 對應起來,當後端處理請求時,可以從 Session 中獲取用戶信息。那麽 Session 是怎麽和請求對應起來的?答案是通過 Cookie,在 Cookie 中填充一個類似 SessionID 之類的字段用來標識請求。這樣用戶的信息存在後端,相對安全,也不需要在 Cookie 中存儲大量信息浪費流量。但前端想要獲取用戶信息,例如昵稱,頭像等信息,依然需要請求後端接口去獲取這些信息。

Session 管理

通過 Cookie、Session 這些技術,服務端可以標識到不同的用戶,從而提供一些個性化服務。隨著用戶規模的增長,一個應用有多個實例,部署在不同的 Web 容器中。因此應用不可能再依賴單一的 Web 容器來管理 Session,需要將 Session 管理拆分出來。為了實現 Session 管理,需要實現以下兩點:

Session 管理需要接入高可用,高性能的存儲組件。

有一種可靠的失效機制,當 Session 過期時,將 Session 失效掉。

為此常見的 Session 管理都會采用高性能的存儲方式來存儲 Session,例如 Redis 和 MemCache,並且通過集群的部署,防止單點故障,提升高可用性。然後采用定時器,或者後臺輪詢的方式在 Session 過期時將 Session 失效掉。

Spring Session 應運而生,它是一種流行的 Session 管理實現方式,相比上文提到的,Spring Session 做的要更多。Spring Session 並不和特定的協議如 HTTP 綁定,實現了一種廣義上的 Session,支持 WebSocket 和 WebSession 以及多種存儲類型如 Redis、MongoDB 等等。

Spring Session 架構設計
Spring Session 由核心模塊和具體存儲方式相關聯的實現模塊構成。核心模塊包含了 Spring Session 的基本抽象和 API。Spring Session 有兩個核心組件:Session 和 SessionRepository。Spring Session 簡單易用,通過 SessionRepository 來操作 Session。當建立會話時,創建 Session,將一些用戶信息(例如用戶 ID)存到 Session 中,並通過 SessionRepository 將 Session 持久化。當會話重新建立的時候,可以獲取到 Session 中的信息。同時後臺維護了一個定時任務,通過一些巧妙的方式,將過期的 Session 通過 SessionRepository 刪除掉。下面詳細介紹一下這兩個核心組件。

Session

Session 即會話,這裏的 Session 指的是廣義的 Session 並不和特定的協議如 HTTP 綁定,支持 HttpSession、WebSocket Session,以及其他與 Web 無關的 Session。Session 可以存儲與用戶相關的信息或者其他信息,通過維護一個鍵值對(Key-Value)來存儲這些信息。Session 接口簽名如清單 1 所示:

清單 1. Session 接口

public interface Session {
String getId();
<T> T getAttribute(String attributeName);
Set<String> getAttributeNames();
void setAttribute(String attributeName, Object attributeValue);
void removeAttribute(String attributeName);
}

以下是相關參數介紹:

getId:每個 Session 都有一個唯一的字符串用來標識 Session。

getAttribute:獲取 Session 中的數據,需要傳遞一個 name 獲取對應的存儲數據,返回類型是泛型,不需要進行強制轉換。

getAttributeNames:獲取 Session 中存儲信息所有的 name(也就是 Key)。

setAttribute:填充或修改 Session 中存儲的數據。

removeAttribute:刪除 Session 中填充的數據。

Session 因其存儲方式的不同,支持以下多種實現方式:

GemFireSession:采用 GemFire 作為數據源,在金融領域應用非常廣泛。

HazelcastSession:采用 Hazelcast 作為數據源。

JdbcSession:采用關系型數據庫作為數據源,支持 SQL。

MapSession:采用 Java 中的 Map 作為數據源,一般作為快速啟動的 demo 使用。

MongoExpiringSession:采用 MongoDB 作為數據源。

RedisSession:采用 Redis 作為數據源。

以上存儲方式中,采用 Redis 作為數據源非常流行,因此下文將重點討論 Spring Session 在 Redis 中實現。

SessionRepository

SessionRepository 用來增刪改查 Session 在對應數據源中的接口。SessionRepository 的接口簽名如清單 2 所示:

清單 2. SessionRepository 接口

public interface SessionRepository<S extends Session> {
S createSession();
void save(S session);
S getSession(String id);
void delete(String id);
}

以下是相關參數介紹:

createSession:創建 Session。

Session:更新 Session。

getSession:根據 ID 來獲取 Session。

delete:根據 ID 來刪除 Session。

Spring Session 在 Redis 中的實現
在 Spring Session 中最常用的數據源為 Redis,本部分將重點介紹 Spring Session 如何在 Redis 中實現。Spring Session 創建 Session 後,使用 SessionRepository 將 Session 持久化到 Redis 中。當 Session 中的數據更新時,Redis 中的數據也會更新;當 Session 被重新訪問刷新時,Redis 中的過期時間也會刷新;當 Redis 中的數據失效時,Session 也會失效。

采用 Redis 作為存儲對應的實現類

前文提到的 Session 和 SessionRepository 組件,Spring Session 采用 Redis 作為存儲方式時,都有對應的實現方式,即下面兩個實現類。

RedisSession

Session 在采用 Redis 作為存儲方式時,對應的實現類為 RedisSession。RedisSession 並不直接實現 Session, 而是實現了 ExpiringSession。ExpiringSession 增加了一些屬性,用來判斷 Session 是否失效,ExpiringSession 繼承 Session。RedisSession 的接口簽名如清單 3 所示:

清單 3. RedisSession 接口

 final class RedisSession implements ExpiringSession {
 private final MapSession cached;
 private Long originalLastAccessTime;
 String, Object> delta = new HashMap<String, Object>();
 private boolean isNew;
 private String originalPrincipalName;
 }

以下是相關參數介紹:

cached:采用 MapSession 作為緩存,意味著查找 Session 中的信息先從 MapSession 中查找,然後再從 Redis 中查找。

originalLastAccessTime:上一次訪問時間。

delta:與 Session 中的更新數據相關。

isNew:RedisSession 是否是新建的、未被更新過。

originalPrincipalName:主題名稱。

Session 在 Redis 中以 HashMap 的結構方式存儲。

RedisOperationsSessionRepository

SessionRepository 在采用 Redis 作為存儲方式時,對應的實現類為 RedisOperationSessionRepository。RedisOperationSessionRepository 並不直接實現 SessionRepository,而是實現了 FindByIndexNameSessionRepository。FindByIndexNameSessionRepository 繼承 SessionRepository,並提供了強大的 Session 查找接口。FindByIndexNameSessionRepository 接口如清單 4 所示:

清單 4.RedisOperationsSessionRepository 接口

public class RedisOperationsSessionRepository implements
FindByIndexNameSessionRepository <RedisOperationsSessionRepository.RedisSession>,
MessageListener {
 static final String DEFAULT_SPRING _SESSION_REDIS_PREFIX = "spring:session:";
static final String CREATION_TIME_ATTR = "creationTime";
static final String MAX_INACTIVE_ATTR = "maxInactiveInterval";
static final String LAST_ACCESSED_ATTR = "lastAccessedTime";
static final String SESSION_ATTR_PREFIX = "sessionAttr:";
}

以下是相關參數介紹:

DEFAULT_SPRING_SESSION_REDIS_PREFIX:Spring Session 在 Redis 中存儲 Session 的前綴。

CREATION_TIME_ATTR:Session 的創建時間。

MAX_INACTIVE_ATTR:Session 的有效時間。

LAST_ACCESSED_ATTR:Session 的上次使用時間。

SESSION_ATTR_PREFIX:例如在 Session 中存儲了 name 屬性,value 為小明,Session 在 Redis 中以 HashMap 的方式,那麽 name 的存儲方式為 sessionAttr:name, value 為小明。

Session 在 Redis 中的存儲結構

SessionRepository 存儲 Session,本質上是在操作 Redis,如清單 5 所示:

清單 5. Session 在 Redis 中的存儲

1. HMSET spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe creationTime 1404360000000 maxInactiveInterval 1800 lastAccessedTime 1404360000000 sessionAttr:name lee sessionAttr:mobile 18381111111
2. EXPIRE spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe 2100
3. APPEND spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe ""
4. EXPIRE spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe 1800
5. SADD spring:session:expirations:1439245080000 expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe
6. EXPIRE spring:session:expirations1439245080000 2100

在 Redis 中所有 Key 的前綴都是 spring:session(與上文中的 DEFAULT_SPRING_SESSION_REDIS_PREFIX)相對應。假設多個項目共用一個 Redis,這時需要改變前綴,前綴中可以加入項目名如 lily 變為 lily:spring:session。

在 Redis 中創建 Session

創建 Session 時會填充一個唯一的字符串用來標識 Session。在 Redis 中會為 Session 設置以下屬性 creationTime、maxInactiveInterval 和 lastAccessedTime 與上文中的創建時間、有效時間、上次訪問時間相對應。Session 中填充了兩個屬性 name 和 mobile。Session 的創建如清單 6 所示:

清單 6. Session 創建

HMSET spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe creationTime 1404360000000 maxInactiveInterval 1800 lastAccessedTime 1404360000000 sessionAttr:name lee sessionAttr:mobile 18381111111
 EXPIRE spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe 2100

Session 在 Redis 中創建之後觸發 SessionCreatedEvent,創建 Session 後需要額外的邏輯可以訂閱該事件。註意,Session 中的失效時間屬性 maxInactiveInterval 的值為 1800,但在 Redis 中 Session 的失效時間為 2100,這涉及到 Session 在 Redis 中的失效機制,下文會詳細解答。

在 Redis 中實現 Session 失效

Redis 提供了失效機制,可以為鍵值對設置失效期。試想一下,用 Redis 實現一個最簡單的 Session 失效,可以為存儲在 Redis 中的 Session 直接設置失效,時間設置為 1800 即可。但 Spring Session 為什麽沒有這樣做呢?這是 Spring Session 為應用提供的一個擴展點,當 Session 失效時,Spring Session 可以通過消息訂閱的方式通知到應用,應用可能會做出一些自己的邏輯處理。因此 Spring Session 新增加了 Expiration Key,為 Expiration Key 設置失效時間為 1800,如清單 7 所示:

清單 7. Expiration Key

 APPEND spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe ""
 EXPIRE spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe 1800

當 Expiration Key 被刪除之後會觸發 SessionDestroyEvent (內含 Session 相關信息)。Spring Session 會清除 Expiration Redis 中的 Session。但是存在這樣一個問題,Redis 無法保證當 Key 過期無法訪問時能夠觸發 SessionDestroyEvent。Redis 後臺維護了一個任務,去定時地檢測 Key 是否失效(不可訪問),如果失效會觸發 SessionDestroyEvent。但是這個任務的優先級非常低,很有可能 Key 已經失效了,但檢測任務沒有分配到執行時間片去觸發 SessionDestroyEvent。更多關於 Redis 中 Key 失效的細節參考 Timing of expired events。

為了解決這個問題,Spring Session 根據整點分鐘數維護了一個集合,根據 Expiration Key 的失效時間將其填充到 expirations:整點分鐘數的集合中,如清單 8 所示:

清單 8. expirations 集合

 SADD spring:session:expirations:1439245080000 expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe
 EXPIRE spring:session:expirations1439245080000 2100

Spring Session 後臺會維護一個定時任務去檢測符合整點分鐘數的 expirations 集合,然後訪問其中的 Expiration Key。如果 Expiration Key 已經失效,Redis 會自動刪除 Expiration Key 並觸發 SessionDestroyEvent,這樣 Spring Session 會清理掉已經觸發 SessionDestroyEvent 的 Session。Spring Session 維護的定時任務代碼在 RedisOperationsSessionRepository 中,如清單 9 所示:

清單 9. Spring Session 定時任務

@Scheduled(cron = "${spring.session.cleanup.cron.expression:0 * * * * *}")
public void cleanupExpiredSessions() {
this.expirationPolicy.cleanExpiredSessions();
}

定時任務每分鐘的 0 秒開始執行,如果開發人員覺得這個頻率太高,可以通過自定義 spring.session.cleanup.corn.expression 進行更改任務的執行時間。

通過上述分析,我們發現 Spring Session 設計的非常巧妙。Spring Session 並不會根據 expirations 集合中的內容去刪除 Expiration Key。而是對可能失效的 Expiration Key 進行請求,讓 Redis 自身判斷 Key 是否已經失效,如果失效則進行清除,觸發刪除事件。此外,在 Redis 集群中,如果不采用分布式鎖(會極大的降低性能),Redis 可能會錯誤的把一個 Key 標記為失效,如果冒然的刪除 Key 會導致出錯。采用請求 Expiration Key 的方式,Redis 自身會做出正確的判斷。

Spring Session 與 Web 的集成
Spring Session 是與協議無關的,因此想要在 Web 中使用 Spring Session 需要進行集成。一個很常見的問題是:Spring Session 在 Web 中的入口是哪裏?答案是 Filter。Spring Session 選擇 Filter 而不是 Servlet 的方式有以下優點:Spring Session 依賴 J2EE 標準,無需依賴特定的 MVC 框架。另一方面 Spring MVC 通過 Servlet 做請求轉發,如果 Spring Session 采用 Servlet,那麽 Spring Session 和 Spring MVC 的集成會存在問題。

Spring Session 與 Web 集成的時候,需要用到以下 4 個核心組件:SessionRepositoryFilter、SessionRepositoryRequestWrapper、SessionRepositoryResponseWrapper 和 MultiHttpSessionStrategy,它們的協作方式如下:

當請求到來的時候,SessionRepositoryFilter 會攔截請求,采用包裝器模式,將 HttpServletRequest 進行包裝為 SessionRepositoryRequestWrapper。

SessionRepositoryRequestWrapper 會覆蓋 HttpServletRequest 原本的 getSession()方法。getSession() 會改變 Session 的獲取和存儲方式,開發人員可以自己定義采用某種方式,例如 Redis、數據庫等來獲取 Session。用戶獲取到 Session 之後,可能會對 Session 做出改變,開發人員不需要手動的對 Session 進行提交和持久化,SpringSession 將自動完成。

SessionRepositoryFilter 將 HttpServletResponse 包裝為 SessionRepositoryResponseWrapper,並覆蓋 SessionRepositoryResponseWrapper 生命周期函數 onResponseCommitted(當請求處理完畢,該函數會被調用)。

在 onResponseCommitted 函數中,會調用 HttpSessionStrategy 確保 Session 被正確地持久化。這樣 Session 在 HTTP 的整個生命周期就完成了。

下面通過解析各組件的源碼來說明 Spring Session 如何與 Web 集成。

SessionRepositoryFilter

SessionRepositoryFilter 攔截所有請求,對 HttpServletRequest 進行包裝處理生成 SessionRepositoryRequestWrapper,對 HttpServletResponse 進行包裝處理生成 SessionRepositoryResponseWrapper。SessionRepositoryFilter 的核心代碼,如清單 10 所示:

清單 10. doFilterInternal 方法

 doFilterInternal(HttpServletRequest request,HttpServletResponse response, FilterChain filterChain){
SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryRequestWrapper(request, response, 
this.servletContext);
SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryResponseWrapper(wrappedRequest, 
response);
HttpServletRequest strategyRequest = this.httpSessionStrategy.wrapRequest(wrappedRequest, wrappedResponse);
HttpServletResponse strategyResponse = this.httpSessionStrategy.wrapResponse(wrappedRequest, wrappedResponse);
}

註意 SessionRepositoryFilter 必須放置在任何訪問或者進行 commit 操作之前,因為只有這樣才能保證 J2EE 的 Session 被 Spring Session 提供的 Session 進行復寫並進行正確的持久化。

SessionRepositoryRequestWrapper

SessionRepositoryRequestWrapper 是 HttpServletRequest 包裝類,並覆蓋 getSession 方法。getSession 方法會做如下操作:

調用 MultiHttpSessionStrategy 生成和獲取 Session 的唯一標識符 ID。

調用 SessionRepository 生成和獲取 Session。

getSession(boolean create)的代碼如清單 11 所示:

清單 11. getSession 方法

@Override
public HttpSessionWrapper getSession(boolean create) {
......
String requestedSessionId = getRequestedSessionId();
if (requestedSessionId != null && getAttribute(INVALID_SESSION_ID_ATTR) == null) {
S session = getSession(requestedSessionId);
......
}
......
S session = SessionRepositoryFilter .this.sessionRepository.createSession();
}

getRequestedSessionId 方法用來獲取 Session 的 ID,本質上就是調用 MultiHttpSessionStrategy 來獲取,如清單 12 所示:

清單 12. getRequestedSessionId 方法

 @Override
 public String getRequestedSessionId() {
 return SessionRepositoryFilter.this.httpSessionStrategy .getRequestedSessionId(this);
  }

getSession(String id)方法用來獲取 Session,本質上是調用 SessionRepository 來查找 Session,如清單 13 所示:

清單 13. getSession 方法

 private S getSession(String sessionId) {
 S session = SessionRepositoryFilter .this.sessionRepository.getSession(sessionId);
if (session == null) {
 return null;
 }
 session.setLastAccessedTime (System.currentTimeMillis());
 return session;
 }

SessionRepositoryResponseWrapper

SessionRepositoryResponseWrapper 是 HttpServletResponse 的包裝類,覆蓋了 onResponseCommitted 方法。主要職責是檢測 Session 是否失效,如果失效進行相應處理;確保新創建的 Session 被正確的持久化。onResponseCommitted 方法如清單 14 所示:

清單 14. onResponseCommitted 方法

@Override
protected void onResponseCommitted() {
this.request.commitSession();
}

onResponseCommitted 方法本質上調用 SessionRepositoryRequestWrapper 的 commitSession 方法,如清單 15 所示:
清單 15. commitSession 方法

private void commitSession() {
HttpSessionWrapper wrappedSession = getCurrentSession();
if (wrappedSession == null) {
if (isInvalidateClientSession()) {
SessionRepositoryFilter.this .httpSessionStrategy.onInvalidateSession(this, this.response);
 }
}
else {
S session = wrappedSession.getSession();
SessionRepositoryFilter.this .sessionRepository.save(session);
if(!isRequestedSessionIdValid()|| !session.getId().equals (getRequestedSessionId())){
 SessionRepositoryFilter.this .httpSessionStrategy.onNewSession (session,this, this.response);
}
}
}

commitSession 方法會判斷 Session 的狀態,進行失效、更新等處理。

MultiHttpSessionStrategy

MultiHttpSessionStrategy 繼承 RequestResponsePostProcessor 和 HttpSessionStrategy 接口。RequestResponsePostProcessor 接口,允許開發人員對 HttpServletRequest 和 HttpServletResponse 進行一些定制化的操作,例如讀取自定義的請求頭,進行個性化處理。

HttpSessionStrategy 即 Session 實現策略,上文提到 Session 的失效策略是采用 Cookie 的方式,因此 HttpSessionStrategy 的默認失效方式是 CookieHttpSessionStrategy。HttpSessionStrategy 的接口簽名如清單 16 所示:

清單 16. HttpSessionStrategy 方法

public interface HttpSessionStrategy {
String getRequestedSessionId (HttpServletRequest request);
void onNewSession (Session session, HttpServletRequest request ,HttpServletResponse response);
void onInvalidateSession (HttpServletRequest request, HttpServletResponse response);
}

以下是相關參數介紹:

getRequestedSessionId:獲取 Session 的 ID,默認從 Cookie 中獲取 Session 字段的值。

onNewSession:當用後臺為請求建立了 Session 時,需要通知瀏覽器等客戶端,接收 Session 的 ID。默認通過 Cookie 實現,將 Session 字段填充 Session 的 ID,並放置在 Set-cookie 響應頭中。

onInvalidateSession:當 Session 失效時調用,默認通過 Cookie 的方式,將 Session 字段刪除。

下面簡單演示一下采用 Cookie 來實現 Session,如清單 17 所示:

清單 17. 采用 Cookie 實現 Session 示例

Request(請求)
GET / HTTP/1.0
Host: kuboot.cn
Response(響應)
HTTP/1.0 200 OK
Set-cookie: session=”123”; domain=”kuboot.cn”
Request(請求)
GET / HTTP/1.0
Host: kuboot.cn
Cookie: session=”123”

**

項目樣例

**
這是一個 demo,演示了如何簡單的使用 Spring Session 與 Web 進行集成。項目地址:q q q u n:948368769(大量資源q u n)

結束語

本文分析了 Spring Session 的架構,演示了采用 Redis 存儲 Session 的實現細節,涉及時間監聽和如何通過定時任務巧妙地失效 Session。此外,通過源碼解析梳理了在 Web 中集成 Spring Session 的流程。

詳解 Spring Session 架構與設計