Web開發中的session
在web開發中,session是個非常重要的概念。在許多動態網站的開發人員看來,session就是一個變數,而且其表現像個黑洞,他只需要將東西在合適的時機放進這個洞裡,等需要的時候再把東西取出來。這是開發人員對session最直觀的感受,但是黑洞裡的景象或者說session內部到底是怎麼工作的呢?當筆者向身邊的一些同事或朋友問及相關的更進一步的細節時,很多人往往要麼含糊其辭要麼主觀臆斷,所謂知其然而不知其所以然。
筆者由此想到很多開發人員,包括我自己,每每都是糾纏於架構甚至二次開發平台之上,而對於其下的核心和基礎知之甚少,或者有心無力甚至毫不關心,少了逐本溯源的精神,每憶及此,無不慚愧。曾經實現過一個簡單的HttpServer,但當時由於知識儲備和時間的問題,沒有考慮到session這塊,不過近期在工作之餘翻看了一些資料,並進行了相關實踐,小有所得,本著分享的精神,我將在本文中儘可能全面地將個人對於session的理解展現給讀者,同時盡我所能地論及一些相關的知識,以期讀者在對session有所瞭解的同時也能另有所悟,正所謂授人以漁。
Session是什麼
Session一般譯作會話,牛津詞典對其的解釋是進行某活動連續的一段時間。從不同的層面看待session,它有著類似但不全然相同的含義。比如,在web應用的使用者看來,他開啟瀏覽器訪問一個電子商務網站,登入、並完成購物直到關閉瀏覽器,這是一個會話。而在web應用的開發人員開來,使用者登入時我需要建立一個資料結構以儲存使用者的登入資訊,這個結構也叫做session。因此在談論session的時候要注意上下文環境。而本文談論的是一種基於HTTP協議的用以增強web應用能力的機制或者說一種方案,它不是單指某種特定的動態網頁面技術,而這種能力就是保持狀態,也可以稱作保持會話。
為什麼需要session
談及session一般是在web應用的背景之下,我們知道web應用是基於HTTP協議的,而HTTP協議恰恰是一種無狀態協議。也就是說,使用者從A頁面跳轉到B頁面會重新發送一次HTTP請求,而服務端在返迴響應的時候是無法獲知該使用者在請求B頁面之前做了什麼的。
對於HTTP的無狀態性的原因,相關RFC裡並沒有解釋,但聯絡到HTTP的曆史以及應用情境,我們可以推測出一些理由:
1. 設計HTTP最初的目的是為了提供一種發布和接收HTML頁面的方法。那個時候沒有動態網頁面技術,只有純粹的靜態HTML頁面,因此根本不需要協議能保持狀態;
2. 使用者在收到響應時,往往要花一些時間來閱讀頁面,因此如果保持用戶端和服務端之間的串連,那麼這個串連在大多數的時間裡都將是閒置,這是一種資源的無端浪費。所以HTTP原始的設計是預設短串連,即用戶端和服務端完成一次請求和響應之後就斷開TCP串連,伺服器因此無法預知用戶端的下一個動作,它甚至都不知道這個使用者會不會再次訪問,因此讓HTTP協議來維護使用者的訪問狀態也全然沒有必要;
3. 將一部分複雜性轉嫁到以HTTP協議為基礎的技術之上可以使得HTTP在協議這個層面上顯得相對簡單,而這種簡單也賦予了HTTP更強的擴充能力。事實上,session技術從本質上來講也是對HTTP協議的一種擴充。
總而言之,HTTP的無狀態是由其曆史使命而決定的。但隨著網路技術的蓬勃發展,人們再也不滿足於死板乏味的靜態HTML,他們希望web應用能動起來,於是用戶端出現了指令碼和DOM技術,HTML裡增加了表單,而服務端出現了CGI等等動態技術。
而正是這種web動態化的需求,給HTTP協議提出了一個難題:一個無狀態的協議怎樣才能關聯兩次連續的請求呢?也就是說無狀態的協議怎樣才能滿足有狀態的需求呢?
此時有狀態是必然趨勢而協議的無狀態性也是木已成舟,因此我們需要一些方案來解決這個矛盾,來保持HTTP串連狀態,於是出現了cookie和session。
對於此部分內容,讀者或許會有一些疑問,筆者在此先談兩點:
1. 無狀態性和長串連
可能有人會問,現在被廣泛使用的HTTP1.1預設使用長串連,它還是無狀態的嗎?
串連方式和有無狀態是完全沒有關係的兩回事。因為狀態從某種意義上來講就是資料,而串連方式只是決定了資料的傳輸方式,而不能決定資料。長串連是隨著電腦效能的提高和網路環境的改善所採取的一種合理的效能上的最佳化,一般情況下,web伺服器會對長串連的數量進行限制,以免資源的過度消耗。
2. 無狀態性和session
Session是有狀態的,而HTTP協議是無狀態的,二者是否矛盾呢?
Session和HTTP協議屬於不同層面的事物,後者屬於ISO七層模型的最高層應用程式層,前者不屬於後者,前者是具體的動態網頁面技術來實現的,但同時它又是基於後者的。在下文中筆者會分析Servlet/Jsp技術中的session機制,這會使你對此有更深刻的理解。
Cookie和Session
上面提到解決HTTP協議自身無狀態的方式有cookie和session。二者都能選項組,前者是將狀態資料儲存在用戶端,後者則儲存在服務端。
首先看一下cookie的工作原理,這需要有基本的HTTP協議基礎。
cookie是在RFC2109(已廢棄,被RFC2965取代)裡初次被描述的,每個用戶端最多保持三百個cookie,每個網域名稱下最多20個Cookie(實際上一般瀏覽器現在都比這個多,如Firefox是50個),而每個cookie的大小為最多4K,不過不同的瀏覽器都有各自的實現。對於cookie的使用,最重要的就是要控制cookie的大小,不要放入無用的資訊,也不要放入過多資訊。
無論使用何種服務端技術,只要發送回的HTTP響應中包含如下形式的頭,則視為伺服器要求設定一個cookie:
Set-cookie:name=name;expires=date;path=path;domain=domain
支援cookie的瀏覽器都會對此作出反應,即建立cookie檔案並儲存(也可能是記憶體cookie),使用者以後在每次發出請求時,瀏覽器都要判斷當前所有的cookie中有沒有沒失效(根據expires屬性判斷)並且匹配了path屬性的cookie資訊,如果有的話,會以下面的形式加入到要求標頭中發回服務端:
Cookie: name="zj";Path="/linkage"
服務端的動態指令碼會對其進行分析,並做出相應的處理,當然也可以選擇直接忽略。
這裡牽扯到一個規範(或協議)與實現的問題,簡單來講就是規範規定了做成什麼樣子,那麼實現就必須依據規範來做,這樣才能互相相容,但是各個實現所使用的方式卻不受約束,也可以在實現了規範的基礎上超出規範,這就稱之為擴充了。無論哪種瀏覽器,只要想提供cookie的功能,那就必須依照相應的RFC規範來實現。所以這裡伺服器只管發Set-cookie頭域,這也是HTTP協議無狀態性的一種體現。
需要注意的是,出於安全性的考慮,cookie可以被瀏覽器禁用。
再看一下session的原理:
筆者沒有找到相關的RFC,因為session本就不是協議層面的事物。它的基本原理是服務端為每一個session維護一份會話資訊資料,而用戶端和服務端依靠一個全域唯一的標識來訪問會話資訊資料。使用者訪問web應用時,服務端程式決定何時建立session,建立session可以概括為三個步驟:
1. 產生通用唯一識別碼(sessionid);
2. 開闢資料存放區空間。一般會在記憶體中建立相應的資料結構,但這種情況下,系統一旦掉電,所有的會話資料就會丟失,如果是電子商務網站,這種事故會造成嚴重的後果。不過也可以寫到檔案裡甚至儲存在資料庫中,這樣雖然會增加I/O開銷,但session可以實現某種程度的持久化,而且更有利於session的共用;
3. 將session的全域唯一標示符發送給用戶端。
問題的關鍵就在服務端如何發送這個session的唯一標識上。聯絡到HTTP協議,資料無非可以放到請求行、頭域或Body裡,基於此,一般來說會有兩種常用的方式:cookie和URL重寫。
1. Cookie
讀者應該想到了,對,服務端只要設定Set-cookie頭就可以將session的標識符傳送到用戶端,而用戶端此後的每一次請求都會帶上這個標識符,由於cookie可以設定失效時間,所以一般包含session資訊的cookie會設定失效時間為0,即瀏覽器進程有效時間。至於瀏覽器怎麼處理這個0,每個瀏覽器都有自己的方案,但差別都不會太大(一般體現在建立瀏覽器視窗的時候);
2. URL重寫
所謂URL重寫,顧名思義就是重寫URL。試想,在返回使用者請求的頁面之前,將頁面內所有的URL後面全部以get參數的方式加上session標識符(或者加在path info部分等等),這樣使用者在收到響應之後,無論點擊哪個連結或提交表單,都會在再帶上session的標識符,從而就實現了會話的保持。讀者可能會覺得這種做法比較麻煩,確實是這樣,但是,如果用戶端禁用了cookie的話,URL重寫將會是首選。
到這裡,讀者應該明白我前面為什麼說session也算作是對HTTP的一種擴充了吧。如下兩幅圖是筆者在Firefox的Firebug外掛程式中的,可以看到,當我第一次訪問index.jsp時,回應標頭裡包含了Set-cookie頭,而要求標頭中沒有。當我再次重新整理頁面時,圖二顯示在響應中不在有Set-cookie頭,而在要求標頭中卻有了Cookie頭。注意一下Cookie的名字:jsessionid,顧名思義,就是session的標識符,另外可以看到兩幅圖中的jsessionid的值是相同的,原因筆者就不再多解釋了。另外讀者可能在一些網站上見過在最後附加了一段形如jsessionid=xxx的URL,這就是採用URL重寫來實現的session。
第一次請求index.jsp
再次請求index.jsp
Cookie和session由於實現手段不同,因此也各有優缺點和各自的應用情境:
1. 應用情境
Cookie的典型應用情境是Remember Me服務,即使用者的賬戶資訊通過cookie的形式儲存在用戶端,當使用者再次請求匹配的URL的時候,賬戶資訊會被傳送到服務端,交由相應的程式完成自動登入等功能。當然也可以儲存一些用戶端資訊,比如頁面配置以及搜尋曆史等等。
Session的典型應用情境是使用者登入某網站之後,將其登入資訊放入session,在以後的每次請求中查詢相應的登入資訊以確保該使用者合法。當然還是有購物車等等經典情境;
2. 安全性
cookie將資訊儲存在用戶端,如果不進行加密的話,無疑會暴露一些隱私資訊,安全性很差,一般情況下敏感資訊是經過加密後儲存在cookie中,但很容易就會被竊取。而session只會將資訊儲存在服務端,如果儲存在檔案或資料庫中,也有被竊取的可能,只是可能性比cookie小了太多。
Session安全性方面比較突出的是存在工作階段劫持的問題,這是一種安全威脅,這在下文會進行更詳細的說明。總體來講,session的安全性要高於cookie;
3. 效能
Cookie儲存在用戶端,消耗的是用戶端的I/O和記憶體,而session儲存在服務端,消耗的是服務端的資源。但是session對伺服器造成的壓力比較集中,而cookie很好地分散了資源消耗,就這點來說,cookie是要優於session的;
4. 時效性
Cookie可以通過設定有效期間使其較長時間記憶體在於用戶端,而session一般只有比較短的有效期間(使用者主動銷毀session或關閉瀏覽器後引發逾時);
5. 其他
Cookie的處理在開發中沒有session方便。而且cookie在用戶端是有數量和大小的限制的,而session的大小卻只以硬體為限制,能儲存的資料無疑大了太多。
Servlet/JSP中的Session
通過上述的講解,讀者應該對session有了一個大體的認識,但是具體到某種動態網頁面技術,又是怎麼實現session的呢?下面筆者將結合session的生命週期(lifecycle),從原始碼的層次來具體分析一下在servlet/jsp技術中,session是怎麼實現的。代碼部分以tomcat6.0.20作為參考。
建立
在我問過的一些從事java web開發的人中,對於session的建立時機大都這麼回答:當我請求某個頁面的時候,session就被建立了。這句話其實很含糊,因為要建立session請求的發送是必不可少的,但是無論何種請求都會建立session嗎?錯。我們來看一個例子。
眾所周知,jsp技術是servlet技術的反轉,在開發階段,我們看到的是jsp頁面,但真正到運行時階段,jsp頁面是會被“翻譯”為servlet類來執行的,例如我們有如下jsp頁面:
......
response.setContentType("text/html;charset=ISO-8859-1");
pageContext= _jspxFactory.getPageContext(this, request, response,
null, true, 8192, true);
_jspx_page_context= pageContext;
application= pageContext.getServletContext();
config = pageContext.getServletConfig();
session = pageContext.getSession();
out =pageContext.getOut();
_jspx_out= out;
out.write("\r\n");
out.write("<!DOCTYPEHTML PUBLIC \"-//W3C//DTD HTML 4.01 Transitional//EN\">\r\n");
out.write("<html>\r\n");
......
可以看到有一句顯式建立session的語句,它是怎麼來的呢?我們再看一下對應的jsp頁面,在jsp的page指令中加入了session="true",意思是在該頁面啟用session,其實作為動態技術,這個參數是預設為true的,這很合理,在此顯示寫出來只是做一下強調。很顯然二者有著必然的聯絡。筆者在jsp/servlet的翻譯器(org.apache.jasper.compiler)的源碼中找到了相關證據:
......
if(pageInfo.isSession())
out.printil("session =pageContext.getSession();");
out.printil("out =pageContext.getOut();");
out.printil("_jspx_out= out;");
......
上面的程式碼片段的意思是如果頁面中定義了session="true",就在產生的servlet源碼中加入session的擷取語句。這隻能夠說明session建立的條件,顯然還不能說明session是如何建立的,本著逐本溯源的精神,我們繼續往下探索。
有過servlet開發經驗的應該記得我們是通過HttpServletRequest的getSession方法來擷取當前的session對象的:
publicHttpSession getSession(boolean create);
publicHttpSession getSession();
二者的區別只是無參的getSession將create預設設定為true而已。即:
publicHttpSession getSession() {
return (getSession(true));
}
那麼這個參數到底意味著什麼呢?通過層層跟蹤,筆者終於理清了其中的脈絡,由於函數之間的關係比較複雜,如果想更詳細地瞭解內部機制,建議去獨立閱讀tomcat相關部分的原始碼。這裡我將其中的大致流程敘述一下:
1. 使用者請求某jsp頁面,該版面設定了session="true";
2. Servlet/jsp容器將其翻譯為servlet,並載入、執行該servlet;
3. Servlet/jsp容器在封裝HttpServletRequest對象時根據cookie或者url中是否存在jsessionid來決定是綁定當前的session到HttpRequest還是建立新的session對象(在請求解析階段發現並記錄jsessionid,在Request對象建立階段將session綁定);
4. 程式按需操作session,存取資料;
5. 如果是新建立的session,在結果響應時,容器會加入Set-cookie頭,以提醒瀏覽器要保持該會話(或者採用URL重寫方式將新的連結呈現給使用者)。
通過上面的敘述讀者應該瞭解了session是何時建立的,這裡再從servlet這個層面總結一下:當使用者請求的servlet調用了getSession方法時,都會擷取session,至於是否建立新的session取決於當前request是否已綁定session。當用戶端在請求中加入了jsessionid標識而servlet容器根據此標識尋找到了對應的session對象時,會將此session綁定到此次請求的request對象,用戶端請求中不帶jsessionid或者此jsessionid對應的session已到期失效時,session的綁定無法完成,此時必須建立新的session。同時發送Set-cookie頭通知用戶端開始保持新的會話。
保持
理解了session的建立,就很好理解會話是如何在用戶端和服務端之間保持的了。當首次建立了session後,用戶端會在後續的請求中將session的標識符帶到服務端,服務端程式只要在需要session的時候調用getSession,服務端就可以將對應的session綁定到當前請求,從而實現狀態的保持。當然這需要用戶端的支援,如果禁用了cookie而又不採用url重寫的話,session是無法保持的。
如果幾次請求之間有一個servlet未調用getSession(或者乾脆請求一個靜態頁面)會不會使得會話中斷呢?這個不會發生的,因為用戶端只會將合法的cookie值傳送給服務端,至於服務端拿cookie做什麼事它是不會關心的,當然也無法關心。Session建立之後,用戶端會一直將session的標識符傳送到伺服器,無論請求的頁面是動態、靜態,甚至是一副圖片。
銷毀
此處談到的銷毀是指會話的廢棄,至於儲存會話資訊的資料結構是回收被重用還是直接釋放記憶體我們並不關心。Session的銷毀有兩種情況:逾時和手動銷毀。
由於HTTP協議的無狀態性,服務端無法得知一個session對象何時將再次被使用,可能使用者開啟了一個session之後再也沒有後續的訪問,而且session的保持是需要消耗一定的服務端開銷的,因此不可能一味地建立session而不去回收無用的session。這裡就引入了一個逾時機制。Tomcat中的逾時在web.xml裡做如下配置:
<session-config>
<session-timeout>30</session-timeout>
</session-config>
上述配置是指session在30分鐘沒有被再次使用就將其銷毀。Tomcat是怎麼計算這個30分鐘的呢?原來在getSession之後,都要調用它的access方法,修改lastAccessedTime,在銷毀session的時候就是判斷目前時間和這個lastAccessedTime的差值。
手動銷毀是指直接調用其invalidate方法,此方法實際上是調用expire方法來手動將其設定為逾時。
當使用者手動請求了session的銷毀時,用戶端是無法知道服務端的session已經被銷毀的,它依然會發送先前的session標識符到服務端。而此時如果再次請求了某個調用了getSession的servlet,服務端是無法根據先前的session標識符找到相應的session對象的,這是又要重新建立新的session,分配新的標識符,並告知服務端更新session標識符開始保持新的會話。
Session的資料結構
在servlet/jsp中,容器是用何種資料結構來儲存session相關的變數的呢?我們猜測一下,首先它必須被同步操作,因為在多線程環境下session是線程間共用的,而web伺服器一般情況下都是多線程的(為了提高效能還會用到池技術);其次,這個資料結構必須容易操作,最好是傳統的索引值對的存取方式。
那麼我們先具體到單個session對象,它除了儲存自身的相關資訊,比如id之外,tomcat的session還提供給程式員一個用以儲存其他資訊的介面(在類org.apache.catalina.session.StandardSession裡):
publicvoid setAttribute(String name, Object value, boolean notify)
在這裡可以追蹤到它到底使用了何種資料:
protectedMap attributes = new ConcurrentHashMap();
這就很明確了,原來tomcat使用了一個ConcurrentHashMapObject Storage Service資料,這是java的concurrent包裡的一個類。它剛好滿足了我們所猜測的兩點需求:同步與易操作性。
那麼tomcat又是用什麼資料結構來儲存所有的session對象呢?果然還是ConcurrentHashMap(在管理session的org.apache.catalina.session.ManagerBase類裡):
protectedMap<String, Session> sessions = new ConcurrentHashMap<String,Session>();
具體原因就不必多說了。至於其他web伺服器的具體實現也應該考慮到這兩點。
SessionHijack
Session hijack即工作階段劫持是一種比較嚴重的安全威脅,也是一種廣泛存在的威脅,在session技術中,用戶端和服務端通過傳送session的標識符來維護會話,但這個標識符很容易就能被嗅探到,從而被其他人利用,這屬於一種中間人攻擊。
本部分通過一個執行個體來說明何為工作階段劫持,通過這個執行個體,讀者其實更能理解session的本質。
首先,我編寫了如下頁面:
<%@page language="java" pageEncoding="ISO-8859-1"session="true"%>
<!DOCTYPEHTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<html>
<head>
<title>index.jsp</title>
</head>
<body>
This is index.jsp page.
<br>
<%
Object o =session.getAttribute("counter");
if (o == null) {
session.setAttribute("counter", 1);
} else {
Integer i =Integer.parseInt(o.toString());
session.setAttribute("counter", i + 1);
}
out.println(session.getAttribute("counter"));
%>
<ahref="<%=response.encodeRedirectURL("index.jsp")%>">index</a>
</body>
</html>
頁面的功能是在session中放置一個計數器,第一次訪問該頁面,這個計數器的值初始化為1,以後每一次訪問這個頁面計數器都加1。計數器的值會被列印到頁面。另外,為了比較簡單地類比,筆者禁用了用戶端(採用firefox3.0)的cookie,轉而改用URL重寫方式,因為直接複製連結要比偽造cookie方便多了。
下面,開啟firefox訪問該頁面,我們看到了計數器的值為1:
然後點擊index連結來重新整理計數器,注意不要重新整理當前頁,因為我們沒用採用cookie的方式,只能在url後面帶上jsessionid,而此時地址欄裡的url是無法帶上jsessionid的。四,我把計數器重新整理到了20。
下面是最關鍵的,複製firefox地址欄裡的地址(筆者看到的是http://localhost:8080/sessio
n/index.jsp;jsessionid=1380D9F60BCE9C30C3A7CBF59454D0A5),然後開啟另一個瀏覽器,此處不必將其cookie禁用。這裡我開啟了蘋果的safari3瀏覽器,然後將地址粘貼到其地址欄裡,斷行符號後如:
很奇怪吧,計數器直接到了21。這個例子筆者是在同一台電腦上做的,不過即使換用兩台來做,其結果也是一樣的。此時如果交替點擊兩個瀏覽器裡的index連結你會發現他們其實操縱的是同一個計數器。其實不必驚訝,此處safari盜用了firefox和tomcat之間的維持會話的鑰匙,即jsessionid,這屬於session hijack的一種。在tomcat看來,safari交給了它一個jsessionid,由於HTTP協議的無狀態性,它無法得知這個jsessionid是從firefox那裡“劫持”來的,它依然會去尋找對應的session,並執行相關計算。而此時firefox也無法得知自己的保持會話已經被“劫持”。
結語
到這裡,讀者應該對session有了更多的更深層次的瞭解,不過由於筆者的水平以及視野有限,文中也不乏表述欠妥之處,通篇更多地描述了在servlet/jsp中的session機制,但其他開發平台的機制也都萬變不離其宗。只要認真思考,你會發現其實這裡林林總總之間,總有一些因果關係存在。在軟體規模日益增大的背景下,我們更多的時候接觸到的是架構、組件,程式員的雙眼被蒙蔽了,在這些架構、組件不斷產生以及版本的不斷更新中,其實有很多相對不變的東西,那就是規範、協議、模式、演算法等等,真正令一個人得到提高的還是那些底層的支撐技術。平時多多思考的話,你就能把類似的探索轉化為印證。做技術猶如解牛,知筋知骨方能遊刃有餘。
轉載請保留出處:shoru.cnblogs.com 晉哥哥的私房錢