本文講述了一路走來對Session的認知。文章有點長,不過是故事型的,應該不枯燥。相信讀完也一定會有所收穫。 (一) “當你登陸系統後,伺服器會創建一個Session,保存你的登陸信息,下次再訪問時就不需要再登陸。Session可以保存到資料庫里或文件里,必要時可以還原出來。”沒錯,這就是我十幾年前 ...
本文講述了一路走來對Session的認知。文章有點長,不過是故事型的,應該不枯燥。相信讀完也一定會有所收穫。
(一)
“當你登陸系統後,伺服器會創建一個Session,保存你的登陸信息,下次再訪問時就不需要再登陸。Session可以保存到資料庫里或文件里,必要時可以還原出來。”
沒錯,這就是我十幾年前認識的Session。那時正讀大學,由於天生對游戲不感興趣,也沒有運動細胞,整天閑得很,唯有學習。
那是一個到處充滿著“等等等等,等等等等,等等等等,等”手機鈴聲的年代。有人會問,iphone呢,估計還在喬布斯的襁褓里精心呵護著的吧。
由於我到大學才算真正接觸電腦,而且還是買網卡在宿舍里通過電話線撥號上網,關鍵還是按時間計費的。
我上網技術本來就很差,再加上上網也不方便,同時受學校環境影響,我就學習.NET了,整天沒事就看MSDN(中文離線版的)。
開頭的那段話就是我在MSDN里看到的,看過很多遍,所以記憶特別清晰。前半句我能明白,因為我也登陸過系統,確實只需登陸一次,後續就不用登陸了。
但是後半句我就很納悶,甚至莫名其妙,Session為什麼要保存到資料庫里呢,這個問題在當時想過很多遍,一直想不通。
不過這也正常,那時候我可能連“文件”這個詞都不能很好的理解。更不要提什麼“目錄”了,你直接告訴我“文件夾”不就行了嘛。
不理解歸不理解,但我能記住,只要面試時能balabala說出來,這就夠了。
(二)
終於畢業了,回到家鄉的省會找了一份工作。那個年代的工作是什麼呢?
“企業建站大酬賓啦,基礎型800,標準型1200,豪華型1600,帶會員的外加400。”。
沒錯,就是用.NET里的ASP.NET(俗稱WebForm)開發小型企業網站。
公司非常小,一個美工,一個技術(會asp,.net只懂一點點),一個客服(不是接電話的,是定期打電話回訪客戶的),一個行政(為啥不叫“前臺”呢,因為公司門口沒有地方放桌子),再加一個我。
那時的我不叫開發,叫程式員。每天做的事情也不叫寫代碼,叫“加程式”。當你看到“加程式”這三個字時一定很莫名。我來解釋下。
美工做好效果圖,客戶確認後,會切圖做成靜態的html頁面。我把程式代碼加進去(就是把html頁面換成.aspx頁面),讓所有頁面“動”起來。估計這就是“加程式”這個叫法的來源,嗯,不錯,挺形象的。
可見我這寫代碼的沒一點地位,是我的程式加到人家美工的作品里,人家美工才是大姐大。嗚嗚。
終於可以使用Session了,不再像大學那會兒總是紙上談兵。
在.aspx頁面對應的C#源代碼文件里,有個屬性就叫Session(是從System.Web.UI.Page類繼承的),可以直接拿來用。
在登陸時把用戶信息放入Session,如Session["userName"] = "lixinjie",Session["nickName"] = "李大胖"等。
在登出時把用戶信息清除掉,如Session["userName"] = null,Session["nickName"] = null等。
納尼,就是這樣清除的嗎?不應該是Session.RemoveAll()或Session.Clear()嗎?告訴你,從沒這樣用過,那時打開SQL Server 2000看到密碼竟是用明文存儲的,也不要太驚訝哦。
那時剛入行的我也有很多煩惱,為啥靜態html頁面的佈局樣式都是好的,等我“加完程式”後頁面的佈局樣式就都亂了。還得讓美工幫忙調。
為此我還專門學習div+css佈局(為啥不學table佈局呢?table土的掉渣,div才高大上呢),貌似最後也沒太大改觀,佈局還是照樣亂。
抱著“齊家治國平天下”的我,怎麼可能安心在這小公司里天天“加程式”呢。不行,我要辭職,要去大城市,乾“大事”。
你們肯定告訴我,辭職要提前一個月提,公司可能要重新招人,做好工作交接什麼的。告訴你,那時的我,不存在這些的。你都不知道我大學曠過多少節課,大四時勸室友說,趕緊曠課吧,再不曠就沒機會了,馬上要畢業了。
下班就跟老闆說,我想辭職,第二天就不去了。老闆也對得起我,到現在最後一個月的工資還沒給我發呢。後來我想,那時都是發現金的,直接發到手上,老闆肯定是找不著我所以才不給我發的。嗯,這樣想心理平衡多了。
(三)
春節後一張前往北京的動車票,我踏上了旅途。那時感覺動車票老貴了,好幾百呢。
春節剛過的北京依然寒冷,不過那時2元錢游遍整個北京城還是非常不錯的,只是幾個月過去了都不知道地面上是啥樣子的。
找了一家公司,還是.NET里的ASP.NET開髮網站,沒辦法,那時我只會這個。後來公司準備撤掉Java部門,搞Java的人集體辭職了,由我來負責所有Java項目的收尾工作。
很奇怪為啥是我負責Java的收尾,我可是以.NET的身份進去的。莫非他們覺得我做.NET水平很次?或難道我給他們說過我會Java?不過我確實會Java,只是會Java語法,看過一些JDK文檔而已。
那時正是大夏天,天天往客戶那裡跑,那一號線坐的我爽歪歪,沒有空調,人頭攢動,汗流浹背。
終於可以在Java里使用session了。
是這樣用的,在登陸時,request.getSession().setAttribute("userName", "lixinjie"),request.getSession().setAttribute("nickName", "李大胖")等。
在程式中如果要使用的話,是這樣的request.getSession().getAttribute("nickName")等。
在登出時,是這樣的request.getSession().removeAttribute("userName"),request.getSession().removeAttribute("nickName")等,終於知道用remove了。
納尼,就remove一下就完了嗎?不應該再request.getSession().invalidate()一下嗎?告訴你,從來沒用過。
不是還有一個獲取session的方法,帶一個布爾參數,指示在session不存在時是否創建一個新的。我表示那時從來沒見過這個方法,連api去哪裡找都不知道。不像微軟,就一個MSDN包括所有的內容,Java的東西,這兒一點,那兒一點,到處都是,零零碎碎的,我很懵。
更別提其它什麼和session相關的內容了。那時連打包都不知道,就是把編譯後的東西一股腦複製到tomcat下麵,就完事了。
對於一個在大學看了很多C、C++,OOP,.NET理論,畢業後就用ASP.NET做過幾個小網站,然後半路轉Java,還沒有人帶的我,可想而知。
但我能把工作完成的還可以,因為我理論知識還算豐富啊,畢竟都是web開發嘛,.NET和Java好多東西都相通,只是叫法不一樣而已。。
但是我對於Java的進步並不快,很長一段時間我都不知道自己用的這些叫servlet api,更不知道去哪裡找,因為JDK里沒有。
後來我就離開了那家公司,唯一遺憾的就是沒有去傳媒大學看過美女,雖然天天坐八通線路過人家學校門口。
(四)
給客戶做的系統已經運行好幾年了(我去的時候系統已經存在了),平時的開發不算太忙。
有一天客戶說,因為安全問題,他們的網路做了規劃,分核心域和互聯網域。資料庫及訪問資料庫的代碼放入核心域(那個年代還沒有redis什麼事),其餘的代碼放入互聯網域,外網只能訪問互聯網域。
這意味著我們的系統要拆分啊。我去,這整個就像一盤義大利面似的(義大利面那時用來形容asp和jsp的,因為它們的html代碼和程式代碼混合在一起,很亂),根本拆不動,也沒時間啊。
我的一個同事(工作時間比我長)說,就在互聯網域放一個Nginx伺服器做反向代理,把原來整個項目放入核心域。把外部來的HTTP請求通過Nginx轉發到核心域,再把響應轉發出來,這不就OK了。
我能明白這個做法,但是感覺跟客戶想要的不太一樣,管他呢,反正客戶只註重結果,不太在意細節。這個事情就這樣搞定了。
我谷歌(當時谷歌還是可以隨便用的)了反向代理這個詞,覺得明明就是個代理,為啥要加個反向呢,不明白。Nginx這個詞那時對我來說簡直就是個新物種了。
後來客戶又提了一個需求,說部署一個tomcat不太合適,萬一掛了,系統就不能用了。應該再部署一個,提高整體的可靠性。這不就是現在說的高可用嘛,但是在當時,好像不太能聽到高可用這個詞。
我同事說這簡單,Nginx除了反向代理外,還有負載均衡功能,只要配置一下,後面掛兩個tomcat就行了。他搞好後我們開始測試,發現明明已經登陸了為啥還要求登陸啊。
後來通過列印日誌發現,登陸時是在第一個tomcat上執行的,後續的請求又發往了第二個tomcat。但它上面沒有用戶的Session啊,Session在第一個上呢,所以就又要求登陸了。
其實最好是做Session共用,但是那個年代確實很少,而且又是“意麵式”的老系統,這種方法行不通。
我同事說只要一個用戶第一次請求哪個tomcat,後續永遠請求那個tomcat不就行了嘛,只要修改下負載均衡策略,設置為ip hash就行了。
這樣成功地解決(避免)了問題。這種處理方式還叫高可用嗎,哎呀不管了,只要實現客戶的需求就行,反正客戶那時也關註不到這個細節。
此時我知道了原來Session是由tomcat創建的,駐留在記憶體里,每個Session都有一個唯一的Id標識,叫做session id。當用戶登陸時,這個session id會被伺服器寫到cookie里傳回客戶端。
下次這個客戶端再發起請求時就會把這個cookie帶上,tomcat從cookie里解析出session id,然後去自己的所有session里找,如果找到session說明他已經登陸過了,反之則沒有,要求他去登陸。
(五)
給西部某省客戶做的項目運行良好。另外一個省也有相似的需求,就把這個項目拿來改吧改吧進行復用。該上線時突然意識到,原來的那個省人口少,一個tomcat就能搞定,現在這個可是人口相對多的大省,和客戶溝通後決定部署8台tomcat。
用戶信息是放到session里的,此時真的要實現session共用了。那時項目里已經用了redis,所以就採用tomcat+redis實現session共用的方案。從網上下載了個jar包,放到tomcat下,然後修改配置文件,重啟tomcat,進行測試。
登陸redis里查看,發現並沒有session。可能是版本問題,就在網上下載其它版本的jar包,進行重試。試了很長時間,依然是不行,我一直在網上查找解決方法。
當時是一個運維小哥在一直操作,他的大哥運維老大一直在旁邊站著,因為那時和現在一樣,臨近春節,那天正好是公司年會,都著急著走呢,可是問題不解決,誰也走不了。
我在網上查到這種方式只支持redis單節點,不支持集群,而我們項目用的是redis集群。我把這個原因告訴了運維小哥,可是沒有redis單節點啊。最後沒辦法了,運維小哥就把集群停掉,單獨剝離出來一個節點啟動了。
測試後發現redis里終於有session的數據了。於是我說那就使用單節點吧,可是運維老大不同意,說不安全。那就繼續想辦法吧。
同時其它同事也在測試,發現結果不對。錯誤信息是redis的問題,我扒拉扒拉項目代碼,發現代碼是按訪問集群的方式寫的。最後就是session共用的方式不支持集群,項目里的代碼不支持單節點,我去,完全的自我對抗。
能用的session共用方案只有這一種。重新修改項目代碼讓它支持單節點吧,也不現實,馬上就該上線了。此時我都有點懵了。還都等著去參加年會呢。
實在沒辦法了,運維老大說這樣吧,你們的項目代碼還是使用redis集群,我再裝一個單節點redis,專門讓session共用使用。至此,一個“兩全其美”的方案終於出爐。
其實之前我就知道這個session共用方案,但自己沒有試驗過,也沒有瞭解過其原理,只知道添加一個jar包,修改一些配置就可以了。通過這次事情我終於明白了。
就是重新實現tomcat操作session時的一部代碼,並通過修改配置文件的方式把自己的實現加進去來替換掉原來的一部分實現。當tomcat創建session後會把它存入redis,獲取session時會去redis里把它讀出來,修改session後會重新把它更新到redis里。
所以不管你的請求路由到8台tomcat中的哪一個,最終都是讀取的redis里的同一個session數據,這就實現了session共用。
tomcat本身也從有狀態的變更為無狀態的,可以任意擴展節點了。
(六)
隨著分散式時代的到來,集群化部署是大趨勢。需要找一個適合的session共用方案,優先想到的當然是官方的spring session了。
經過一些努力,終於把spring session用到項目中了。開發/測試的時候,一切正常,等部署上線時發現有問題。
明明上一個請求放到session里的東西,在下一個請求去取的時候竟然沒有,這也太奇怪了,生平還是頭一次遇到。於是就在獲取session的代碼部分加個列印語句,首先得把session id輸出一下吧。
結果還真有驚喜,每次session id都不一樣。這說明瞭什麼,每次都是一個新的session啊。肯定不對啊,但是我不會去懷疑spring session本身會有問題,至於為什麼不去懷疑呢,以後的文章會慢慢講出來。
稍稍穩定一下氣息,來分析一下。在代碼中是這樣獲取session的,request.getSession(),這個方法的意思是當有session時就返回session,當沒有時就創建一個新的session再返回。
既然每次都是不同的session id,說明瞭每次都是創建了新的session。也就是因為每次當前都沒有session所以才會去創建新的。這就奇怪了,我往裡存數據時明明都已經有session了,而且數據都存成功了呢。
存數據時session創建成功是事實,取數據時因沒有找到那個session而創建新的這也是事實。我們之所以相信能找到那個session,就是因為把session id放到cookie里傳到了伺服器端,伺服器端根據session id找到session。
趕緊查看瀏覽器,發現cookie正常傳給伺服器了。但是伺服器的行為卻是好像沒收到。問題肯定出現在中間某個環節。
隨後與客戶聯繫發現,網路中有一個Nginx把cookie過濾掉了。所以請求到達伺服器端根本就沒了cookie,所以不可能找到session,因此只能創建新的session了。
因客戶的網路很難調整,幸運的是spring session支持把session id放入header中傳輸,至此問題得以解決。
spring session支持多種session的存儲介質,當然用的最多的應該還是redis。大家都知道長時間不操作的話session是會過期的。我們第一個想到的就是redis的key也支持過期時間啊。
只要把session的過期時間設置成redis的TTL,在訪問session時更新這個TTL就行了。當你不訪問時,TTL逐漸減少到0,key過期,session也就過期了,看似很不錯。
但是為了完整地支持session的特性,spring session肯定要做的更多。就好比一個健壯的程式裡面至少有三分之一的代碼都是在處理異常情況一樣。
一是redis里的key過期後還是存在的,當你再去訪問這個key時,redis發現這個key已過期,才會把這個key刪除。當然redis也會按一定的演算法去發現過期的key並刪除它們。因為這個演算法不是地毯式的,所以總會有漏網之魚。
二是session在過期時需要觸發過期事件。由於redis的原因,session過期時可能大家都不知道,所以根本無法觸發過期事件。
三是即使我們知道了redis的過期,去觸發了session的過期事件,由於此時redis的key已經因為過期而被刪除了,所以在session的過期事件里已經獲取不到session的數據了。
針對前兩個問題,spring session里有個定時任務,定時輪詢過期的key,刪除key,並觸發session的過期事件。
針對第三個問題,可以把session的數據和session的過期時間分開存儲,即單獨用一個key存儲session的過期時間,這樣session過期時,session的數據還是存在的,等觸發完session的過期事件,稍後再讓session的數據本身也過期。
(七)
有次對一個已有項目的復用,該項目採用spring xml配置,是通過自定義過濾器實現許可權控制的,採用apache shiro實現基於redis的session共用。
由於不想讓公司的技術歷史拖得太長,就把它改造為spring boot的方式。
自定義許可權過濾器使用FilterRegistrationBean的方式進行註冊,如下圖:
apache shiro使用shiro官方提供的starter和spring boot進行整合,如下圖:
因shiro是用來做session共用的,正常順序是先執行shiro獲取session,然後再執行自定義許可權過濾器過濾許可權。我也是這麼做的,很可惜運行時報錯。在網上搜了好長時間,也按照各種方法修改測試,可惜還是不行。
雖然我之前沒有使用過shiro,但是我知道spring security的原理,所以shiro應該和它差不太多。這是我對原理層面的認知。
具體的使用方式是完全按照官方文檔來的,應該也不會有問題。所以當時我唯一覺得問題出在順序上了。即先執行了自定義許可權過濾器,此時shiro還沒執行呢,所以獲取不到session,報錯了。
可是無論我怎麼調整過濾器的註冊順序,都無濟於事。實在是不行了,我只能去源碼里砰砰運氣了。shiro本身肯定是沒有問題的,所以就看starter的源碼就行了,好在它的源碼很少。
因為我知道shiro會註冊一個核心的過濾器,所以一定要把它找到才行。不一會功夫,還真被我找著了。如下圖:
乍一看,沒有問題呀,使用FilterRegistrationBean註冊過濾器,而且將它的順序設置為1,我的自定義過濾器註冊順序都是好幾百呢,肯定在它後面啊。
仔細一看發現它使用了一個註解@ConditionalOnMissingBean,意思就是當沒有註冊這個類型的bean時我才會註冊。和@Bean方法連用時方法的返回類型就是bean的類型。
很顯然這裡註冊的bean類型就是FilterRegistrationBean類型。因為我的自定義許可權過濾器就是使用它註冊的,這個類型的bean在容器中已經有了,所以starter里已經不會再去註冊shiro的核心過濾器了。
當然,這都是分析推論,究竟是不是這個呢,還要去驗證。我去容器里查找,發現果然沒有註冊shiro的這個過濾器。然後把我的自定義許可權過濾器註冊部分給註釋掉,發現shiro的這個過濾器就被註冊了。
事實證明我們的分析是對的。只能說我正好碰到了shiro官方starter的釘子上了。因此我就放棄使用starter的集成方式,使用傳統的集成方式了。至此,問題得以解決。
之後我也看了shiro的部分源碼,直到有一天我看到了有個介面叫SessionDAO,裡面竟然是四個CRUD方法,忽然我就明白了,其實session並沒有受到優待,shiro只是把它當作數據拿DAO去操作它。
這就說明它可以被CRUD,可以被序列化和反序列化,可以被傳輸等。它和普通的數據其實並沒有什麼本質的不同,唯一的區別可能就是它本身的邏輯複雜些,具有超時時間等這些特性。
也許是十幾年前我在微軟的MSDN上看到了對Session的特別介紹,使我產生了先入為主的思想,一直覺得session是與眾不同的,不過至此我對它已經有了新的認識。
你是不是也是這樣認為的呢?歡迎留言告訴我。
編程新說
用獨特的視角說技術