背景:最近對一個老項目進行改造,使其支持多機部署,其中最關鍵的一點就是實現多機session共享。項目有多老呢,jdk版本是1.6,spring版本是3.2,jedis版本是2.2。java
接到這項目任務後,理所固然地google了,一搜索,發現解決方案分爲兩大類:web
對於「tomcat的session管理」,很不幸,線上代碼用的是resin,直接pass了;redis
對於「spring-session」,這是spring全家桶系列,項目中正好使用了spring,能夠很方便集成,而且原業務代碼不用作任何發動,彷佛是個不錯的選擇。可是,在引入spring-session過程當中發生了意外:項目中使用的jedis版本不支持!項目中使用的jedis版本是2.2,而spring-session中使用的jedis版本是2.5,有些命令像"set PX/EX NX/XX",項目中使用的redis是不支持的,但spring-session引入的jedis支持,直接引入的話,風險難以把控,而升級項目中的redis版本的話,代價就比較高了。spring
綜上所述,以上兩個方案都行不能,既然第三方組件行不通,那就只能自主實現了。json
經過參考一些開源項目的實現,自主實現分佈式session的關鍵點有如下幾點:緩存
爲了實現此功能,咱們定義以下幾個類:tomcat
類的具體實現以下:服務器
SessionFilter類cookie
/** * 該類實現了Filter */ public class SessionFilter implements Filter { /** redis的相關操做 */ @Autowired private RedisExtend redisExtend; @Override public void init(FilterConfig filterConfig) throws ServletException { } @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { //這裏將request轉換成自主實現的SessionRequestWrapper //通過傳遞後,項目中獲取到的request就是SessionRequestWrapper ServletRequest servletRequest = new SessionRequestWrapper((HttpServletRequest)request, (HttpServletResponse)response, redisExtend); chain.doFilter(servletRequest, response); } @Override public void destroy() { } }
ServletRequestWrap類session
/** * 該類繼承了HttpServletRequestWrapper並重寫了session相關類 * 以後項目中經過'request.getSession()'就是調用此類的getSession()方法了 */ public class SessionRequestWrapper extends HttpServletRequestWrapper { private final Logger log = LoggerFactory.getLogger(SessionRequestWrapper.class); /** 本來的requst,用來獲取原始的session */ private HttpServletRequest request; /** 原始的response,操做cookie會用到 */ private HttpServletResponse response; /** redis命令的操做類 */ private RedisExtend redisExtend; /** session的緩存,存在本機的內存中 */ private MemorySessionCache sessionCache; /** 自定義sessionId */ private String sid; public SessionRequestWrapper(HttpServletRequest request, HttpServletResponse response, RedisExtend redisExtend) { super(request); this.request = request; this.response = response; this.redisExtend = redisExtend; this.sid = getSsessionIdFromCookie(); this.sessionCache = MemorySessionCache.initAndGetInstance(request.getSession().getMaxInactiveInterval()); } /** * 獲取session的操做 */ @Override public HttpSession getSession(boolean create) { if (!create) { return null; } HttpSession httpSession = request.getSession(); try { return sessionCache.getSession(httpSession.getId(), new Callable<DistributionSession>() { @Override public DistributionSession call() throws Exception { return new DistributionSession(request, redisExtend, sessionCache, sid); } }); } catch (Exception e) { log.error("從sessionCache獲取session出錯:{}", ExceptionUtils.getStackTrace(e)); return new DistributionSession(request, redisExtend, sessionCache, sid); } return null; } @Override public HttpSession getSession() { return getSession(true); } /** * 從cookie裏獲取自定義sessionId,若是沒有,則建立一個 */ private String getSsessionIdFromCookie() { String sid = CookieUtil.getCookie(SessionUtil.SESSION_KEY, this); if (StringUtils.isEmpty(sid)) { sid = java.util.UUID.randomUUID().toString(); CookieUtil.setCookie(SessionUtil.SESSION_KEY, sid, this, response); this.setAttribute(SessionUtil.SESSION_KEY, sid); } return sid; } }
DistributionSession類
/* * 分佈式session的實現類,實現了session * 項目中由request.getSession()獲取到的session就是該類 */ public class DistributionSession implements HttpSession { private final Logger log = LoggerFactory.getLogger(DistributionSession.class); /** 自定義sessionId */ private String sid; /** 原始的session */ private HttpSession httpSession; /** redis操做類 */ private RedisExtend redisExtend; /** session的本地內存緩存 */ private MemorySessionCache sessionCache; /** 最後訪問時間 */ private final String LAST_ACCESSED_TIME = "lastAccessedTime"; /** 建立時間 */ private final String CREATION_TIME = "creationTime"; public DistributionSession(HttpServletRequest request, RedisExtend redisExtend, MemorySessionCache sessionCache, String sid) { this.httpSession = request.getSession(); this.sid = sid; this.redisExtend = redisExtend; this.sessionCache = sessionCache; if(this.isNew()) { this.setAttribute(CREATION_TIME, System.currentTimeMillis()); } this.refresh(); } @Override public String getId() { return this.sid; } @Override public ServletContext getServletContext() { return httpSession.getServletContext(); } @Override public Object getAttribute(String name) { byte[] content = redisExtend.hget(SafeEncoder.encode(SessionUtil.getSessionKey(sid)), SafeEncoder.encode(name)); if(ArrayUtils.isNotEmpty(content)) { try { return ObjectSerializerUtil.deserialize(content); } catch (Exception e) { log.error("獲取屬性值失敗:{}", ExceptionUtils.getStackTrace(e)); } } return null; } @Override public Enumeration<String> getAttributeNames() { byte[] data = redisExtend.get(SafeEncoder.encode(SessionUtil.getSessionKey(sid))); if(ArrayUtils.isNotEmpty(data)) { try { Map<String, Object> map = (Map<String, Object>) ObjectSerializerUtil.deserialize(data); return (new Enumerator(map.keySet(), true)); } catch (Exception e) { log.error("獲取全部屬性名失敗:{}", ExceptionUtils.getStackTrace(e)); } } return new Enumerator(new HashSet<String>(), true); } @Override public void setAttribute(String name, Object value) { if(null != name && null != value) { try { redisExtend.hset(SafeEncoder.encode(SessionUtil.getSessionKey(sid)), SafeEncoder.encode(name), ObjectSerializerUtil.serialize(value)); } catch (Exception e) { log.error("添加屬性失敗:{}", ExceptionUtils.getStackTrace(e)); } } } @Override public void removeAttribute(String name) { if(null == name) { return; } redisExtend.hdel(SafeEncoder.encode(SessionUtil.getSessionKey(sid)), SafeEncoder.encode(name)); } @Override public boolean isNew() { Boolean result = redisExtend.exists(SafeEncoder.encode(SessionUtil.getSessionKey(sid))); if(null == result) { return false; } return result; } @Override public void invalidate() { sessionCache.invalidate(sid); redisExtend.del(SafeEncoder.encode(SessionUtil.getSessionKey(sid))); } @Override public int getMaxInactiveInterval() { return httpSession.getMaxInactiveInterval(); } @Override public long getCreationTime() { Object time = this.getAttribute(CREATION_TIME); if(null != time) { return (Long)time; } return 0L; } @Override public long getLastAccessedTime() { Object time = this.getAttribute(LAST_ACCESSED_TIME); if(null != time) { return (Long)time; } return 0L; } @Override public void setMaxInactiveInterval(int interval) { httpSession.setMaxInactiveInterval(interval); } @Override public Object getValue(String name) { throw new NotImplementedException(); } @Override public HttpSessionContext getSessionContext() { throw new NotImplementedException(); } @Override public String[] getValueNames() { throw new NotImplementedException(); } @Override public void putValue(String name, Object value) { throw new NotImplementedException(); } @Override public void removeValue(String name) { throw new NotImplementedException(); } /** * 更新過時時間 * 根據session的過時規則,每次訪問時,都要更新redis的過時時間 */ public void refresh() { //更新最後訪問時間 this.setAttribute(LAST_ACCESSED_TIME, System.currentTimeMillis()); //刷新有效期 redisExtend.expire(SafeEncoder.encode(SessionUtil.getSessionKey(sid)), httpSession.getMaxInactiveInterval()); } /** * Enumeration 的實現 */ class Enumerator implements Enumeration<String> { public Enumerator(Collection<String> collection) { this(collection.iterator()); } public Enumerator(Collection<String> collection, boolean clone) { this(collection.iterator(), clone); } public Enumerator(Iterator<String> iterator) { super(); this.iterator = iterator; } public Enumerator(Iterator<String> iterator, boolean clone) { super(); if (!clone) { this.iterator = iterator; } else { List<String> list = new ArrayList<String>(); while (iterator.hasNext()) { list.add(iterator.next()); } this.iterator = list.iterator(); } } private Iterator<String> iterator = null; @Override public boolean hasMoreElements() { return (iterator.hasNext()); } @Override public String nextElement() throws NoSuchElementException { return (iterator.next()); } } }
由項目中的redis操做類RedisExtend
是由spring容器來實例化的,爲了能在DistributionSession
類中使用該實例,須要使用spring容器來實例化filter,在spring的配置文件中添加如下內容:
<!-- 分佈式 session的filter --> <bean id="sessionFilter" class="com.xxx.session.SessionFilter"></bean>
在web.xml中配置filter時,也要經過spring來管理:
<!-- 通常來講,該filter應該位於全部的filter以前。 --> <filter> <!-- spring實例化時的實例名稱 --> <filter-name>sessionFilter</filter-name> <!-- 採用spring代理來實現filter --> <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class> <init-param> <param-name>targetFilterLifecycle</param-name> <param-value>true</param-value> </init-param> </filter> <filter-mapping> <filter-name>sessionFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>
使用redis來管理session時,對象應該使用什麼序列化方式?首先,理所固然地想到使用json。咱們來看看json序列化時究竟行不行。
在項目中,往session設置值和從session中獲取值的操做分別以下:
/** 假設如今有一個user類,屬性有:name與age*/ User user = new User("a", 13); request.getSession().setAttribute("user", user); //經過如下方式獲取 User user = (User)request.getSession().getAttribute("user");
在DistributionSession
中實現setAttribute()
方法時,能夠採用以下方式:
public void setAttribute(String name, Object object) { String jsonString = JsonUtil.toJson(object); redisExtend.hset(this.sid, name, jsonString); }
但在getAttribute()
方法的實現上,json反序列化就無能爲力了:
public Object getAttribute(String name) { String jsonString = redisExtend.hget(this.sid, name); return JsonUtil.toObject(jsonString, Object.class); }
在json反序列化時,若是不指定類型,或指定爲Object時,json序列化就有問題了:
//這裏的object實際類型是JSONObject或Map,取決於使用的json工具包 Object object = request.getSession().getAttribute("user"); //在類型轉換時,這一句會報錯 User user = (User)object;
有個小哥哥就比較聰明,在序列化時,把參數的類型一併帶上了,如上面的json序列化成com.xxx.User:{"name":"a","age":13}
再保存到redis中,這樣在反序化時,先獲取到com.xxx.User
類,再來作json反序列:
String jsonString = redisExtend.hget(this.sid, name); String[] array = jsonString.split(":"); Class type = Class.forname(array[0]); Object obj = JsonUtil.toObject(array[1], type);
這樣確實能解決一部分問題,但若是反序列化參數中有泛型就無能爲力了!如今session存儲的屬性以下:
List<User> list = new ArrayList<>(); User user1 = new User("a", 13); User user2 = new User("b", 12); list.add(user1); list.add(user2); request.getSession().setAttribute("users", list);
這種狀況下,序列出來的json會這樣:
java.util.List:[{"name":"a","age":13}, {"name":"b","age":12}]
在反序列化時,會這樣:
Object obj = JsonUtil.toObject(array[1], List.class);
到這裏確實是沒問題的,但咱們能夠看到泛型信息丟失了,咱們在調用getAttribute()
時,會這樣調用:
//這裏的obj實現類型是List,至於List的泛型類型,是JSONObject或Map,取決於使用的json工具包 Object obj = request.getSession().getAttribute("users"); //若是這樣調用不用報錯:List users = (List)obj; //加上泛型值後,java編譯器會認爲是要把JSONObject或Map轉成User,仍是會致使類型轉換錯誤 List<User> users = (List)obj;
這一步就會出現問題了,緣由是在反序列化時,只傳了List,沒有指定List裏面放的是什麼對象,Json反序列化是按Object類型來處理的,前面提到fastJson會序列化成JSONObject,gson與jackson會序列化成Map
,直接強轉成User
必定會報錯。
爲了解決這個問題,這裏直接使用java的對象序列化方法:
public class ObjectSerializerUtil { /** * 序列化 * @param obj * @return * @throws IOException */ public static byte[] serialize(Object obj) throws IOException { byte[] bytes; ByteArrayOutputStream baos = null; ObjectOutputStream oos = null; try { baos = new ByteArrayOutputStream(); oos = new ObjectOutputStream(baos); oos.writeObject(obj); bytes = baos.toByteArray(); } finally { if(null != oos) { oos.close(); } if(null != baos) { baos.close(); } } return bytes; } /** * 反序列化 * @param bytes * @return * @throws IOException * @throws ClassNotFoundException */ public static Object deserialize(byte[] bytes) throws IOException, ClassNotFoundException { Object obj; ByteArrayInputStream bais = null; ObjectInputStream ois = null; try { bais = new ByteArrayInputStream(bytes); ois = new ObjectInputStream(bais); obj = ois.readObject(); } finally { if(null != ois) { ois.close(); } if(null != bais) { bais.close(); } } return obj; } }
session共享的關鍵就在於jessionId的處理了,正是cookie裏有了jessonId的存在,http纔會有所謂的登陸/註銷一說。對於jessionId,先提兩個問題:
對於第一個問題,jessionId是在服務端建立的,當用戶首次訪問時,服務端發現沒有傳jessionId,會在服務端分配一個jessionId,作一些初始化工做,並把jessionId返回到客戶端。客戶端收到後,會保存在cookie裏,下次請求時,會把這個jessionId傳過去,這樣當服務端再次接收到請求後,不知道該用戶以前已經訪問過了,不用再作初始化工做了。
若是客戶端的cookie裏存在了jessionId,是否是就不會再在服務端生成jessionId了呢?答案是不必定。當服務端接收到jessionId後,會判斷該jessionId是否由當前服務端建立,若是是,則使用此jessionId,不然會丟棄此jessionId而從新建立一個jessionId。
在集羣環境中,客戶端C第一次訪問了服務端的S1服務器,並建立了一個jessionId1,當下一次再訪問的時候,若是訪問到的是服務端的S2服務器,此時客戶端雖然上送了jessionId1,但S2服務器並不認,它會把C看成是首次訪問,並分配新的jessionId,這就意味着用戶須要從新登陸。這種情景下,使用jessionId來區分用戶就不太合理了。
爲了解決這個問題,這裏使用在cookie中保存自定義的sessionKey的形式來解決這個問題:
//完整代碼見第二部分SessionRequestWrapper類 private String getSsessionIdFromCookie() { String sid = CookieUtil.getCookie(SessionUtil.SESSION_KEY, this); if (StringUtils.isEmpty(sid)) { sid = java.util.UUID.randomUUID().toString(); CookieUtil.setCookie(SessionUtil.SESSION_KEY, sid, this, response); this.setAttribute(SessionUtil.SESSION_KEY, sid); } return sid; }
cookie的操做代碼以下:
CookieUtil類
public class CookieUtil { protected static final Log logger = LogFactory.getLog(CookieUtil.class); /** * 設置cookie</br> * * @param name * cookie名稱 * @param value * cookie值 * @param request * http請求 * @param response * http響應 */ public static void setCookie(String name, String value, HttpServletRequest request, HttpServletResponse response) { int maxAge = -1; CookieUtil.setCookie(name, value, maxAge, request, response); } /** * 設置cookie</br> * * @param name * cookie名稱 * @param value * cookie值 * @param maxAge * 最大生存時間 * @param request * http請求 * @param response * http響應 */ public static void setCookie(String name, String value, int maxAge, HttpServletRequest request, HttpServletResponse response) { String domain = request.getServerName(); setCookie(name, value, maxAge, domain, response); } public static void setCookie(String name, String value, int maxAge, String domain, HttpServletResponse response) { AssertUtil.assertNotEmpty(name, new NullPointerException("cookie名稱不能爲空.")); AssertUtil.assertNotNull(value, new NullPointerException("cookie值不能爲空.")); Cookie cookie = new Cookie(name, value); cookie.setDomain(domain); cookie.setMaxAge(maxAge); cookie.setPath("/"); response.addCookie(cookie); } /** * 獲取cookie的值</br> * * @param name * cookie名稱 * @param request * http請求 * @return cookie值 */ public static String getCookie(String name, HttpServletRequest request) { AssertUtil.assertNotEmpty(name, new NullPointerException("cookie名稱不能爲空.")); Cookie[] cookies = request.getCookies(); if (cookies == null) { return null; } for (int i = 0; i < cookies.length; i++) { if (name.equalsIgnoreCase(cookies[i].getName())) { return cookies[i].getValue(); } } return null; } /** * 刪除cookie</br> * * @param name * cookie名稱 * @param request * http請求 * @param response * http響應 */ public static void deleteCookie(String name, HttpServletRequest request, HttpServletResponse response) { AssertUtil.assertNotEmpty(name, new RuntimeException("cookie名稱不能爲空.")); CookieUtil.setCookie(name, "", -1, request, response); } /** * 刪除cookie</br> * * @param name * cookie名稱 * @param response * http響應 */ public static void deleteCookie(String name, String domain, HttpServletResponse response) { AssertUtil.assertNotEmpty(name, new NullPointerException("cookie名稱不能爲空.")); CookieUtil.setCookie(name, "", -1, domain, response); } }
這樣以後,項目中使用自定義sid來標識客戶端,而且自定義sessionKey的處理所有由本身處理,不會像jessionId那樣會判斷是否由當前服務端生成。
1)DistributionSession並不須要每次從新生成 在SessionRequestWrapper
類中,獲取session的方法以下:
@Override public HttpSession getSession(boolean create) { if (create) { HttpSession httpSession = request.getSession(); try { return sessionCache.getSession(httpSession.getId(), new Callable<DistributionSession>() { @Override public DistributionSession call() throws Exception { return new DistributionSession(request, redisExtend, sessionCache, sid); } }); } catch (Exception e) { log.error("從sessionCache獲取session出錯:{}", ExceptionUtils.getStackTrace(e)); return new DistributionSession(request, redisExtend, sessionCache, sid); } } else { return null; } }
這裏採用了緩存技術,使用sid做爲key來緩存DistributionSession
,若是不採用緩存,則獲取session的操做以下:
@Override public HttpSession getSession(boolean create) { return new DistributionSession(request, redisExtend, sessionCache, sid); }
若是同一sid屢次訪問同一服務器,並不須要每次都建立一個DistributionSession
,這裏就使用緩存來存儲這些DistributionSession
,這樣下次訪問時,就不用再次生成DistributionSession
對象了。
緩存類以下:
MemorySessionCache類
public class MemorySessionCache { private Cache<String, DistributionSession> cache; private static AtomicBoolean initFlag = new AtomicBoolean(false); /** * 初始化,並返回實例 * @param maxInactiveInterval * @return */ public static MemorySessionCache initAndGetInstance(int maxInactiveInterval) { MemorySessionCache sessionCache = getInstance(); //保證全局只初始化一次 if(initFlag.compareAndSet(false, true)) { sessionCache.cache = CacheBuilder.newBuilder() //考慮到並無多少用戶會同時在線,這裏將緩存數設置爲100,超過的值不保存在緩存中 .maximumSize(100) //多久未訪問,就清除 .expireAfterAccess(maxInactiveInterval, TimeUnit.SECONDS).build(); } return sessionCache; } /** * 獲取session * @param sid * @param callable * @return * @throws ExecutionException */ public DistributionSession getSession(String sid, Callable<DistributionSession> callable) throws ExecutionException { DistributionSession session = getInstance().cache.get(sid, callable); session.refresh(); return session; } /** * 將session從cache中刪除 * @param sid */ public void invalidate(String sid) { getInstance().cache.invalidate(sid); } /** * 單例的內部類實現方式 */ private MemorySessionCache() { } private static class MemorySessionCacheHolder { private static final MemorySessionCache singletonPattern = new MemorySessionCache(); } private static MemorySessionCache getInstance() { return MemorySessionCacheHolder.singletonPattern; } }
總結:使用redis自主實現session共享,關鍵點有三個: