开始进行 Web 开发时,你可能在使用 Session 时会碰到 Cookie 和 LocalStorage,被它们所干扰。因为他们都可以存储数据,有过期时间,不需要在使用时重新请求。你还会遇到这样的情况,Web 容器(例如 Tomcat、Jetty)包含 Session 的实现,当服务器重启之后,之前的登录状态会失效需要重新登录。
我们先从 HTTP 协议说起。HTTP 协议有个特点,是无状态的,意味着请求与请求是没有关系的。早期的 HTTP 协议只是用来简单地浏览网页,没有其他需求,因此请求与请求之间不需要关联。但现代的 Web 应用功能非常丰富,可以网上购物、支付、游戏、听音乐等等。如果请求与请求之间没有关联,就会出现一个很尴尬的问题:Web 应用不知道你是谁。例如,用户登录之后在购物车中添加了三件商品到购物车,刷新一下网页,用户仍然处于未登录的状态,购物车里空空如也。很显然这种情况是不可接受的。为此 HTTP 协议急需一种技术让请求与请求之间建立起联系来标识用户。于是出现了 Cookie 技术。
Cookie 是 HTTP 报文的一个请求头,Web 应用可以将用户的标识信息或者其他一些信息(用户名等等)存储在 Cookie 中。用户经过验证之后,每次 HTTP 请求报文中都包含 Cookie;当然服务端为了标识用户,即使不经过登录验证,也可以存放一个唯一的字符串用来标识用户。采用 Cookie 就解决了用户标识的问题,同时 Cookie 中包含有用户的其他信息。Cookie 本质上就是一份存储在用户本地的文件,里面包含了需要在每次请求中传递的信息。但 Cookie 存在以下缺点:
Cookie 以明文的方式存储了用户信息,造成了非常大的安全隐患,而 Session 的出现解决这个问题。用户信息可以以 Session 的形式存储在后端。这样当用户请求到来时,请求可以和 Session 对应起来,当后端处理请求时,可以从 Session 中获取用户信息。那么 Session 是怎么和请求对应起来的?答案是通过 Cookie,在 Cookie 中填充一个类似 SessionID 之类的字段用来标识请求。这样用户的信息存在后端,相对安全,也不需要在 Cookie 中存储大量信息浪费流量。但前端想要获取用户信息,例如昵称,头像等信息,依然需要请求后端接口去获取这些信息。
通过 Cookie、Session 这些技术,服务端可以标识到不同的用户,从而提供一些个性化服务。随着用户规模的增长,一个应用有多个实例,部署在不同的 Web 容器中。因此应用不可能再依赖单一的 Web 容器来管理 Session,需要将 Session 管理拆分出来。为了实现 Session 管理,需要实现以下两点:
为此常见的 Session 管理都会采用高性能的存储方式来存储 Session,例如 Redis 和 MemCache,并且通过集群的部署,防止单点故障,提升高可用性。然后采用定时器,或者后台轮询的方式在 Session 过期时将 Session 失效掉。
Spring Session 应运而生,它是一种流行的 Session 管理实现方式,相比上文提到的,Spring Session 做的要更多。Spring Session 并不和特定的协议如 HTTP 绑定,实现了一种广义上的 Session,支持 WebSocket 和 WebSession 以及多种存储类型如 Redis、MongoDB 等等。
Spring Session 由核心模块和具体存储方式相关联的实现模块构成。核心模块包含了 Spring Session 的基本抽象和 API。Spring Session 有两个核心组件:Session 和 SessionRepository。Spring Session 简单易用,通过 SessionRepository 来操作 Session。当建立会话时,创建 Session,将一些用户信息(例如用户 ID)存到 Session 中,并通过 SessionRepository 将 Session 持久化。当会话重新建立的时候,可以获取到 Session 中的信息。同时后台维护了一个定时任务,通过一些巧妙的方式,将过期的 Session 通过 SessionRepository 删除掉。下面详细介绍一下这两个核心组件。
Session 即会话,这里的 Session 指的是广义的 Session 并不和特定的协议如 HTTP 绑定,支持 HttpSession、WebSocket Session,以及其他与 Web 无关的 Session。Session 可以存储与用户相关的信息或者其他信息,通过维护一个键值对(Key-Value)来存储这些信息。Session 接口签名如清单 1 所示:
public interface Session { String getId(); <T> T getAttribute(String attributeName); Set<String> getAttributeNames(); void setAttribute(String attributeName, Object attributeValue); void removeAttribute(String attributeName); }
以下是相关参数介绍:
getId getAttribute getAttributeNames setAttribute removeAttribute
Session 因其存储方式的不同,支持以下多种实现方式:
GemFireSession HazelcastSession JdbcSession MapSession MongoExpiringSession RedisSession
以上存储方式中,采用 Redis 作为数据源非常流行,因此下文将重点讨论 Spring Session 在 Redis 中实现。
SessionRepository 用来增删改查 Session 在对应数据源中的接口。SessionRepository 的接口签名如清单 2 所示:
public interface SessionRepository<S extends Session> { S createSession(); void save(S session); S getSession(String id); void delete(String id); }
以下是相关参数介绍:
createSession Session getSession delete
在 Spring Session 中最常用的数据源为 Redis,本部分将重点介绍 Spring Session 如何在 Redis 中实现。Spring Session 创建 Session 后,使用 SessionRepository 将 Session 持久化到 Redis 中。当 Session 中的数据更新时,Redis 中的数据也会更新;当 Session 被重新访问刷新时,Redis 中的过期时间也会刷新;当 Redis 中的数据失效时,Session 也会失效。
前文提到的 Session 和 SessionRepository 组件,Spring Session 采用 Redis 作为存储方式时,都有对应的实现方式,即下面两个实现类。
Session 在采用 Redis 作为存储方式时,对应的实现类为 RedisSession。RedisSession 并不直接实现 Session, 而是实现了 ExpiringSession。ExpiringSession 增加了一些属性,用来判断 Session 是否失效,ExpiringSession 继承 Session。RedisSession 的接口签名如清单 3 所示:
final class RedisSession implements ExpiringSession { private final MapSession cached; private Long originalLastAccessTime; String, Object> delta = new HashMap<String, Object>(); private boolean isNew; private String originalPrincipalName; }
以下是相关参数介绍:
cached originalLastAccessTime delta isNew originalPrincipalName
Session 在 Redis 中以 HashMap 的结构方式存储。
SessionRepository 在采用 Redis 作为存储方式时,对应的实现类为 RedisOperationSessionRepository。RedisOperationSessionRepository 并不直接实现 SessionRepository,而是实现了 FindByIndexNameSessionRepository。FindByIndexNameSessionRepository 继承 SessionRepository,并提供了强大的 Session 查找接口。FindByIndexNameSessionRepository 接口如清单 4 所示:
public class RedisOperationsSessionRepository implements FindByIndexNameSessionRepository<RedisOperationsSessionRepository.RedisSession>, MessageListener { static final String DEFAULT_SPRING_SESSION_REDIS_PREFIX = "spring:session:"; static final String CREATION_TIME_ATTR = "creationTime"; static final String MAX_INACTIVE_ATTR = "maxInactiveInterval"; static final String LAST_ACCESSED_ATTR = "lastAccessedTime"; static final String SESSION_ATTR_PREFIX = "sessionAttr:"; }
以下是相关参数介绍:
DEFAULT_SPRING_SESSION_REDIS_PREFIX
:Spring Session 在 Redis 中存储 Session
的前缀。 CREATION_TIME_ATTR
:Session 的创建时间。 MAX_INACTIVE_ATTR
:Session 的有效时间。 LAST_ACCESSED_ATTR
:Session 的上次使用时间。 SESSION_ATTR_PREFIX
:例如在 Session 中存储了 name 属性,value 为 小明
,Session 在 Redis 中以
HashMap 的方式,那么 name 的存储方式为 sessionAttr:name
, value 为 小明
。 SessionRepository 存储 Session,本质上是在操作 Redis,如清单 5 所示:
1. HMSET spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe creationTime 1404360000000 maxInactiveInterval 1800 lastAccessedTime 1404360000000 sessionAttr:name lee sessionAttr:mobile 18381111111 2. EXPIRE spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe 2100 3. APPEND spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe "" 4. EXPIRE spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe 1800 5. SADD spring:session:expirations:1439245080000 expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe 6. EXPIRE spring:session:expirations1439245080000 2100
在 Redis 中所有 Key 的前缀都是 spring:session
(与上文中的 DEFAULT_SPRING_SESSION_REDIS_PREFIX
)相对应。假设多个项目共用一个 Redis,这时需要改变前缀,前缀中可以加入项目名如
lily 变为 lily:spring:session
。
创建 Session 时会填充一个唯一的字符串用来标识 Session。在 Redis 中会为 Session 设置以下属性 creationTime、maxInactiveInterval 和 lastAccessedTime 与上文中的创建时间、有效时间、上次访问时间相对应。Session 中填充了两个属性 name 和 mobile。Session 的创建如清单 6 所示:
HMSET spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe creationTime 1404360000000 maxInactiveInterval 1800 lastAccessedTime 1404360000000 sessionAttr:name lee sessionAttr:mobile 18381111111 EXPIRE spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe 2100
Session 在 Redis 中创建之后触发 SessionCreatedEvent
,创建 Session
后需要额外的逻辑可以订阅该事件。注意,Session 中的失效时间属性 maxInactiveInterval
的值为 1800
,但在 Redis 中 Session 的失效时间为 2100
,这涉及到 Session 在 Redis
中的失效机制,下文会详细解答。
Redis 提供了失效机制,可以为键值对设置失效期。试想一下,用 Redis 实现一个最简单的 Session 失效,可以为存储在 Redis 中的 Session
直接设置失效,时间设置为 1800
即可。但 Spring Session 为什么没有这样做呢?这是 Spring Session 为应用提供的一个扩展点,当
Session 失效时,Spring Session 可以通过消息订阅的方式通知到应用,应用可能会做出一些自己的逻辑处理。因此 Spring Session 新增加了
Expiration Key,为 Expiration Key 设置失效时间为 1800
,如清单 7 所示:
APPEND spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe "" EXPIRE spring:session:sessions:expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe 1800
当 Expiration Key 被删除之后会触发 SessionDestroyEvent (内含 Session 相关信息)。Spring Session 会清除 Expiration Redis 中的 Session。但是存在这样一个问题,Redis 无法保证当 Key 过期无法访问时能够触发 SessionDestroyEvent。Redis 后台维护了一个任务,去定时地检测 Key 是否失效(不可访问),如果失效会触发 SessionDestroyEvent。但是这个任务的优先级非常低,很有可能 Key 已经失效了,但检测任务没有分配到执行时间片去触发 SessionDestroyEvent。更多关于 Redis 中 Key 失效的细节参考 Timing of expired events 。
为了解决这个问题,Spring Session 根据整点分钟数维护了一个集合,根据 Expiration Key 的失效时间将其填充到 expirations:
整点分钟数的集合中,如清单 8 所示:
SADD spring:session:expirations:1439245080000 expires:33fdd1b6-b496-4b33-9f7d-df96679d32fe EXPIRE spring:session:expirations1439245080000 2100
Spring Session 后台会维护一个定时任务去检测符合整点分钟数的 expirations 集合,然后访问其中的 Expiration Key。如果 Expiration Key 已经失效,Redis 会自动删除 Expiration Key 并触发 SessionDestroyEvent,这样 Spring Session 会清理掉已经触发 SessionDestroyEvent 的 Session。Spring Session 维护的定时任务代码在 RedisOperationsSessionRepository 中,如清单 9 所示:
@Scheduled(cron = "${spring.session.cleanup.cron.expression:0 * * * * *}") public void cleanupExpiredSessions() { this.expirationPolicy.cleanExpiredSessions(); }
定时任务每分钟的 0 秒开始执行,如果开发人员觉得这个频率太高,可以通过自定义 spring.session.cleanup.corn.expression
进行更改任务的执行时间。
通过上述分析,我们发现 Spring Session 设计的非常巧妙。Spring Session 并不会根据 expirations 集合中的内容去删除 Expiration Key。而是对可能失效的 Expiration Key 进行请求,让 Redis 自身判断 Key 是否已经失效,如果失效则进行清除,触发删除事件。此外,在 Redis 集群中,如果不采用分布式锁(会极大的降低性能),Redis 可能会错误的把一个 Key 标记为失效,如果冒然的删除 Key 会导致出错。采用请求 Expiration Key 的方式,Redis 自身会做出正确的判断。
Spring Session 是与协议无关的,因此想要在 Web 中使用 Spring Session 需要进行集成。一个很常见的问题是:Spring Session 在 Web 中的入口是哪里?答案是 Filter。Spring Session 选择 Filter 而不是 Servlet 的方式有以下优点:Spring Session 依赖 J2EE 标准,无需依赖特定的 MVC 框架。另一方面 Spring MVC 通过 Servlet 做请求转发,如果 Spring Session 采用 Servlet,那么 Spring Session 和 Spring MVC 的集成会存在问题。
Spring Session 与 Web 集成的时候,需要用到以下 4 个核心组件:SessionRepositoryFilter、SessionRepositoryRequestWrapper、SessionRepositoryResponseWrapper 和 MultiHttpSessionStrategy,它们的协作方式如下:
下面通过解析各组件的源码来说明 Spring Session 如何与 Web 集成。
SessionRepositoryFilter 拦截所有请求,对 HttpServletRequest 进行包装处理生成 SessionRepositoryRequestWrapper,对 HttpServletResponse 进行包装处理生成 SessionRepositoryResponseWrapper。SessionRepositoryFilter 的核心代码,如清单 10 所示:
doFilterInternal(HttpServletRequest request,HttpServletResponse response, FilterChain filterChain){ 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); }
注意 SessionRepositoryFilter 必须放置在任何访问或者进行 commit 操作之前,因为只有这样才能保证 J2EE 的 Session 被 Spring Session 提供的 Session 进行复写并进行正确的持久化。
SessionRepositoryRequestWrapper 是 HttpServletRequest 包装类,并覆盖 getSession 方法。getSession 方法会做如下操作:
getSession(boolean create)的代码如清单 11 所示:
@Override public HttpSessionWrapper getSession(boolean create) { ...... String requestedSessionId = getRequestedSessionId(); if (requestedSessionId != null && getAttribute(INVALID_SESSION_ID_ATTR) == null) { S session = getSession(requestedSessionId); ...... } ...... S session = SessionRepositoryFilter.this.sessionRepository.createSession(); }
getRequestedSessionId 方法用来获取 Session 的 ID,本质上就是调用 MultiHttpSessionStrategy 来获取,如清单 12 所示:
@Override public String getRequestedSessionId() { return SessionRepositoryFilter.this.httpSessionStrategy.getRequestedSessionId(this); }
getSession(String id)方法用来获取 Session,本质上是调用 SessionRepository 来查找 Session,如清单 13 所示:
private S getSession(String sessionId) { S session = SessionRepositoryFilter.this.sessionRepository.getSession(sessionId); if (session == null) { return null; } session.setLastAccessedTime(System.currentTimeMillis()); return session; }
SessionRepositoryResponseWrapper 是 HttpServletResponse 的包装类,覆盖了 onResponseCommitted 方法。主要职责是检测 Session 是否失效,如果失效进行相应处理;确保新创建的 Session 被正确的持久化。onResponseCommitted 方法如清单 14 所示:
@Override protected void onResponseCommitted() { this.request.commitSession(); }
onResponseCommitted 方法本质上调用 SessionRepositoryRequestWrapper 的 commitSession 方法,如清单 15 所示:
private void commitSession() { HttpSessionWrapper wrappedSession = getCurrentSession(); if (wrappedSession == null) { if (isInvalidateClientSession()) { SessionRepositoryFilter.this.httpSessionStrategy.onInvalidateSession(this, this.response); } } else { S session = wrappedSession.getSession(); SessionRepositoryFilter.this.sessionRepository.save(session); if(!isRequestedSessionIdValid()|| !session.getId().equals(getRequestedSessionId())){ SessionRepositoryFilter.this.httpSessionStrategy.onNewSession(session,this, this.response); } } }
commitSession 方法会判断 Session 的状态,进行失效、更新等处理。
MultiHttpSessionStrategy 继承 RequestResponsePostProcessor 和 HttpSessionStrategy 接口。RequestResponsePostProcessor 接口,允许开发人员对 HttpServletRequest 和 HttpServletResponse 进行一些定制化的操作,例如读取自定义的请求头,进行个性化处理。
HttpSessionStrategy 即 Session 实现策略,上文提到 Session 的失效策略是采用 Cookie 的方式,因此 HttpSessionStrategy 的默认失效方式是 CookieHttpSessionStrategy。HttpSessionStrategy 的接口签名如清单 16 所示:
public interface HttpSessionStrategy { String getRequestedSessionId(HttpServletRequest request); void onNewSession(Session session, HttpServletRequest request,HttpServletResponse response); void onInvalidateSession(HttpServletRequest request, HttpServletResponse response); }
以下是相关参数介绍:
getRequestedSessionId
:获取 Session 的 ID,默认从 Cookie 中获取 Session 字段的值。 onNewSession
:当用后台为请求建立了 Session 时,需要通知浏览器等客户端,接收 Session 的 ID。默认通过 Cookie
实现,将 Session 字段填充 Session 的 ID,并放置在 Set-cookie
响应头中。 onInvalidateSession
:当 Session 失效时调用,默认通过 Cookie 的方式,将 Session 字段删除。 下面简单演示一下采用 Cookie 来实现 Session,如清单 17 所示:
Request(请求) GET / HTTP/1.0 Host: kuboot.cn Response(响应) HTTP/1.0 200 OK Set-cookie: session=”123”; domain=”kuboot.cn” Request(请求) GET / HTTP/1.0 Host: kuboot.cn Cookie: session=”123”
这是一个 demo,演示了如何简单的使用 Spring Session 与 Web 进行集成。项目地址: https://github.com/springAppl/lily.git 。
本文分析了 Spring Session 的架构,演示了采用 Redis 存储 Session 的实现细节,涉及时间监听和如何通过定时任务巧妙地失效 Session。此外,通过源码解析梳理了在 Web 中集成 Spring Session 的流程。
参考 Spring Session 官方文档 ,了解更多内容。