spring+redis自主實現分散式session(非spring-session方式)
背景:最近對一個老專案進行改造,使其支援多機部署,其中最關鍵的一點就是實現多機session共享。專案有多老呢,jdk版本是1.6,spring版本是3.2,jedis版本是2.2。
1.方案的確定
接到這專案任務後,理所當然地google了,一搜索,發現結果分為兩大類:
- tomcat的session管理
- spring-session
對於“tomcat的session管理”,很不幸,線上程式碼用的是resin,直接pass了;
對於“spring-session”,這是spring全家桶系列,可以很方便擴充套件session管理功能,並且原始碼一行不用改。但是,引入過程中發生了意外:專案中使用的jedis版本不支援,而spring-session引入的jedis高於專案中使用的redis版本!舉例來說,"set PX/EX NX/XX"命令,專案中使用的redis是不支援的,但spring-session引入的jedis支援,直接引入風險不可控。如果相升級專案中的redis版本的話,代價就比較高了。
綜上所述,以上兩個方案都行不能,只能自己來寫了。
翻閱一些資料,確認方案如下:
- 使用servlet的filter功能來接管session;
- 使用redis來管理全域性session
2.使用filter來接管session
為了實現此功能,我們定義如下幾個類:
- SessionFilter:servlet的過濾器,替換原始的session
- DistributionSession:分散式session,實現了HttpSession類;
- SessionRequestWrapper:在filter中用來接管session;
類的具體實現如下:
SessionFilter類
/** * 該類實現了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類
/** * 該類繼承了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) { 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; } } @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>
3.全域性的session管理:redis
使用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()
方法的實現上,就無能為力了:
public Object getAttribute(String name) {
String jsonString = redisExtend.hget(this.sid, name);
//這一句一定有問題
return JsonUtil.toObject(jsonString, Object.class);
}
在json反序列化時,如果不指定型別,或指定為Object時,json序列化就有問題了:
- fastJson會序列化成JSONObject
- gson與jackson會序列化成Map
這時再來做型別轉換時,就會報錯。
有個小哥哥就比較聰明,在序列化時,把引數的型別一併帶上了,如上面的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()
時,會這樣呼叫:
List<User> users = (List)request.getSession().getAttribute("users");
這一步就會出現問題了,原因是在反序列化時,只傳了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;
}
}
4.jessionId的處理
session共享的關鍵就在於jessionId的處理了,正是cookie裡有了jessonId的存在,http才會有所謂的登入/登出一說。對於jessionId,先提兩個問題:
- jessionId是由客戶端生成還是由服務端生成的?
- 如果客戶端傳了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那樣會判斷是否由當前服務端生成。
5.進一步優化
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共享,關鍵點有三個:
- 使用filter來接管全域性session;
- 將java物件序列化成二進位制資料儲存到redis,反序列化時也使用java物件反序列化方式;
- 原始的jessionId可能會丟棄並重新生成,需要自主操作cookie重新定義sessionKey.