此文知識來自于:《深入分析Java_Web技術》第十章
現(xiàn)代session與cookie的應用
本章概要:
當我們的一個應用系統(tǒng)有幾百臺服務器時,如何解決Session在多臺服務器之間共享的問題?它們還有一些安全問題,如Cookie被盜、Cookie偽造等問題應如何避免?Session與Cookie的作用都是為了保持訪問用戶與后端服務器的交互狀態(tài)。例如,使用Cookie來傳遞信息時,隨著Cookie個數(shù)的增多和訪問量的增加,它占用的網(wǎng)絡帶寬也很大,試想假如Cookie占用200個字節(jié),如果一天的PV有幾億,那么它要占用多少帶寬?所以有大訪問量時希望用Session,但是Session的致命弱點是不容易在多臺服務器之間共享,這也限制了Session的使用。
1. 理解Cookie
Cookie的作用通俗地說就是當一個用戶通過HTTP訪問一個服務器時,這個服務器會將一些Key/Value鍵值對返回給客戶端瀏覽器,并給這些數(shù)據(jù)加上一些限制條件,在條件符合時這個用戶下次訪問這個服務器時,數(shù)據(jù)又被完整地帶回給服務器。
當初W3C設計Cookie時實際考慮的是為了記錄用戶在一段時間內(nèi)訪問Web應用的行為路徑。由于HTTP是一種無狀態(tài)協(xié)議,當用戶的一次訪問請求結束后,后端服務器就無法知道下一次來訪問的還是不是上次訪問的用戶。例如,在一個很短的時間內(nèi),如果與用戶相關的數(shù)據(jù)被頻繁訪問,可以針對這個數(shù)據(jù)做緩存,這樣可以大大提高數(shù)據(jù)的訪問性能。Cookie的作用正是如此,由于是同一個客戶端發(fā)出的請求,每次發(fā)出的請求都會帶有第一次訪問時,服務端設置的信息,這樣服務端就可以根據(jù)Cookie值來劃分訪問的用戶了。
1.1 Cookie屬性項
當前Cookie有兩個版本:Version0和Version1,它們有兩種設置響應頭的標識,分別是“Set-Cookie”和“Set-Cookie2”。它們屬性項有些不同。
Version0屬性項:
屬性項 | 屬性項介紹 |
---|---|
NAME=VALUE | 設置要保存的Key/Value,注意這里的NAME不能和其它屬性項的名字一樣 |
Expires | 過期時間 |
Domain | 生成該Cookie的域名 |
Path | 該Cookie是在當前哪個路徑下生成的 |
Secure | 如果設置了這個屬性,那么只會在SSH連接時才會回傳該Cookie |
Expires | 過期時間 |
在Java Web的Servlet規(guī)范并不支持Set-Cookie2響應頭,在實際應用中Set-Cookie2的一些屬性項卻可以設置在Set-Cookie中。博主在查看Cookie源碼,發(fā)現(xiàn)是支持的:

另外也可以從源碼可以得知,一般所說的Cookie鍵值對,都是值NAME和VALUE屬性,其實Cookie還有其他的屬性,通過Get/Set方法進行獲取和設置。
另外下面是博主在使用Chrome瀏覽器查看的Cookie:

1.2 Cookie如何工作
當我們用如下方式創(chuàng)建Cookie時:
String getCookie(Cookie[] cookies, String key) {
if (cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals(key)) {
return cookie.getValue();
}
}
}
return null;
}
@Override
public void doGet(HttpServletRequest req, HttpServletResponse res) {
Cookie[] cookies = req.getCookies();
String userName = getCookie(cookies, "userName");
String userAge = getCookie(cookies, "userAge");
if (userName == null) {
res.addCookie(new Cookie("userName", "liwenguang"));
}
if (userAge == null) {
res.addCookie(new Cookie("userAge", "22"));
}
res.getHeaders("Set-Cookie");
}
以下幾點需要注意:
- 所創(chuàng)建Cookie的NAME不能和Set-Cookie或者Set-Cookie2的屬性項值一樣。
- 所創(chuàng)建Cookie的NAME和VALUE的值不能設置成非ASCII字符,如果要使用中文,可以通過URLEncoder將其編碼。
- 當NAME和VALUE的值出現(xiàn)一些TOKEN字符(如“\”、“,”等)時,構建返回頭會將該Cookie的Version自動設置為1。
- 當在該Cookie的屬性項中出現(xiàn)Version為1的屬性項時,構建HTTP響應頭同樣會將Version設置為1。
1.3 使用Cookie的限制
任何語言對Cookie的操作,其實都是讓瀏覽器對Cookie的操作,Cookie的瀏覽器的特性,而瀏覽器對Cookie有數(shù)量限制(50個/每個域名),總大小限制(4096,Chrome沒有這個限制)。
2 理解Session
前面已經(jīng)介紹了Cookie可以讓服務端程序跟蹤每個客戶端的訪問,但是每次客戶端的訪問都必須傳回這些Cookie,如果Cookie很多,則無形地增加了客戶端
與服務端的數(shù)據(jù)傳輸量,而Session的出現(xiàn)正是為了解決這個問題。
同一個客戶端每次和服務端交互時,不需要每次都傳回所有的Cookie值,而是只要傳回一個ID,這個ID是客戶端第一次訪問服務端時生成的,而且每個客戶端是唯一的。
這樣每個客戶端就有了一個唯一的ID,客戶端只要傳回這個ID就行了,這個ID通常是NAME為JSESIONID的一個Cookie。
2.1 Session與Cookie
下面詳解講一下Session是如何基于Cookie來工作的。實際上有以下三種方式可以讓Session正常工作。
- 基于URL Path Parameter,默認支持。
- 基于Cookie,如果沒有修改Context容器的Cookies標識,則默認也是支持的。
- 基于SSL,默認不支持,只有connector.getAttribute("SSLEnabled")為TRUE時才支持。
在第一種情況,當瀏覽器不支持Cookie功能時,瀏覽器會將用戶的SessionCookieName重寫到用戶請求的URL參數(shù)中,傳遞格式如/path/Servlet;name=value;name2=value2?Name3=value3,其中“Servlet;”后面的K-V就是要傳遞的Path Parameters,服務器會從這個Path Parameters中拿到用戶配置的SessionCookieName。關于這個SessionCookieName,如果在web.xml中配置session-config配置項,其cookie-config下的name屬性就是這個SessionCookieName的值。如果沒有配置sessio-config配置項,默認的SessionCookieName就是大家熟悉的“JSESSIONID”。需要說明的一點是,與Session關聯(lián)的Cookie與其他Cookie沒有什么不同。接著Request根據(jù)這個SessionCookieName到Parameters中拿到Session ID并設置到request.setRequestedSessionId中。
請注意,如果客戶端也支持Cookie,則Tomcat仍然會解析Cookie中的Session ID,并會覆蓋URL中的Session ID。
如果是第三種情況,則會根據(jù)javax.servlet.request.ssl_session屬性值設置Session ID。
2.2 Session如何工作
有了Session ID,服務端就可以創(chuàng)建HttpSession對象了,第一次觸發(fā)通過request.getSession()
方法。如果當前的Session ID還沒有對應的HttpSession對象,那么就創(chuàng)建一個新的,并將這個對象加到org.apache.catalina.Manager的session容器中保存。Manager類將管理所有Session生命周期,Session過期將被回收,服務器關閉,Sessoin將被序列化到磁盤等。只要這個HttpSession對象存在,用戶就可以根據(jù)Session ID來獲取這個對象,也就做到了對狀態(tài)的保持。

從Request中獲取的Session對象保存在org.apache.catalina.Manager類中,它的實現(xiàn)類是org.apache.catalina.session.StandardManager,通過requestedSessionId從StandardManager的Sessions集合取出對應的StandardSession對象。由于一個requestedSessionId對應一個訪問的客戶端,所以一個客戶端也就對應了一個StandardSession對象,這個對象正是保存我們創(chuàng)建的Session值的。下面我們看一下StandardManager這個類是如何管理StandardSession的生命周期的。
StandardManager類負責Servlet容器中所有的StandardSession對象的生命周期管理。當Servlet容器重啟或關閉時,StandardManager負責持久化沒有過期的StandardSession對象,它會將所有的StandardSession對象持久化到一個以“SESSIONS。ser”為文件名的文件中。到Servlet容器重啟時,也就是StandardManager初始化時,它會重新讀取這個文件,解析出所有Session對象,重新保存在StandardManager的sessions集合中。
當Servlet容器關閉時StandardManager類會調用unload方法將session集合中的StandardSession對象寫到“SESSIONS.ser”文件中,然后在啟動時再重新恢復,注意要持久化保存Servlet容器中的Session對象,必須調用Servlet容器的stop的start命令,而不能直接結束(kill)Servlet容器的進程。
因為直接結束進程,Servlet容器沒有機會調用unload方法來持久化這些Session對象。
另外,在StandardManager的sessions集合中的StandardSession對象并不是永遠保存的,否則Servlet容器的內(nèi)存將容易被消耗盡,所以必須給每個Session對象定義一個有效時間,超過這個時間則Session對象將被清除。在Tomcat中這個有效時間是60s(maxInactiveInterval屬性通知),超過60s該Session將會過期。檢查每個Session是否失效是Tomcat的一個后臺線程中完成的。
除了后臺進程檢查Session是否失效外,當調用request.getSession()
時也會檢查該Session是否過期。值得注意的是,request.getSession()
方法調用的StandardSession永遠都會存在,即使與這個客戶端關聯(lián)的Session對象已經(jīng)過期。如果過期,則又會重新創(chuàng)建一個全新的StandardSession對象,但是以前設置的Session值將會丟失。如果你取到了Session對象,但是通過session.getAttribute
取不到前面設置的Session值,請不要奇怪,因為很可能已經(jīng)失效了,請檢查以下<Manager pathname="" maxInactiveInterval="60" />中maxInactiveInterval
配置項的值,如果不想讓Session過期則可以設置為-1。但是你要仔細評估一下,網(wǎng)站的訪問量和設置的Session的大小,防止將你的Servlet容器內(nèi)存撐爆。如果不想自動創(chuàng)建Session對象,也可以通過request.getSession(bolean create)
方法來判斷與該客戶端關聯(lián)的Session對象是否存在。
3 Cookie安全問題
Cookie通過把所有要保存的數(shù)據(jù)通過HTTP的頭部從客戶端傳遞到服務端,又從服務端傳回到客戶端,所有的數(shù)據(jù)都存儲在客戶端的瀏覽器里,所以這些Cookie數(shù)據(jù)可以被訪問到,通過瀏覽器插件可以對Cookie進行修改等。
相對而言Session的安全性要高很多,因為Session是將數(shù)據(jù)保存在服務端,只是通過Cookie傳遞一個SessionID而已,所以Session更適合存儲用戶隱私和重要的數(shù)據(jù)。
4 分布式Session框架
4.1 Cookie存在哪些問題
- 客戶端Cookie存儲限制
- Cookie管理的混亂,每個應用系統(tǒng)都自己管理每個應用使用的Cookie會導致混亂,由于通常應用系統(tǒng)都在同一個域名下,Cookie又有上面一條提到的限制,所以統(tǒng)一管理很容易出現(xiàn)Cookie超出限制的情況。
- 不安全,雖然通過設置HttpOnly屬性防止一些私密Cookie被客戶端訪問,但是仍然不能保證Cookie無法被篡改。為了保證Cookie的私密性通常會對Cookie進行加密,但是維護這個加密Key也是一件麻煩的事情,無法保證定期更新加密Key也是會帶來安全性問題的一個重要因素。
4.2 Cookie+Session可以解決哪些問題
下面是分布式Session框架可以解決的問題:
- Session配置的統(tǒng)一管理。
- Cookie使用的監(jiān)控和統(tǒng)一規(guī)范管理。
- Session存儲的多元化。
- Session配置的動態(tài)修改。
- Session加密key的定期修改。
- 充分的容災機制,保持框架的使用穩(wěn)定性。
- Session各種存儲的監(jiān)控和報警支持。
- Session框架的可擴展性,兼容更多的Session機制如wapSession。
- 跨域名Session與Cookie如何共享的問題?,F(xiàn)在同一個網(wǎng)站可能存在多個域名,如何將Session和Cookie在不同的域名之間共享是一個具有挑戰(zhàn)性的問題。
4.3 總體實現(xiàn)思路
為了達成上面所說的幾個目標,我們需要一個服務訂閱服務器,在應用啟動時可以從這個訂閱服務器訂閱這個應用需要的可寫Session項和可寫Cookie項,這些配置的Session和Cookie可以限制這個應用能夠使用哪些Session和Cookie,甚至可以通知Session和Cookie可讀或可寫。這樣可以精確地控制哪些應用可以操作哪些Session和Cookie,可以有效控制Session的安全性和Cookie的數(shù)量。

如Session的配置項可以為如下形式:
<sessions>
<session>
<key>sessionID</key>
<cookiekey>sessionID</cookiekey>
<lifeCycle>9000</lifeCycle>
<base64>true</base64>
</session>
.......
</sessions>
Cookie的配置可以為如下形式:
<cookies>
<cookie>
<key>cookie</key>
<lifeCycle>10000</lifeCycle>
<type>1</type>
<path>/wp</path>
<domain>liwenguang.website</domain>
<decrypt>false</decrypt>
<httpOnly>false</httpOnly>
</cookie>
......
</cookies>
統(tǒng)一通過訂閱服務器推送配置可以有效地幾種管理資源,所以可以省去每個應用都來配置Cookie,簡化Cookie的管理。如果應用要使用一個新增的Cookie,則可以通過一個統(tǒng)一的平臺來申請,申請通過才將這個配置項增加到訂閱服務器。如果是一個所有應用都要使用的全局Cookie,那么只需要將這個Cookie通過訂閱服務器統(tǒng)一推送過去就行了,省去了要在每個應用中手動增加Cookie的配置。
關于這個訂閱服務器現(xiàn)在有很多開源的配置服務器,如ZooKeeper集群管理服務器,可以統(tǒng)一管理所有服務器的配置文件。
由于應用是一個集群,所以不可能將創(chuàng)建的Session都保存在每臺應用服務器的內(nèi)存中,因為如果每臺服務器有幾十萬的訪問用戶,那么服務器的內(nèi)存可能不夠用,即使內(nèi)存夠用,這些Session也無法同步到這個應用的所有服務器中。所以要共享這些Session必須將它們存儲在一個分布式緩存中,可以隨時寫入和讀取,而且性能要很好才能滿足要求。當前能滿足這個要求的系統(tǒng)有很多,如MemCache或者淘寶的開源分布式緩存系統(tǒng)Tair都是很好的選擇。
解決了配置和存儲問題,下面看一下如何存取Session和Cookie。
既然是一個分布式Session的處理框架,必然會重新實現(xiàn)HttpSession的操作接口,使得應用操作Session的對象都是我們實現(xiàn)的InnerHttpSession對象,這個操作必須在進入應用之前完成,所以可以配置一個filter攔截用戶的請求。
先看一下如何封裝HttpSession對象和攔截請求,如下時序圖:

我們可以在應用的web.xml中配置一個SessionFilter,用于在請求到達MVC框架之前封裝HttpServletRequest和HttpServletResponse對象,并創(chuàng)建我們自己的InnerHttpSession對象,把它設置到request和response對象中。這樣應用系統(tǒng)通過request.getHttpSession()返回的就是我們創(chuàng)建的InnerHttpSession對象了,我們可以攔截response的addCookies設置的Cookie。
在時序圖中,應用創(chuàng)建的所有Session對象都會保存在InnerHttpSession對象中,當用戶的這次訪問請求完成時,Session框架將會把這個InnerHttpSession的所有內(nèi)容再更新到分布式緩存中,以便于這個用戶通過其它服務器再次訪問這個應用系統(tǒng)。另外,為了保證一些應用對Session穩(wěn)定性的特殊要求,可以將一些非常關鍵的Session再存儲到Cookie中,如當分布式緩存存在問題時,可以將部分Session存儲到Cookie中,這樣即使分布式緩存出現(xiàn)問題也不會影響關鍵業(yè)務的正常運行。
4.4 增加Session跨域實現(xiàn)
還有一個非常重要的問題就是如何處理跨域名來共享Cookie的問題。我們知道Cookie是有域名限制的,也就是在一個域名下的Cookie不能被另一個域名訪問,所以如果在一個域名下已經(jīng)登錄成功,如何訪問到另外一個域名的應用且保證登錄狀態(tài)仍然有效,對這個問題大型網(wǎng)站應該經(jīng)常會遇到。如何解決這個問題呢?
下面介紹一種處理方式,流程圖如下:

訪問域名A時服務器A獲得了session,用戶訪問域名B時,如果發(fā)現(xiàn)服務器B沒有session,則302重定向跳轉到中轉服務器C(C你可以理解成專門取Session的域),服務器C獲得了session后,則再進行302重定向到A服務器,寫入session,從而完成了session跨域。(如今,大部分都是實現(xiàn)的單點登錄SSO,來解決子系統(tǒng)間的跨域問題,讓子系統(tǒng)共享頂級域名的Session、Cookie等)。
5 Cookie壓縮
Cookie在HTTP的頭部,所以通常的gzip和deflate針對HTTP Body的壓縮不能壓縮Cookie,如果Cookie的量非常大,則可以考慮將Cookie也做壓縮,壓縮方式是將Cookie的多個k/v對看成普通的文本,做文本壓縮。壓縮算法同樣可以使用gzip和deflate算法,但是需要注意的一點是,根據(jù)Cookie的規(guī)范,在Cookie中不能包含控制字符,僅能包含ASCII碼為34~126的可見字符。所以要將壓縮后的結果再進行轉碼,可以進行Base32或者Base64編碼。
// 使用DeflaterOutputStream壓縮后再用BASE64編碼
Cookie c = getCookieObject("");
HttpServletResponse res = getResponse();
ByteArrayOutputStream bos = new ByteArrayOutputStream();
DeflaterOutputStream dos = new DeflaterOutputStream(bos);
try {
dos.write(c.getValue().getBytes());
dos.close();
System.out.println("before compress length:" + c.getValue().length());
String compress = new sun.misc.BASE64Encoder().encode(bos.toByteArray());
res.addCookie(new Cookie("compress", compress));
System.out.println("after compress length:" + compress.getBytes().length);
} catch (IOException e) {
e.printStackTrace();
}
// 使用BASE64解碼后再用InflaterInputStream解壓
ByteArrayOutputStream out = new ByteArrayOutputStream();
try {
byte[] compress = new sun.misc.BASE64Decoder().decodeBuffer(new String(c.getValue().getBytes()));
ByteArrayInputStream bis = new ByteArrayInputStream(compress);
InflaterInputStream inflater = new InflaterInputStream(bis);
byte[] b = new byte[1024];
int count;
while ((count = inflater.read(b)) >= 0) {
out.write(b, 0, count);
}
inflater.close();
System.out.println(out.toByteArray());
} catch (IOException e) {
e.printStackTrace();
}
6 表單重復提交問題
要防止表單重復提交,就要標識用戶的每一次訪問請求,使得每一次訪問對服務端來說都是唯一確定的。為了標識用戶的每次訪問請求,可以在用戶請求一個表單域時增加一個隱藏表單項,這個表單項每次都是唯一的token,如:

當用戶第一次請求表單頁面時生成唯一的token,并存儲到用戶Session中,當用戶第二次請求表單頁面時再生成唯一的token,覆蓋Session,這樣就能保證每次都只能通過請求表單頁面來提交表單。
7 多終端Session統(tǒng)一
當前大部分網(wǎng)站都有了無線端,對無線端的Cookie如何處理也是很多程序員必須考慮的問題。
在無線端發(fā)展初期,后端的服務系統(tǒng)未必和PC的服務系統(tǒng)是統(tǒng)一的,這樣就涉及在一端調用多個系統(tǒng)時如何做到服務端Session共享的問題了。有兩個明顯的例子:
一個是在無線端可能會通過手機訪問無線服務端系統(tǒng),如果它們兩個的登錄系統(tǒng)沒有統(tǒng)一的話,將會非常麻煩,可能會出現(xiàn)二次登錄的情況;
另一個是在手機上登錄以后再在PC上同樣訪問服務端數(shù)據(jù),Session能否共享就決定了客戶端是否要再次登錄。
針對這兩種情況,目前都有理由的解決方案。
- 多端共享Session
多端共享Session必須要做的工作是不管是無線端還是PC端,后端的服務系統(tǒng)必須統(tǒng)一會話架構,也就是兩邊的登錄系統(tǒng)必須要基于一致的會員數(shù)據(jù)結構、Cookie與Session的統(tǒng)一。也就是不管是PC端登錄還是無線端登錄,后面對應的數(shù)據(jù)結構和存儲要統(tǒng)一,寫到客戶端的Cookie也必須一樣,這是前提條件。
那么如何做到這一點?就是要按照我們在前面所說的實現(xiàn)分布式的Session框架。

上面服務端統(tǒng)一Session后,在同一個終端上不管是訪問哪個服務端都能做到登錄狀態(tài)統(tǒng)一。例如不管是Native還是內(nèi)嵌Webview,都可以拿統(tǒng)一的Session ID去服務端驗證登錄狀態(tài)。
- 多終端登錄
目前很多網(wǎng)站都會出現(xiàn)無線端和PC端多端登錄的情況,例如可以通過掃碼登錄等。這些是如何實現(xiàn)的呢?
這里手機端在掃碼之前必須是已經(jīng)登錄的狀態(tài),因為這樣才能獲取到到底是誰將要登錄的信息,同時掃碼的二維碼也帶有一個特定的標識,標識是這個客戶端
通過手機端登陸了。當手機端掃碼成功后,會在服務端設置這個二維碼對應的標識為已經(jīng)登錄成功,這時PC客戶端會通過將“心跳”請求發(fā)送到服務端,來驗證是否已經(jīng)登錄成功,這樣就稱為一種便捷的登錄方式。(博主以微信掃碼登錄為例,每次微信PC端的二維碼都是帶有一個唯一的標識,當你用登錄的手機微信掃碼之后,手機將你已登錄的微信信息獲取,并發(fā)送給微信服務端,微信服務端將二維碼的唯一標識以及手機微信的賬號信息綁定,發(fā)送給微信PC端,其中,微信PC二維碼客戶端類似Watch了某個ZK的節(jié)點進行監(jiān)聽,這樣避免客戶端每隔一段時間發(fā)送心跳)。
8 總結
Cookie和Session都是為了保持用戶訪問的連續(xù)狀態(tài),之所以要保持這種狀態(tài),一方面是為了方便業(yè)務實現(xiàn),另一方面就是簡化服務端的程序設計,提高訪問性能,但是也帶來了安全問題、應用的分布式部署帶來的Session的同步問題以及跨域名Session的同步問題(通過單點登錄避免)。