Spring-Session基於Redis管理Session
在上文Tomcat Session管理分析介紹了使用tomcat-redis-session-manager來集中式管理session,其中一個侷限性就是必須使用tomcat容器;本文介紹的spring-session也能實現session的集中式管理,並且不侷限於某種容器;
spring-session管理session實戰
1.maven依賴的jar
<dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> <version>1.3.1.RELEASE</version> <type>pom</type> </dependency> <dependency> <groupId>biz.paluch.redis</groupId> <artifactId>lettuce</artifactId> <version>3.5.0.Final</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-web</artifactId> <version>4.3.4.RELEASE</version> </dependency>
2.準備spring-session.xml配置檔案
<?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:context="http://www.springframework.org/schema/context" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd"> <!--支援註解 --> <context:annotation-config /> <bean class="org.springframework.session.data.redis.config.annotation.web.http.RedisHttpSessionConfiguration" /> <bean class="org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory"> <property name="hostName" value="localhost" /> <property name="port" value="6379" /> </bean> </beans>
session同樣是使用redis來做集中式儲存,為了方便測試使用本地的6379埠redis,LettuceConnectionFactory是redis連線工廠類;
RedisHttpSessionConfiguration可以簡單理解為spring-session使用redis來儲存session的功能類,此類本身使用了@Configuration註解,@Configuration註解相當於把該類作為spring的xml配置檔案中的,此類中包含了很多bean物件同樣也是註解@Bean;
3.準備servelt類
public class SSessionTest extends HttpServlet { private static final long serialVersionUID = 1L; public SSessionTest() { super(); } protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { response.getWriter().append("sessionId=" + request.getSession().getId()); } protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { doGet(request, response); } }
定義了一個簡單的servelt,每次請求都在介面列印sessionId;
4.配置web.xml
<?xml version="1.0" encoding="UTF-8"?>
<web-app>
<display-name>Archetype Created Web Application</display-name>
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>classpath*:spring-session.xml</param-value>
</context-param>
<filter>
<filter-name>springSessionRepositoryFilter</filter-name>
<filter-class>org.springframework.web.filter.DelegatingFilterProxy
</filter-class>
</filter>
<filter-mapping>
<filter-name>springSessionRepositoryFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<listener>
<listener-class>org.springframework.web.context.ContextLoaderListener
</listener-class>
</listener>
<servlet>
<servlet-name>SSessionTest</servlet-name>
<display-name>SSessionTest</display-name>
<description></description>
<servlet-class>zh.maven.ssesion.SSessionTest</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>SSessionTest</servlet-name>
<url-pattern>/SSessionTest</url-pattern>
</servlet-mapping>
</web-app>
首先配置了載入類路徑下的spring-session.xml配置檔案,然後配置了一個名稱為springSessionRepositoryFilter的過濾器;這裡定義的class是類DelegatingFilterProxy,此類本身並不是過濾器,是一個代理類,可以通過使用targetBeanName引數來指定具體的過濾器類(如下所示),如果不指定預設就是filter-name指定的名稱;
<init-param>
<param-name>targetBeanName</param-name>
<param-value>springSessionRepositoryFilter</param-value>
</init-param>
5.測試
瀏覽器中訪問:http://localhost:8080/ssession/SSessionTest,檢視結果:
sessionId=d520abed-829f-4d0d-9b51-5e9bc9c7e7f2
檢視redis
127.0.0.1:6379> keys *
1) "spring:session:expirations:1530194760000"
2) "spring:session:sessions:expires:d520abed-829f-4d0d-9b51-5e9bc9c7e7f2"
3) "spring:session:sessions:d520abed-829f-4d0d-9b51-5e9bc9c7e7f2"
6.常見問題
具體異常如下:
org.springframework.beans.factory.NoSuchBeanDefinitionException: No bean named 'springSessionRepositoryFilter' available
at org.springframework.beans.factory.support.DefaultListableBeanFactory.getBeanDefinition(DefaultListableBeanFactory.java:680)
at org.springframework.beans.factory.support.AbstractBeanFactory.getMergedLocalBeanDefinition(AbstractBeanFactory.java:1183)
at org.springframework.beans.factory.support.AbstractBeanFactory.doGetBean(AbstractBeanFactory.java:284)
at org.springframework.beans.factory.support.AbstractBeanFactory.getBean(AbstractBeanFactory.java:202)
at org.springframework.context.support.AbstractApplicationContext.getBean(AbstractApplicationContext.java:1087)
at org.springframework.web.filter.DelegatingFilterProxy.initDelegate(DelegatingFilterProxy.java:326)
at org.springframework.web.filter.DelegatingFilterProxy.initFilterBean(DelegatingFilterProxy.java:235)
at org.springframework.web.filter.GenericFilterBean.init(GenericFilterBean.java:199)
at org.apache.catalina.core.ApplicationFilterConfig.initFilter(ApplicationFilterConfig.java:285)
at org.apache.catalina.core.ApplicationFilterConfig.getFilter(ApplicationFilterConfig.java:266)
at org.apache.catalina.core.ApplicationFilterConfig.<init>(ApplicationFilterConfig.java:108)
at org.apache.catalina.core.StandardContext.filterStart(StandardContext.java:4981)
at org.apache.catalina.core.StandardContext.startInternal(StandardContext.java:5683)
at org.apache.catalina.util.LifecycleBase.start(LifecycleBase.java:145)
at org.apache.catalina.core.ContainerBase$StartChild.call(ContainerBase.java:1702)
at org.apache.catalina.core.ContainerBase$StartChild.call(ContainerBase.java:1692)
at java.util.concurrent.FutureTask.run(FutureTask.java:262)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1145)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:615)
at java.lang.Thread.run(Thread.java:745)
指定的filter找不到實現類,原因是沒有使用配置,此配置可以讓系統能夠識別相應的註解,而在類RedisHttpSessionConfiguration中使用了大量的註解,其中就有個使用@Bean註解的方法;
spring-session管理session分析
1.DelegatingFilterProxy代理類
DelegatingFilterProxy裡沒有實現過濾器的任何邏輯,具體邏輯在其指定的filter-name過濾器中;
@Override
protected void initFilterBean() throws ServletException {
synchronized (this.delegateMonitor) {
if (this.delegate == null) {
// If no target bean name specified, use filter name.
if (this.targetBeanName == null) {
this.targetBeanName = getFilterName();
}
// Fetch Spring root application context and initialize the delegate early,
// if possible. If the root application context will be started after this
// filter proxy, we'll have to resort to lazy initialization.
WebApplicationContext wac = findWebApplicationContext();
if (wac != null) {
this.delegate = initDelegate(wac);
}
}
}
}
初始化過濾器,如果沒有配置targetBeanName,則直接使用filter-name,這裡指定的是springSessionRepositoryFilter,這個名稱是一個固定值此filter在RedisHttpSessionConfiguration中被定義;
2.RedisHttpSessionConfiguration配置類
在RedisHttpSessionConfiguration的父類SpringHttpSessionConfiguration中定義了springSessionRepositoryFilter
@Bean
public <S extends ExpiringSession> SessionRepositoryFilter<? extends ExpiringSession> springSessionRepositoryFilter(
SessionRepository<S> sessionRepository) {
SessionRepositoryFilter<S> sessionRepositoryFilter = new SessionRepositoryFilter<S>(
sessionRepository);
sessionRepositoryFilter.setServletContext(this.servletContext);
if (this.httpSessionStrategy instanceof MultiHttpSessionStrategy) {
sessionRepositoryFilter.setHttpSessionStrategy(
(MultiHttpSessionStrategy) this.httpSessionStrategy);
}
else {
sessionRepositoryFilter.setHttpSessionStrategy(this.httpSessionStrategy);
}
return sessionRepositoryFilter;
}
此方法返回值是SessionRepositoryFilter,這個其實就是真實的過濾器;方法引數sessionRepository同樣使用@Bean註解的方式定義;
@Bean
public RedisOperationsSessionRepository sessionRepository(
@Qualifier("sessionRedisTemplate") RedisOperations<Object, Object> sessionRedisTemplate,
ApplicationEventPublisher applicationEventPublisher) {
RedisOperationsSessionRepository sessionRepository = new RedisOperationsSessionRepository(
sessionRedisTemplate);
sessionRepository.setApplicationEventPublisher(applicationEventPublisher);
sessionRepository
.setDefaultMaxInactiveInterval(this.maxInactiveIntervalInSeconds);
if (this.defaultRedisSerializer != null) {
sessionRepository.setDefaultSerializer(this.defaultRedisSerializer);
}
String redisNamespace = getRedisNamespace();
if (StringUtils.hasText(redisNamespace)) {
sessionRepository.setRedisKeyNamespace(redisNamespace);
}
sessionRepository.setRedisFlushMode(this.redisFlushMode);
return sessionRepository;
}
此方法的返回值是RedisOperationsSessionRepository,有關於session持久化到redis的相關操作都在此類中;
注:持久化到redis只是spring-session的一種方式,也支援持久化到其他資料庫中(jdbc,Mongo,Hazelcast等);
3.SessionRepositoryFilter過濾器
所有的請求都會先經過SessionRepositoryFilter過濾器,doFilter方法如下:
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);
SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryRequestWrapper(
request, response, this.servletContext);
SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryResponseWrapper(
wrappedRequest, response);
HttpServletRequest strategyRequest = this.httpSessionStrategy
.wrapRequest(wrappedRequest, wrappedResponse);
HttpServletResponse strategyResponse = this.httpSessionStrategy
.wrapResponse(wrappedRequest, wrappedResponse);
try {
filterChain.doFilter(strategyRequest, strategyResponse);
}
finally {
wrappedRequest.commitSession();
}
}
request被包裝成了SessionRepositoryRequestWrapper物件,response被包裝成了SessionRepositoryResponseWrapper物件,SessionRepositoryRequestWrapper中重寫了getSession等方法;finally中執行了commitSession方法,將session進行持久化操作;
4.SessionRepositoryRequestWrapper包裝類
重點看一下重寫的getSession方法,程式碼如下:
@Override
public HttpSessionWrapper getSession(boolean create) {
HttpSessionWrapper currentSession = getCurrentSession();
if (currentSession != null) {
return currentSession;
}
String requestedSessionId = getRequestedSessionId();
if (requestedSessionId != null
&& getAttribute(INVALID_SESSION_ID_ATTR) == null) {
S session = getSession(requestedSessionId);
if (session != null) {
this.requestedSessionIdValid = true;
currentSession = new HttpSessionWrapper(session, getServletContext());
currentSession.setNew(false);
setCurrentSession(currentSession);
return currentSession;
}
else {
// This is an invalid session id. No need to ask again if
// request.getSession is invoked for the duration of this request
if (SESSION_LOGGER.isDebugEnabled()) {
SESSION_LOGGER.debug(
"No session found by id: Caching result for getSession(false) for this HttpServletRequest.");
}
setAttribute(INVALID_SESSION_ID_ATTR, "true");
}
}
if (!create) {
return null;
}
if (SESSION_LOGGER.isDebugEnabled()) {
SESSION_LOGGER.debug(
"A new session was created. To help you troubleshoot where the session was created we provided a StackTrace (this is not an error). You can prevent this from appearing by disabling DEBUG logging for "
+ SESSION_LOGGER_NAME,
new RuntimeException(
"For debugging purposes only (not an error)"));
}
S session = SessionRepositoryFilter.this.sessionRepository.createSession();
session.setLastAccessedTime(System.currentTimeMillis());
currentSession = new HttpSessionWrapper(session, getServletContext());
setCurrentSession(currentSession);
return currentSession;
}
private S getSession(String sessionId) {
S session = SessionRepositoryFilter.this.sessionRepository
.getSession(sessionId);
if (session == null) {
return null;
}
session.setLastAccessedTime(System.currentTimeMillis());
return session;
}
大致分為三步,首先去本地記憶體中獲取session,如果獲取不到去指定的資料庫中獲取,這裡其實就是去redis裡面獲取,sessionRepository就是上面定義的RedisOperationsSessionRepository物件;如果redis裡面也沒有則建立一個新的session;
5.RedisOperationsSessionRepository類
關於session的儲存,更新,刪除,獲取操作都在此類中;
5.1儲存session
每次在訊息處理完之後,會執行finally中的commitSession方法,每個session被儲存都會建立三組資料,如下所示:
127.0.0.1:6379> keys *
1) "spring:session:expirations:1530254160000"
2) "spring:session:sessions:expires:d5e0f376-69d1-4fd4-9802-78eb5a3db144"
3) "spring:session:sessions:d5e0f376-69d1-4fd4-9802-78eb5a3db144"
hash結構記錄
key格式:spring:session:sessions:[sessionId],對應的value儲存session的所有資料包括:creationTime,maxInactiveInterval,lastAccessedTime,attribute;
set結構記錄
key格式:spring:session:expirations:[過期時間],對應的value為expires:[sessionId]列表,有效期預設是30分鐘,即1800秒;
string結構記錄
key格式:spring:session:sessions:expires:[sessionId],對應的value為空;該資料的TTL表示sessionId過期的剩餘時間;
相關程式碼如下:
public void onExpirationUpdated(Long originalExpirationTimeInMilli,
ExpiringSession session) {
String keyToExpire = "expires:" + session.getId();
long toExpire = roundUpToNextMinute(expiresInMillis(session));
if (originalExpirationTimeInMilli != null) {
long originalRoundedUp = roundUpToNextMinute(originalExpirationTimeInMilli);
if (toExpire != originalRoundedUp) {
String expireKey = getExpirationKey(originalRoundedUp);
this.redis.boundSetOps(expireKey).remove(keyToExpire);
}
}
long sessionExpireInSeconds = session.getMaxInactiveIntervalInSeconds();
String sessionKey = getSessionKey(keyToExpire);
if (sessionExpireInSeconds < 0) {
this.redis.boundValueOps(sessionKey).append("");
this.redis.boundValueOps(sessionKey).persist();
this.redis.boundHashOps(getSessionKey(session.getId())).persist();
return;
}
String expireKey = getExpirationKey(toExpire);
BoundSetOperations<Object, Object> expireOperations = this.redis
.boundSetOps(expireKey);
expireOperations.add(keyToExpire);
long fiveMinutesAfterExpires = sessionExpireInSeconds
+ TimeUnit.MINUTES.toSeconds(5);
expireOperations.expire(fiveMinutesAfterExpires, TimeUnit.SECONDS);
if (sessionExpireInSeconds == 0) {
this.redis.delete(sessionKey);
}
else {
this.redis.boundValueOps(sessionKey).append("");
this.redis.boundValueOps(sessionKey).expire(sessionExpireInSeconds,
TimeUnit.SECONDS);
}
this.redis.boundHashOps(getSessionKey(session.getId()))
.expire(fiveMinutesAfterExpires, TimeUnit.SECONDS);
}
static long expiresInMillis(ExpiringSession session) {
int maxInactiveInSeconds = session.getMaxInactiveIntervalInSeconds();
long lastAccessedTimeInMillis = session.getLastAccessedTime();
return lastAccessedTimeInMillis + TimeUnit.SECONDS.toMillis(maxInactiveInSeconds);
}
static long roundUpToNextMinute(long timeInMs) {
Calendar date = Calendar.getInstance();
date.setTimeInMillis(timeInMs);
date.add(Calendar.MINUTE, 1);
date.clear(Calendar.SECOND);
date.clear(Calendar.MILLISECOND);
return date.getTimeInMillis();
}
getMaxInactiveIntervalInSeconds預設是1800秒,expiresInMillis返回了一個到期的時間戳;roundUpToNextMinute方法在此基礎上添加了1分鐘,並且清除了秒和毫秒,返回的long值被用來當做key,用來記錄一分鐘內應當過期的key列表,也就是上面的set結構記錄;
後面的程式碼分別為以上三個key值指定了有效期,spring:session:sessions:expires是30分鐘,而另外2個都是35分鐘;
理論上只需要為spring:session:sessions:[sessionId]指定有效期就行了,為什麼還要再儲存兩個key,官方的說法是依賴redis自身提供的有效期並不能保證及時刪除;
5.2定期刪除
除了依賴redis本身的有效期機制,spring-session提供了一個定時器,用來定期檢查需要被清理的session;
@Scheduled(cron = "${spring.session.cleanup.cron.expression:0 * * * * *}")
public void cleanupExpiredSessions() {
this.expirationPolicy.cleanExpiredSessions();
}
public void cleanExpiredSessions() {
long now = System.currentTimeMillis();
long prevMin = roundDownMinute(now);
if (logger.isDebugEnabled()) {
logger.debug("Cleaning up sessions expiring at " + new Date(prevMin));
}
String expirationKey = getExpirationKey(prevMin);
Set<Object> sessionsToExpire = this.redis.boundSetOps(expirationKey).members();
this.redis.delete(expirationKey);
for (Object session : sessionsToExpire) {
String sessionKey = getSessionKey((String) session);
touch(sessionKey);
}
}
/**
* By trying to access the session we only trigger a deletion if it the TTL is
* expired. This is done to handle
* https://github.com/spring-projects/spring-session/issues/93
*
* @param key the key
*/
private void touch(String key) {
this.redis.hasKey(key);
}
同樣是通過roundDownMinute方法來獲取key,獲取這一分鐘內要被刪除的session,此value是set資料結構,裡面存放這需要被刪除的sessionId;
(注:這裡面存放的的是spring:session:sessions:expires:[sessionId],並不是實際儲存session資料的spring:session:sessions:[sessionId])
首先刪除了spring:session:expirations:[過期時間],然後遍歷set執行touch方法,並沒有直接執行刪除操作,看touch方法的註釋大致意義就是嘗試訪問一下key,如果key已經過去則觸發刪除操作,利用了redis本身的特性;
5.3鍵空間通知(keyspace notification)
定期刪除機制並沒有刪除實際儲存session資料的spring:session:sessions:[sessionId],這裡利用了redis的keyspace notification功能,大致就是通過命令產生一個通知,具體什麼命令可以配置(包括:刪除,過期等)具體可以檢視:http://redisdoc.com/topic/not...;
spring-session的keyspace notification配置在ConfigureNotifyKeyspaceEventsAction類中,RedisOperationsSessionRepository負責接收訊息通知,具體程式碼如下:
public void onMessage(Message message, byte[] pattern) {
byte[] messageChannel = message.getChannel();
byte[] messageBody = message.getBody();
if (messageChannel == null || messageBody == null) {
return;
}
String channel = new String(messageChannel);
if (channel.startsWith(getSessionCreatedChannelPrefix())) {
// TODO: is this thread safe?
Map<Object, Object> loaded = (Map<Object, Object>) this.defaultSerializer
.deserialize(message.getBody());
handleCreated(loaded, channel);
return;
}
String body = new String(messageBody);
if (!body.startsWith(getExpiredKeyPrefix())) {
return;
}
boolean isDeleted = channel.endsWith(":del");
if (isDeleted || channel.endsWith(":expired")) {
int beginIndex = body.lastIndexOf(":") + 1;
int endIndex = body.length();
String sessionId = body.substring(beginIndex, endIndex);
RedisSession session = getSession(sessionId, true);
if (logger.isDebugEnabled()) {
logger.debug("Publishing SessionDestroyedEvent for session " + sessionId);
}
cleanupPrincipalIndex(session);
if (isDeleted) {
handleDeleted(sessionId, session);
}
else {
handleExpired(sessionId, session);
}
return;
}
}
接收已spring:session:sessions:expires開頭的通知,然後截取出sessionId,然後通過sessionId刪除實際儲存session的資料;
此處有個疑問就是為什麼要引入spring:session:sessions:expires:[sessionId]型別key,spring:session:expirations的value直接儲存spring:session:sessions:[sessionId]不就可以了嗎,這裡使用此key的目的可能是讓有效期和實際的資料分開,如果不這樣有地方監聽到session過期,而此時session已經被移除,導致獲取不到session的內容;並且在上面設定有效期的時候,spring:session:sessions:[sessionId]的有效期多了5分鐘,應該也是為了這個考慮的;
總結
比起之前介紹的tomcat-redis-session-manager來管理session,spring-session引入了更多的鍵值,並且還引入了定時器,這無疑增加了複雜性和額外的開銷,實際專案具體使用哪種方式還需要權衡一下。