相信用過spring-session做session共享的朋友都很喜歡它的精巧易用-不依賴具體web容器、不需要修改已成項目的代碼。筆者在使用spring-session的過程中也對spring-session的絕佳包容性、穩定性贊嘆不已,spring-session 和 redis 的結合堪稱神器,但是兩者結合下來真的可以完全代替原本的session管理嗎?
一、url rewrite保持Session
相信很多做過文件上傳的朋友遇到過這樣的需求-在瀏覽器中顯示上傳進度條並且要求多瀏覽器兼容性,特殊國情~兼容IE低版本,OK,只能用上筆者認為已經過時的技術-Flash,做前端比較多的肯定知道SWFUpload、Uploadify這類通過調用Flash上傳實現瀏覽器本身不具備的顯示進度條的功能。但是在某些瀏覽器、某些flash客戶端版本下,上傳的HTTP請求是不帶cookie的,so,session問題如何解決?普遍的做法是通過url rewrite保持Session,即獲取cookie中的jsessionid來放到請求url的參數中。那么spring-session支持嗎?回答NO,至少spring-session源碼中是沒有支持的,如何支持呢?
我們閱讀代碼可以看到spring-session中實現從cookie到session的策略類是CookieHttpSessionStrategy,並且允許自定義策略類,只需要在spring-session中定義bean就行了,所以我們來擴展這個CookieHttpSessionStrategy。
1. 想要直接繼承CookieHttpSessionStrategy?那是不可能的,它是final的,為啥?暫時不清楚。
2. 看來只能硬來了,首先把CookieHttpSessionStrategy的源碼復制出來,放到自己的項目里一份,去掉final關鍵字,姑且新類名就叫SessionForCookieStrategy吧。
3. 為了整潔,不建議在這個類下直接修改了,咱還是應該堅持java人的操守不是?新建一個SessionUnionStrategy類,提供了從request域中獲取jsessionid的參數。
4. 建立SessionForURLFilter,即處理從url中獲取jsessionid然后把值丟給request Attribute中。
5. 配置文件配置Strategy和Filter
上代碼:
SessionUnionStrategy類:
public class SessionUnionStrategy extends SessionForCookieStrategy{ @Override public Map<String, String> getSessionIds(HttpServletRequest request) { Map<String, String> result = super.getSessionIds(request); if(result.isEmpty()){ String jsessionId = (String)request.getAttribute(SessionForURLFilter.OLDEST_URL_SESSION_ID_ATTRIBUTE_NAME); if ((jsessionId != null) && (!"".equals(jsessionId.trim()))) { result.put(DEFAULT_ALIAS, jsessionId); } } return result; } }
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
SessionForURLFilter類:
public class SessionForURLFilter extends OncePerRequestFilter{ public static final String OLDEST_URL_SESSION_ID_ATTRIBUTE_NAME = "OLDEST_URL_SESSION_ID_ATTRIBUTE_NAME"; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { if(request.isRequestedSessionIdFromURL()){ String jsessionId = request.getRequestedSessionId(); if ((jsessionId != null) && (!"".equals(jsessionId.trim()))){ request.setAttribute(OLDEST_URL_SESSION_ID_ATTRIBUTE_NAME, jsessionId); } } filterChain.doFilter(request, response); } }
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
spring容器配置文件中:
<bean name="sessionForURLFilter" class="cn.emay.bootstrap.util.SessionForURLFilter"/> <bean class="cn.emay.bootstrap.util.SessionUnionStrategy"> <property name="cookieSerializer"> <bean class="org.springframework.session.web.http.DefaultCookieSerializer"> <property name="cookieName" value="JSESSIONID"/> </bean> </property> </bean>
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
web.xml文件中:
<filter> <filter-name>sessionForURLFilter</filter-name> <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class> </filter> <filter-mapping> <filter-name>sessionForURLFilter</filter-name> <url-pattern>/*</url-pattern> <dispatcher>REQUEST</dispatcher> <dispatcher>ERROR</dispatcher> </filter-mapping>
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
二、除了JDK序列化還能用JSON序列化方式嗎?
用過spring-session的朋友都知道,它的基本工作原理是把原本session中的對象從單機的內存中剝離出來放到的公共存儲中,這就需要序列化了,默認使用JDK序列化方式,並且是支持自定義序列化方式的。很多人知道既然一般一個JAVA對象的JSON的存儲量肯定比JDK序列化方式的存儲量小的多,那為啥不用JSON來存儲?一來可以減輕IO的壓力,二來可以直接在redis中直接閱讀session數據。
首先在spring-session的文檔中找到這么一段:
Custom RedisSerializer
You can customize the serialization by creating a Bean namedspringSessionDefaultRedisSerializer
that implementsRedisSerializer<Object>
.
筆者也忍不住也就試了一番,spring容器配置:
<bean id="springSessionDefaultRedisSerializer" class="org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer"/>
- 1
可以跑起來,不過遇見下列代碼就頭疼了:
@RequestMapping("/setS") public String setSession(HttpServletRequest req) { Long value = 1l; req.getSession().setAttribute("key", value); return null; } @RequestMapping("/getS") public String getSession(HttpServletRequest req) { Long value = (Long)req.getSession().getAttribute("key"); System.out.println(value); return null; }
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
觸發異常:
java.lang.ClassCastException: java.lang.Integer cannot be cast to java.lang.Long
- 1
去redis中找具體存儲數據:
7) "sessionAttr:key" 8) "1"
- 1
- 2
了然,JSON的虛化列方式明了是明了,但是連個java類型都沒有限定說明,雖然我們可以去獲取對象前判斷類型再轉化,但是也就喪失了spring-session使用的關鍵優點-不需要修改已有代碼。
三、JSP下的session設置坑
這是一個比較難發現的問題,有些朋友在spring-session上手之后可能一帆風順就沒有去關注spring-session的基本工作流程,但是在spring-session何時將放入session中的對象序列化存儲到redis中如果沒有一個清晰的認識可能會進入這個坑。
如果你在你的代碼中有這樣存入session對象:
controller中:
@RequestMapping("/setS") public String setSession(HttpServletRequest req) { Map<Object,Object> value = new HashMap<Object,Object>(); req.getSession().setAttribute("valid", value); return "test"; } @RequestMapping("/getS") public String getSession(HttpServletRequest req) { Map<Object,Object> value=(Map<Object,Object>)req.getSession().getAttribute("valid"); System.out.println(value.keySet().size()); return null; }
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
test.jsp中:
<c:forEach var="v" begin="1" end="100" step="1"> <%-- 任意長文本--%> <c:set target="${valid}" property="${v}" value="1"/>y </c:forEach>
- 1
- 2
- 3
- 4
最終getS打印的size未必是100,本地測試在jetty下正常,在tomcat下就不是100了,可能只有一半,只存入了一半數據?調試得出問題所在,看圖:
結論是當JSP輸出到buffer的時候如果buffer滿了的話將flushBuffer,同時將由spring-session提交session,即寫入redis。spring-session源碼中:
RedisOperationsSessionRepository中部分方法:
public void setAttribute(String attributeName, Object attributeValue) { this.cached.setAttribute(attributeName, attributeValue); this.delta.put(RedisOperationsSessionRepository.getSessionAttrNameKey(attributeName), attributeValue); flushImmediateIfNecessary(); } private void saveDelta() { ...序列化存入redis this.delta = new HashMap(this.delta.size()); ... }
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
由此可見當flushBuffer的時候會將delta重置,此時已經將對象序列化入redis中了,不會管之后這里邊的對象會不會改變,除非再次delta.put(...)
最終解決辦法及建議:在完成對象修改之后最后將需要設置進session中的對象setAttribute...
。
四、redis鍵空間通知與對象序列化serialVersionUID改變之后
筆者對spring-session的redis鍵空間通知方面的接觸始於一個開發問題,如果在一個web集群下單個web容器中修改了將放入session中的對象的class結構(或者說是serialVersionUID改變),那么在其它web容器在有session失效中,該容器將觸發異常-無法反序列化session對象,最終通過抓包發現,當其它服務器有session的重新登錄的時候該web容器向redis發出了hgetall (舊sessionid)命令。也就是說web集群中所有的session失效時,其它所有服務器將接受到通知並反序列化這個session中的所有對象。結合spring-session文檔可以找到:
Firing SessionDeletedEvent or SessionExpiredEvent is made available through the SessionMessageListener which listens to Redis Keyspace events. In order for this to work, Redis Keyspace events for Generic commands and Expired events needs to be enabled. For example:
redis-cli config set notify-keyspace-events Egx
很明顯spring-session實現Session刪除事件和Session過期事件需要依賴redis的鍵空間通知功能,spring-session的源碼中直接默認執行這句redis命令(是的,直接執行config set,筆者對這種直接侵入的做法實不敢苟同)。當然會有朋友想到實現這種全局通知對redis的性能影響得多大,在高並發訪問情況下尤其影響吧。對此筆者翻閱了spring-session的在線文檔,沒有一個清晰的解釋。只有提到如果使用者的redis是一個安全較高的公共redis(比如阿里雲的),可以這樣配置:
<util:constant static-field="org.springframework.session.data.redis.config.ConfigureRedisAction.NO_OP"/>
- 1
- 2
筆者也同樣搜索了很久,大多博文對這個的解釋模棱兩可。通過測試得出這句配置只是說讓spring-session不去直接執行config set
,並沒有說可以不用redis的鍵空間通知,而且如果你的程序已經運行過了,即已經對redis設置過這個鍵空間通知了,不去手動在redis種清除這個config那么將依然收到鍵空間通知。如果需要徹底不接受redis鍵空間通知,可首先加入這句配置,然后去redis中將鍵空間通知config置空(筆者只是實現了不通知,是否有其它程序上的問題沒有全面的測試,為了穩定暫時只能按照spring-session默認的來)。對於能否取消redis鍵空間通知以提高web集群的性能筆者沒有再深入spring-session源碼,有經驗的讀者可以給予下意見。
五、題外:spring升級后的一個問題
spring-session要求spring基礎庫版本在3.2.14以上,如果你的web應用的spring框架版本是3.0.x,那么在升級至該版本時,請升級關鍵配置:
將過時的配置:
<bean class="org.springframework.web.servlet.mvc.annotation.AnnotationMethodHandlerAdapter" >
- 1
修改為:
<bean class="org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter" >
- 1
否則在文件上傳至返回json的請求處理器時,web容器將上傳成功但返回http錯誤碼,更多的關於這個過時配置的bug讀者可自行Google。
六、spring-session測試性能簡說
筆者在實際LR壓力測試監控過程中,spring-session調用redis方面性能還是挺穩定的,粗略得出的數據有在最高5000人並發訪問web集群時redis占用內存6G,redis連接數600(當然這只是個參考,具體web應用的session存儲內容不同),redis和web容器在同一個內網的環境下前端打開速度與沒有共享session情況下未發生明顯的延遲,建議保證redis服務器與web應用間的數據聯通速率。對測試數據感興趣的開發者推薦使用Apache ab工具進行壓測。