通過Spring Session實現新一代的Session管理
原文:http://www.infoq.com/cn/articles/Next-Generation-Session-Management-with-Spring-Session
Spring Session是如何執行的
我們已經討論了在傳統的應用伺服器中,HTTP session管理存在不足的各種場景,接下來看一下Spring Session是如何解決這些問題的。
Spring Session的架構
當實現session管理器的時候,有兩個必須要解決的核心問題。首先,如何建立叢集環境下高可用的session,要求能夠可靠並高效地儲存資料。其次,不管請求是HTTP、WebSocket、AMQP還是其他的協議,對於傳入的請求該如何確定該用哪個session例項。實質上,關鍵問題在於:在發起請求的協議上,session id該如何進行傳輸?
Spring Session認為第一個問題,也就是在高可用可擴充套件的叢集中儲存資料已經通過各種資料儲存方案得到了解決,如Redis、GemFire以及Apache Geode等等,因此,Spring Session定義了一組標準的介面,可以通過實現這些介面間接訪問底層的資料儲存。Spring Session定義瞭如下核心介面:Session、ExpiringSession
以及SessionRepository
,針對不同的資料儲存,它們需要分別實現。
org.springframework.session.Session
介面定義了session的基本功能,如設定和移除屬性。這個介面並不關心底層技術,因此能夠比servlet HttpSession適用於更為廣泛的場景中。org.springframework.session.ExpiringSession
擴充套件了Session介面,它提供了判斷session是否過期的屬性。RedisSession是這個介面的一個樣例實現。org.springframework.session.SessionRepository
定義了建立、儲存、刪除以及檢索session的方法。將Session例項真正儲存到資料儲存的邏輯是在這個介面的實現中編碼完成的。例如,RedisOperationsSessionRepository就是這個介面的一個實現,它會在Redis中建立、儲存和刪除session。
Spring Session認為將請求與特定的session例項關聯起來的問題是與協議相關的,因為在請求/響應週期中,客戶端和伺服器之間需要協商同意一種傳遞session id的方式。例如,如果請求是通過HTTP傳遞進來的,那麼session可以通過HTTP cookie或HTTP Header資訊與請求進行關聯。如果使用HTTPS的話,那麼可以藉助SSL session id實現請求與session的關聯。如果使用JMS的話,那麼JMS的Header資訊能夠用來儲存請求和響應之間的session id。
對於HTTP協議來說,Spring Session定義了HttpSessionStrategy
介面以及兩個預設實現,即CookieHttpSessionStrategy
和HeaderHttpSessionStrategy
,其中前者使用HTTP cookie將請求與session id關聯,而後者使用HTTP
header將請求與session關聯。
如下的章節詳細闡述了Spring Session使用HTTP協議的細節。
在撰寫本文的時候,在當前的Spring Session 1.0.2 GA釋出版本中,包含了Spring Session使用Redis的實現,以及基於Map的實現,這個實現支援任意的分散式Map,如Hazelcast。讓Spring Session支援某種資料儲存是相當容易的,現在有支援各種資料儲存的社群實現。
Spring Session對HTTP的支援
Spring Session對HTTP的支援是通過標準的servlet filter來實現的,這個filter必須要配置為攔截所有的web應用請求,並且它應該是filter鏈中的第一個filter。Spring Session filter會確保隨後呼叫javax.servlet.http.HttpServletRequest
的getSession()
方法時,都會返回Spring
Session的HttpSession
例項,而不是應用伺服器預設的HttpSession。
如果要理解它的話,最簡單的方式就是檢視Spring Session實際所使用的原始碼。首先,我們瞭解一下標準servlet擴充套件點的一些背景知識,在實現Spring Session的時候會使用這些知識。
在2001年,Servlet 2.3規範引入了ServletRequestWrapper
。它的javadoc文件這樣寫道,ServletRequestWrapper
“提供了ServletRequest
介面的便利實現,開發人員如果希望將請求適配到Servlet的話,可以編寫它的子類。這個類實現了包裝(Wrapper)或者說是裝飾(Decorator)模式。對方法的呼叫預設會通過包裝的請求物件來執行”。如下的程式碼樣例抽取自Tomcat,展現了ServletRequestWrapper是如何實現的。
public class ServletRequestWrapper implements ServletRequest {
private ServletRequest request;
/**
* 建立ServletRequest介面卡,它包裝了給定的請求物件。
* @throws java.lang.IllegalArgumentException if the request is null
*/
public ServletRequestWrapper(ServletRequest request) {
if (request == null) {
throw new IllegalArgumentException("Request cannot be null");
}
this.request = request;
}
public ServletRequest getRequest() {
return this.request;
}
public Object getAttribute(String name) {
return this.request.getAttribute(name);
}
// 為了保證可讀性,其他的方法刪減掉了
}
Servlet 2.3規範還定義了HttpServletRequestWrapper
,它是ServletRequestWrapper
的子類,能夠快速提供HttpServletRequest
的自定義實現,如下的程式碼是從Tomcat抽取出來的,展現了HttpServletRequesWrapper
類是如何執行的。
public class HttpServletRequestWrapper extends ServletRequestWrapper
implements HttpServletRequest {
public HttpServletRequestWrapper(HttpServletRequest request) {
super(request);
}
private HttpServletRequest _getHttpServletRequest() {
return (HttpServletRequest) super.getRequest();
}
public HttpSession getSession(boolean create) {
return this._getHttpServletRequest().getSession(create);
}
public HttpSession getSession() {
return this._getHttpServletRequest().getSession();
}
// 為了保證可讀性,其他的方法刪減掉了
}
所以,藉助這些包裝類就能編寫程式碼來擴充套件HttpServletRequest
,過載返回HttpSession
的方法,讓它返回由外部儲存所提供的實現。如下的程式碼是從Spring Session專案中提取出來的,但是我將原來的註釋替換為我自己的註釋,用來在本文中解釋程式碼,所以在閱讀下面的程式碼片段時,請留意註釋。
/*
* 注意,Spring Session專案定義了擴充套件自
* 標準HttpServletRequestWrapper的類,用來過載
* HttpServletRequest中與session相關的方法。
*/
private final class SessionRepositoryRequestWrapper
extends HttpServletRequestWrapper {
private HttpSessionWrapper currentSession;
private Boolean requestedSessionIdValid;
private boolean requestedSessionInvalidated;
private final HttpServletResponse response;
private final ServletContext servletContext;
/*
* 注意,這個構造器非常簡單,它接受稍後會用到的引數,
* 並且委託給它所擴充套件的HttpServletRequestWrapper
*/
private SessionRepositoryRequestWrapper(
HttpServletRequest request,
HttpServletResponse response,
ServletContext servletContext) {
super(request);
this.response = response;
this.servletContext = servletContext;
}
/*
* 在這裡,Spring Session專案不再將呼叫委託給
* 應用伺服器,而是實現自己的邏輯,
* 返回由外部資料儲存作為支撐的HttpSession例項。
*
* 基本的實現是,先檢查是不是已經有session了。如果有的話,
* 就將其返回,否則的話,它會檢查當前的請求中是否有session id。
* 如果有的話,將會根據這個session id,從它的SessionRepository中載入session。
* 如果session repository中沒有session,或者在當前請求中,
* 沒有當前session id與請求關聯的話,
* 那麼它會建立一個新的session,並將其持久化到session repository中。
*/
@Override
public HttpSession getSession(boolean create) {
if(currentSession != null) {
return currentSession;
}
String requestedSessionId = getRequestedSessionId();
if(requestedSessionId != null) {
S session = sessionRepository.getSession(requestedSessionId);
if(session != null) {
this.requestedSessionIdValid = true;
currentSession = new HttpSessionWrapper(session, getServletContext());
currentSession.setNew(false);
return currentSession;
}
}
if(!create) {
return null;
}
S session = sessionRepository.createSession();
currentSession = new HttpSessionWrapper(session, getServletContext());
return currentSession;
}
@Override
public HttpSession getSession() {
return getSession(true);
}
}
Spring Session定義了SessionRepositoryFilter
,它實現了 Servlet Filter
介面。我抽取了這個filter的關鍵部分,將其列在下面的程式碼片段中,我還添加了一些註釋,用來在本文中闡述這些程式碼,所以,同樣的,請閱讀下面程式碼的註釋部分。
/*
* SessionRepositoryFilter只是一個標準的ServletFilter,
* 它的實現擴充套件了一個helper基類。
*/
public class SessionRepositoryFilter < S extends ExpiringSession >
extends OncePerRequestFilter {
/*
* 這個方法是魔力真正發揮作用的地方。這個方法建立了
* 我們上文所述的封裝請求物件和
* 一個封裝的響應物件,然後呼叫其餘的filter鏈。
* 這裡,關鍵在於當這個filter後面的應用程式碼執行時,
* 如果要獲得session的話,得到的將會是Spring Session的
* HttpServletSession例項,它是由後端的外部資料儲存作為支撐的。
*/
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
request.setAttribute(SESSION_REPOSITORY_ATTR, sessionRepository);
SessionRepositoryRequestWrapper wrappedRequest =
new SessionRepositoryRequestWrapper(request,response,servletContext);
SessionRepositoryResponseWrapper wrappedResponse =
new SessionRepositoryResponseWrapper(wrappedRequest, response);
HttpServletRequest strategyRequest =
httpSessionStrategy.wrapRequest(wrappedRequest, wrappedResponse);
HttpServletResponse strategyResponse =
httpSessionStrategy.wrapResponse(wrappedRequest, wrappedResponse);
try {
filterChain.doFilter(strategyRequest, strategyResponse);
} finally {
wrappedRequest.commitSession();
}
}
}
我們從這一章節得到的關鍵資訊是,Spring Session對HTTP的支援所依靠的是一個簡單老式的ServletFilter
,藉助servlet規範中標準的特性來實現Spring Session的功能。因此,我們能夠讓已有的war檔案使用Spring Session的功能,而無需修改已有的程式碼,當然如果你使用javax.servlet.http.HttpSessionListener
的話,就另當別論了。Spring
Session 1.0並不支援HttpSessionListener
,但是Spring Session 1.1 M1釋出版本已經添加了對它的支援,你可以通過該地址瞭解更多細節資訊。
配置Spring Session
在Web專案中配置Spring Session分為四步:
- 搭建用於Spring Session的資料儲存
- 將Spring Session的jar檔案新增到web應用中
- 將Spring Session filter新增到web應用的配置中
- 配置Spring Session如何選擇session資料儲存的連線
Spring Session自帶了對Redis的支援。搭建和安裝redis的細節可以參考該地址。
有兩種常見的方式能夠完成上述的Spring Session配置步驟。第一種方式是使用Spring Boot來自動配置Spring Session。第二種配置Spring Session的方式是手動完成上述的每一個配置步驟。
藉助像Maven或Gradle這樣的依賴管理器,將Spring Session新增應用中是很容易的。如果你使用Maven和Spring Boot的話,那麼可以在pom.xml中使用如下的依賴:
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session</artifactId>
<version>1.0.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-redis</artifactId>
</dependency>
其中,spring-boot-starter-redis
依賴能夠確保使用redis所需的所有jar都會包含在應用中,所以它們可以藉助Spring Boot進行自動裝配。spring-session依賴將會引入 Spring Session
的jar。
至於Spring Session Servlet filter的配置,可以通過Spring Boot的自動配置來實現,這隻需要在Spring Boot的配置類上使用 @EnableRedisHttpSession
註解就可以了,如下面的程式碼片段所示。
@SpringBootApplication
@EnableRedisHttpSession
public class ExampleApplication {
public static void main(String[] args) {
SpringApplication.run(ExampleApplication.class, args);
}
}
至於Spring Session到Redis連線的配置,可以新增如下配置到Spring Boot的application.properties檔案中:
spring.redis.host=localhost
spring.redis.password=secret
spring.redis.port=6379
Spring Boot提供了大量的基礎設施用來配置到Redis的連線,定義到Redis資料庫連線的各種方式都可以用在這裡。你可以參考該地址的逐步操作指南,來了解如何使用Spring Session和Spring Boot。
在傳統的web應用中,可以參考該指南來了解如何通過web.xml來使用Spring Session。
在傳統的war檔案中,可以參考該指南來了解如何不使用web.xml進行配置。
預設情況下,Spring Session會使用HTTP cookie來儲存session id,但是我們也可以配置Spring Session使用自定義的HTTP header資訊,如x-auth-token: 0dc1f6e1-c7f1-41ac-8ce2-32b6b3e57aa3
,當構建REST API的時候,這種方式是很有用的。完整的指南可以參考該地址。
使用Spring Session
Spring Session配置完成之後,我們就可以使用標準的Servlet API與之互動了。例如,如下的程式碼定義了一個servlet,它使用標準的Servlet session API來訪問session。
@WebServlet("/example")
public class Example extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// 使用正常的servlet API獲取session,在底層,
// session是通過Spring Session得到的,並且會儲存到Redis或
// 其他你所選擇的資料來源中
HttpSession session = request.getSession();
String value = session.getAttribute(???someAttribute???);
}
}
每個瀏覽器多個Session
Spring Session會為每個使用者保留多個session,這是通過使用名為“_s
”的session別名引數實現的。例如,如果到達的請求為http://example.com/doSomething?_s=0 ,那麼Spring Session將會讀取“_s”引數的值,並通過它確定這個請求所使用的是預設session。
如果到達的請求是http://example.com/doSomething?_s=1
的話,那麼Spring Session就能知道這個請求所要使用的session別名為1.如果請求沒有指定“_s
”引數的話,例如http://example.com/doSomething,那麼Spring Session將其視為使用預設的session,也就是說_s=0
。
要為某個瀏覽器建立新的session,只需要呼叫javax.servlet.http.HttpServletRequest.getSession()
就可以了,就像我們通常所做的那樣,Spring Session將會返回正確的session或者按照標準Servlet規範的語義建立一個新的session。下面的表格描述了針對同一個瀏覽器視窗,getSession()
面對不同url時的行為。
HTTP請求URL |
Session別名 |
getSession()的行為 |
example.com/resource |
0 |
如果存在session與別名0關聯的話,就返回該session,否則的話建立一個新的session並將其與別名0關聯。 |
example.com/resource?_s=1 |
1 |
如果存在session與別名1關聯的話,就返回該session,否則的話建立一個新的session並將其與別名1關聯。 |
example.com/resource?_s=0 |
0 |
如果存在session與別名0關聯的話,就返回該session,否則的話建立一個新的session並將其與別名0關聯。 |
example.com/resource?_s=abc |
abc |
如果存在session與別名abc關聯的話,就返回該session,否則的話建立一個新的session並將其與別名abc關聯。 |
如上面的表格所示,session別名不一定必須是整型,它只需要區別於其他分配給使用者的session別名就可以了。但是,整型的session別名可能是最易於使用的,Spring Session提供了HttpSessionManager
介面,這個介面包含了一些使用session別名的工具方法。
我們可以在HttpServletRequest
中,通過名為“org.springframework.session.web.http.HttpSessionManager”
的屬性獲取當前的HttpSessionManager
。如下的樣例程式碼闡述瞭如何得到HttpSessionManager,並且在樣例註釋中描述了其關鍵方法的行為。
@WebServlet("/example")
public class Example extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request,HttpServletResponse response)
throws ServletException, IOException {
/*
* 在請求中,根據名為org.springframework.session.web.http.HttpSessionManager的key
* 獲得Spring Session session管理器的引用
*/
HttpSessionManager sessionManager=(HttpSessionManager)request.getAttribute(
"org.springframework.session.web.http.HttpSessionManager");
/*
* 使用session管理器找出所請求session的別名。
* 預設情況下,session別名會包含在url中,並且請求引數的名稱為“_s”。
* 例如,http://localhost:8080/example?_s=1
* 將會使如下的程式碼打印出“Requested Session Alias is: 1”
*/
String requestedSessionAlias=sessionManager.getCurrentSessionAlias(request);
System.out.println("Requested Session Alias is: " + requestedSessionAlias);
/* 返回一個唯一的session別名id,這個別名目前沒有被瀏覽器用來發送請求。
* 這個方法並不會建立新的session,
* 我們需要呼叫request.getSession()來建立新session。
*/
String newSessionAlias = sessionManager.getNewSessionAlias(request);
/* 使用新建立的session別名來建立URL,這個URL將會包含
* “_s”引數。例如,如果newSessionAlias的值為2的話,
* 那麼如下的方法將會返回“/inbox?_s=2”
*/
String encodedURL = sessionManager.encodeURL("/inbox", newSessionAlias);
System.out.println(encodedURL);
/* 返回session別名與session id所組成的Map,
* 它們是由瀏覽器傳送請求所形成的。
*/
Map < String, String > sessionIds = sessionManager.getSessionIds(request);
}
}
結論
Spring Session為企業級Java的session管理帶來了革新,使得如下的任務變得更加容易:
- 編寫可水平擴充套件的原生雲應用。
- 將session所儲存的狀態解除安裝到特定的外部session儲存中,如Redis或Apache Geode中,它們能夠以獨立於應用伺服器的方式提供高質量的叢集。
- 當用戶使用WebSocket傳送請求的時候,能夠保持HttpSession處於活躍狀態。
- 在非Web請求的處理程式碼中,能夠訪問session資料,比如在JMS訊息的處理程式碼中。
- 支援每個瀏覽器上使用多個session,這樣就可以很容易地構建更加豐富的終端使用者體驗。
- 控制客戶端和伺服器端之間如何進行session id的交換,這樣更加易於編寫Restful API,因為它可以從HTTP 頭資訊中獲取session id,而不必再依賴於cookie。
如果你想拋棄傳統的重量級應用伺服器,但受制於已經使用了這些應用伺服器的session叢集特性,那麼Spring Session將是幫助你邁向更加輕量級容器的重要一步,這些輕量級的容器包括Tomcat、Jetty或Undertow。