1. 程式人生 > >spring+redis自主實現分散式session(非spring-session方式)

spring+redis自主實現分散式session(非spring-session方式)

背景:最近對一個老專案進行改造,使其支援多機部署,其中最關鍵的一點就是實現多機session共享。專案有多老呢,jdk版本是1.6,spring版本是3.2,jedis版本是2.2。

1.方案的確定

接到這專案任務後,理所當然地google了,一搜索,發現結果分為兩大類:

  1. tomcat的session管理
  2. 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,先提兩個問題:

  1. jessionId是由客戶端生成還是由服務端生成的?
  2. 如果客戶端傳了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共享,關鍵點有三個:

  1. 使用filter來接管全域性session;
  2. 將java物件序列化成二進位制資料儲存到redis,反序列化時也使用java物件反序列化方式;
  3. 原始的jessionId可能會丟棄並重新生成,需要自主操作cookie重新定義sessionKey.