Simple Session
Simple Session 是Spring Session
的简单实现版,目的是学习Spring Session
源码的同时,创建一个更容易使用和理解的框架,功能设计上达到够用就好,简单实现也是为了能根据自己的业务需求方便进行定制。
本文介绍Spring Session
的源代码实现细节,以及介绍Simple Session
的区别。
1. 替代Session的秘密
几乎所有的方案都类似,使用Filter
把请求拦截掉然后包装Request
和Response
使得Request.getSession
返回的Session
也是包装过的,改变了原有Session
的行为,譬如存储属性值是把属性值存储在 Redis 中,这样就实现了分布式Session
了。
SpringSession
使用SessionRepositoryFilter
这个过滤器来实现上面所说的。
SimpleSession
使用SimpleSessionFilter
来实现。
1.1 SessionRepositoryFilter
//包装
HttpServletRequest strategyRequest = this.httpSessionStrategy
.wrapRequest(wrappedRequest, wrappedResponse);
//再包装
HttpServletResponse strategyResponse = this.httpSessionStrategy
.wrapResponse(wrappedRequest, wrappedResponse);
try {
filterChain.doFilter(strategyRequest, strategyResponse);
}
finally {
// 提交
wrappedRequest.commitSession();
}
包装的类是SessionRepositoryResponseWrapper
和SessionRepositoryRequestWrapper
对应Response
和Request
。
1.2 SessionRepositoryResponseWrapper
继承自OnCommittedResponseWrapper
主要目标就是一个,当Response
输出完毕后调用commit
。
@Override
protected void onResponseCommitted() {
this.request.commitSession();
}
1.3 SessionRepositoryRequestWrapper
这个类功能比较多,因为要改变原有很多跟Session
的接口,譬如getSession
、isRequestedSessionIdValid
等。
当然最重要的是getSession
方法,返回的Session
是经包装的。
1.3.1 getSession
@Override
public HttpSessionWrapper getSession(boolean create) {
// currentSession 是存在 request 的 attribute 中
HttpSessionWrapper currentSession = getCurrentSession();
// 存在即返回
if (currentSession != null) {
return currentSession;
}
// 获取请求的 sessionId, Cookie策略的话从cookie里拿, header策略的话在 Http Head 中获取
String requestedSessionId = getRequestedSessionId();
// 如果获取到,并且没有‘sessionId失效’标识
if (requestedSessionId != null
&& getAttribute(INVALID_SESSION_ID_ATTR) == null) {
// 这里是从 repository 中读取,如 RedisRepository
S session = getSession(requestedSessionId);
// 读取到了就恢复出session
if (session != null) {
this.requestedSessionIdValid = true;
currentSession = new HttpSessionWrapper(session, getServletContext());
currentSession.setNew(false);
setCurrentSession(currentSession);
return currentSession;
}
// 没有读取到(过期了), 设置‘失效’标识, 下次不用再去 repository 中读取
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(Instant.now());
currentSession = new HttpSessionWrapper(session, getServletContext());
setCurrentSession(currentSession);
return currentSession;
}
这里涉及到Session
和Repository
下面介绍。
1.3.2 commitSession
由于现在的Session
跟之前的已经完全不同,存储属性值更新属性值都是远程操作,使用‘懒操作’模式可以使得频繁的操作更加有效率。
private void commitSession() {
HttpSessionWrapper wrappedSession = getCurrentSession();
// 如果没有session,并且已经被标记为失效时,调用 onInvalidateSession 进行通知处理
if (wrappedSession == null) {
if (isInvalidateClientSession()) {
SessionRepositoryFilter.this.httpSessionStrategy
.onInvalidateSession(this, this.response);
}
}
// 如果存在就更新属性值
else {
S session = wrappedSession.getSession();
SessionRepositoryFilter.this.sessionRepository.save(session);
// 如果请求的sessionId跟当前的session的id不同,或者请求的sessionId无效,
// 则调用 onNewSession 进行通知处理
if (!isRequestedSessionIdValid()
|| !session.getId().equals(getRequestedSessionId())) {
SessionRepositoryFilter.this.httpSessionStrategy.onNewSession(session,
this, this.response);
}
}
}
这里onInvalidateSession
和onNewSession
都是Strategy
的方法,根据不一样的策略采取的处理也不一样。
Strategy
有:
CookieHttpSessionStrategy
HeaderHttpSessionStrategy
1.4 Session
SpringSession
的 session 有两部分组成:
-
Session
: 接口, 默认实现类MapSession
, 它是session的本地对象,存储着属性及一些特征(如:lastAccessedTime
), 最终会被同步到远端, 以及从远端获取下来后存储在本地的实体。 -
HttpSessionAdapter
: 为了能让Session
跟 HttpSession 接洽起来而设立的适配器。
1.5 Repository
public interface SessionRepository<S extends Session> {
// 创建session
S createSession();
// 保存session
void save(S session);
// 根据sessionId 获取
S findById(String id);
// 删除特定id的session值
void deleteById(String id);
}
各种实现,最典型用得最多的就是RedisOperationsSessionRepository
, 下面整个第2章(Spring Session Redis存储结构)就是讲整个类存储的策略和设计。
2. Spring Session Redis存储结构
session在存储时分为:
- session本身的一些属性存储
- 专门负责用于过期的key存储
- 以时间为key存储在该时间点需要过期的sessionId列表
2.1 为什么需要三个存储结构?
先说明第二存储是用来干嘛的,第二存储一般设置成session的过期时间如30分钟或者15分钟,同时session的客户端会注册一个redis的key过期事件的监听,一旦有key过期客户端有会事件响应和处理。
在处理事件时可能会需要该session的信息,这时候第一个存储就有用了,因此第一个存储的过期时间会比第二存储过期时间多1-3min,这就是为什么需要把属性存储和过期分开的原因。
那第三个session的用处呢?对Redis
比较熟悉的同学一定会知道其中的奥秘,因为Redis
的key过期方式是定期随机测试是否过期和获取时测试是否过期(也称懒删除),由于定期随机测试Task的优先级是比较低的,所以即便这个key已经过期但是没有测试到所以不会触发key过期的事件。所以,第三个存储的意义在于,存储了什么时间点会过期的session,这样可以去主动请求来触发懒删除,以此触发过期事件。
2.2 Redis 三个key和存储结构
- Session主内容存储,key:
spring:session:sessions:{SID}
,内容:Map
,key : value
- 过期存储,key:
spring:session:sessions:expires:{UUID}
,内容为空 - 过期sessionId列表存储,key:
spring:session:expirations:{ExpiryTime}
,内容Set
2.3 运行方式
因为第二种key
的存在,所以会自动失效并且发出事件,但是有延迟,所以有个定时任务在不停地扫描当前分钟过期的key
,即扫描第三种key
,一旦扫描到就进行删除。
相应事件的程序会把第一种key
删除。
2.4 代码细节
2.4.1 更新失效时间
// 更新失效时间
public void onExpirationUpdated(Long originalExpirationTimeInMilli, Session session) {
// NO.2 key
String keyToExpire = "expires:" + session.getId();
// 往后推迟 lastAccessTime + MaxInactiveInterval
long toExpire = roundUpToNextMinute(expiresInMillis(session));
// 原来的NO.3 key 清除掉
if (originalExpirationTimeInMilli != null) {
long originalRoundedUp = roundUpToNextMinute(originalExpirationTimeInMilli);
if (toExpire != originalRoundedUp) {
String expireKey = getExpirationKey(originalRoundedUp);
this.redis.boundSetOps(expireKey).remove(keyToExpire);
}
}
long sessionExpireInSeconds = session.getMaxInactiveInterval().getSeconds();
String sessionKey = getSessionKey(keyToExpire);
// MaxInactiveInterval < 0 Session永久有效
if (sessionExpireInSeconds < 0) {
this.redis.boundValueOps(sessionKey).append("");
this.redis.boundValueOps(sessionKey).persist();
this.redis.boundHashOps(getSessionKey(session.getId())).persist();
return;
}
// 拼装NO.3 key
String expireKey = getExpirationKey(toExpire);
BoundSetOperations<Object, Object> expireOperations = this.redis
.boundSetOps(expireKey);
expireOperations.add(keyToExpire);
long fiveMinutesAfterExpires = sessionExpireInSeconds
+ TimeUnit.MINUTES.toSeconds(5);
// NO.3 key 过期时间是自身时间+5分钟
expireOperations.expire(fiveMinutesAfterExpires, TimeUnit.SECONDS);
// sessionKey -> NO.2 key
if (sessionExpireInSeconds == 0) {
this.redis.delete(sessionKey);
}
else {
this.redis.boundValueOps(sessionKey).append("");
this.redis.boundValueOps(sessionKey)
.expire(sessionExpireInSeconds, TimeUnit.SECONDS);
}
// NO.1 也是过期时间推迟5min
this.redis.boundHashOps(getSessionKey(session.getId()))
.expire(fiveMinutesAfterExpires, TimeUnit.SECONDS);
}
2.4.2 Redis事件监听
当 Redis key 过期会往两个频道发布事件,一个是expired
频道的key事件,一个是key频道的expired
事件。(不过需要开启这个功能)
PUBLISH __keyspace@0__:key expired
PUBLISH __keyspace@0__:expired key
下面是Spring Session
中 Redis 的事件监听。
container.addMessageListener(messageListener,
Arrays.asList(new PatternTopic("__keyevent@*:del"),
new PatternTopic("__keyevent@*:expired")));
container.addMessageListener(messageListener, Arrays.asList(new PatternTopic(messageListener.getSessionCreatedChannelPrefix() + "*")));
事件处理:
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);
// 新建Session
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;
}
// 删除及过期Session
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);
}
// 清楚登录用户关联的sessionId,如: userId -> set(sessionId)
cleanupPrincipalIndex(session);
if (isDeleted) {
handleDeleted(sessionId, session);
}
else {
handleExpired(sessionId, session);
}
return;
}
}
其中,handleCreate
、handleDeleted
及handleExpired
都是用于发布Spring Context
的本地事件的。
3 Simple Session 简化和优化
3.1 Session存储使用Map
Session主要内容在Redis中存储采用Map
结构可以优化读写性能,因为绝大多数属性属于写少读多,如果采用整体做序列化的方式,每次都是整存整取,对于session多个属性操作性能会略快,如果操作属性比较少(如一个)那么性能上会略慢,但整体上讲不会对应用构成瓶颈。
3.2 修改更新
Spring Session
将对session的修改,如创建、销毁以及put属性都做成了在请求最后(Response Commit
)再一起保存到 Redis,期间随便操作多少次都不会更新到 Redis 中,这样确实减少了对 Redis 的操作,只要是多于一次的都是优化。(也可以设置成每次操作都进行更新)
但是有个问题,如果 response 已经commit
了,这时候再修改session,值将不会更新到Redis,这个也算不足。
Simple Session
对值的修改也采取懒更新或者立即更新,可以通过配置进行切换。懒更新则使用比 Spring 更简单的方式进行,当SimpleSessionFilter
执行完毕以后进行提交,所以如果顺序排在前面的Filter
(执行after
应该在SimpleSessionFilter
后面)在chain.doFilter
之后就不能再进行session
的操作。
3.3 简化存储结构
3 key 式的存储确实是设计巧妙,但是由于Simple Session
没有去实现Session
变更(create, delete & expired)事件,所以也就没必要去使用 3 key 存储。因此,使用了最简洁的设计:SessionId -> map(key:value)
,存储Session
相关属性及attributes
。
3.4 功能删减
Spime Session
实现了主要功能,包括只实现了Redis
存储方案,至于其他方案如:db,使用者根据需要自己按接口实现。至于如:Spring Security
的支持,socket
场景的支持,都没有纳入主要功能去实现。
网友评论