0
点赞
收藏
分享

微信扫一扫

详解 Spring Session 架构与设计


前言

开始进行 Web 开发时,我们可能会遇到这样的情况,Web 容器(例如 Tomcat、Jetty)包含 Session 的实现,当服务器重启之后,之前的登录状态会失效需要重新登录。又或者你的应用程序部署了不止一台机器,用户在机器A上登陆之后,来到机器B又需要重新登陆,因为机器A的 Session 在机器B 是没有的。

在解决这两个问题之前,我们先来重新了解下 HTTP 协议的相关知识。

HTTP 协议

HTTP 协议有个特点,是无状态的,意味着请求与请求是没有关系的。早期的 HTTP 协议只是用来简单地浏览网页,没有其他需求,因此请求与请求之间不需要关联。但现代的 Web 应用功能非常丰富,可以网上购物、支付、游戏、听音乐等等。如果请求与请求之间没有关联,就会出现一个很尴尬的问题:Web 应用不知道你是谁。为此 HTTP 协议需要一种技术让请求与请求之间建立起联系来标识用户。于是出现了 Cookie 技术。

Cookie 技术

Cookie 是 HTTP 报文的一个请求头,Web 应用可以将用户的标识信息或者其他一些信息(用户名等等)存储在 Cookie 中。用户经过验证之后,每次 HTTP 请求报文中都包含 Cookie;当然服务端为了标识用户,即使不经过登录验证,也可以存放一个唯一的字符串用来标识用户。采用 Cookie 就解决了用户标识的问题,同时 Cookie 中包含有用户的其他信息。Cookie 本质上就是一份存储在用户本地的文件,里面包含了需要在每次请求中传递的信息。

Session 技术

Cookie 以明文的方式存储了用户信息,造成了非常大的安全隐患,而 Session 的出现解决这个问题。用户信息可以以 Session 的形式存储在后端。这样当用户请求到来时,请求可以和 Session 对应起来,当后端处理请求时,可以从 Session 中获取用户信息。那么 Session 是怎么和请求对应起来的?答案是通过 Cookie,在 Cookie 中填充一个类似 SessionID 之类的字段用来标识请求。这样用户的信息存在后端,相对安全,也不需要在 Cookie 中存储大量信息浪费流量。但前端想要获取用户信息,例如昵称,头像等信息,依然需要请求后端接口去获取这些信息。

Session 管理

随着用户规模的增长,一个应用有多个实例,部署在不同的 Web 容器中。因此应用不可能再依赖单一的 Web 容器来管理 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 架构与设计_redis

Spring Session 有两个核心组件:Session 和 SessionRepository。Spring Session 简单易用,通过 SessionRepository 来操作 Session。当建立会话时,创建 Session,将一些用户信息(例如用户 ID)存到 Session 中,并通过 SessionRepository 将 Session 持久化。当会话重新建立的时候,可以获取到 Session 中的信息。同时后台维护了一个定时任务,将过期的 Session 通过 SessionRepository 删除掉。下面详细介绍一下这两个核心组件。

Session

Session 即会话,这里的 Session 指的是广义的 Session 并不和特定的协议如 HTTP 绑定,支持 HttpSession、WebSocket Session,以及其他与 Web 无关的 Session。Session 可以存储与用户相关的信息或者其他信息,通过维护一个键值对(Key-Value)来存储这些信息。Session 接口签名如下所示:

Session 接口:org.springframework.session.Session

/**
* Provides a way to identify a user in an agnostic way. This allows the session to be
* used by an HttpSession, WebSocket Session, or even non web related sessions.
*
* @author Rob Winch
* @since 1.0
*/
public interface Session {

String getId();

<T> T getAttribute(String attributeName);

Set<String> getAttributeNames();

void setAttribute(String attributeName, Object attributeValue);

void removeAttribute(String attributeName);
}

以下是相关参数介绍:

  • ​getId​​:每个 Session 都有一个唯一的字符串用来标识 Session。
  • ​getAttribute​​:获取 Session 中的数据,需要传递一个 name 获取对应的存储数据,返回类型是泛型,不需要进行强制转换。
  • ​getAttributeNames​​:获取 Session 中存储信息所有的 name(也就是 Key)。
  • ​setAttribute​​:填充或修改 Session 中存储的数据。
  • ​removeAttribute​​:删除 Session 中填充的数据。

Session 因其存储方式的不同,支持以下多种实现方式:

详解 Spring Session 架构与设计_spring_02

  • ​GemFireSession​​:采用 GemFire 作为数据源,在金融领域应用非常广泛。
  • ​HazelcastSession​​:采用 Hazelcast 作为数据源。
  • ​JdbcSession​​:采用关系型数据库作为数据源,支持 SQL。
  • ​MapSession​​:采用 Java 中的 Map 作为数据源,一般作为快速启动的 demo 使用。
  • ​MongoExpiringSession​​:采用 MongoDB 作为数据源。
  • ​RedisSession​​:采用 Redis 作为数据源。

以上存储方式中,采用 Redis 作为数据源非常流行,因此下文将重点讨论 Spring Session 在 Redis 中实现。

SessionRepository

SessionRepository 用来增删改查 Session 在对应数据源中的接口。SessionRepository 的接口签名如下所示:

SessionRepository 接口:org.springframework.session.SessionRepository

public interface SessionRepository<S extends Session> {

S createSession();

void save(S session);

S getSession(String id);

void delete(String id);
}

以下是相关参数介绍:

  • ​createSession​​:创建 Session。
  • ​save​​:更新 Session。
  • ​getSession​​:根据 ID 来获取 Session。
  • ​delete​​:根据 ID 来删除 Session。

Spring Session 在 Redis 中的实现

在 Spring Session 中最常用的数据源为 Redis,本部分将重点介绍 Spring Session 如何在 Redis 中实现。Spring Session 创建 Session 后,使用 SessionRepository 将 Session 持久化到 Redis 中。当 Session 中的数据更新时,Redis 中的数据也会更新;当 Session 被重新访问刷新时,Redis 中的过期时间也会刷新;当 Redis 中的数据失效时,Session 也会失效。

采用 Redis 作为存储对应的实现类

前文提到的 Session 和 SessionRepository 组件,Spring Session 采用 Redis 作为存储方式时,都有对应的实现方式,即下面两个实现类。

RedisSession

Session 在采用 Redis 作为存储方式时,对应的实现类为 RedisSession。RedisSession 并不直接实现 Session, 而是实现了 ExpiringSession。ExpiringSession 增加了一些属性,用来判断 Session 是否失效,ExpiringSession 继承 Session。RedisSession 的接口签名如下所示:

RedisSession 接口:org.springframework.session.data.redis.RedisOperationsSessionRepository.RedisSession

final class RedisSession implements ExpiringSession {
private final MapSession cached;
private Long originalLastAccessTime;
private Map<String, Object> delta = new HashMap<String, Object>();
private boolean isNew;
private String originalPrincipalName;

RedisSession() {
this(new MapSession());
this.delta.put(CREATION_TIME_ATTR, getCreationTime());
this.delta.put(MAX_INACTIVE_ATTR, getMaxInactiveIntervalInSeconds());
this.delta.put(LAST_ACCESSED_ATTR, getLastAccessedTime());
this.isNew = true;
this.flushImmediateIfNecessary();
}
// ...
}

以下是相关参数介绍:

  • ​cached​​:采用 MapSession 作为缓存,意味着查找 Session 中的信息先从 MapSession 中查找,然后再从 Redis 中查找。
  • ​originalLastAccessTime​​:上一次访问时间。
  • ​delta​​:与 Session 中的更新数据相关。
  • ​isNew​​:RedisSession 是否是新建的、未被更新过。
  • ​originalPrincipalName​​:主题名称。

Session 在 Redis 中以 HashMap 的结构方式存储。

RedisOperationsSessionRepository

SessionRepository 在采用 Redis 作为存储方式时,对应的实现类为 RedisOperationSessionRepository。RedisOperationSessionRepository 并不直接实现 SessionRepository,而是实现了 FindByIndexNameSessionRepository。FindByIndexNameSessionRepository 继承 SessionRepository,并提供了强大的 Session 查找接口。RedisOperationsSessionRepository 接口如下 所示:

RedisOperationsSessionRepository 接口:org.springframework.session.data.redis.RedisOperationsSessionRepository

public class RedisOperationsSessionRepository implements
FindByIndexNameSessionRepository<RedisOperationsSessionRepository.RedisSession>,
MessageListener {
private static final String SPRING_SECURITY_CONTEXT = "SPRING_SECURITY_CONTEXT";

static PrincipalNameResolver PRINCIPAL_NAME_RESOLVER = new PrincipalNameResolver();

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:";

private String keyPrefix = DEFAULT_SPRING_SESSION_REDIS_PREFIX;

private final RedisOperations<Object, Object> sessionRedisOperations;

private final RedisSessionExpirationPolicy expirationPolicy;

private ApplicationEventPublisher eventPublisher = new ApplicationEventPublisher() {
public void publishEvent(ApplicationEvent event) {
}
public void publishEvent(Object event) {
}
};

private Integer defaultMaxInactiveInterval;

private RedisSerializer<Object> defaultSerializer = new JdkSerializationRedisSerializer();

private RedisFlushMode redisFlushMode = RedisFlushMode.ON_SAVE;
// ...
}

以下是相关参数介绍:

  • ​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 为​​小明​​。
  • ​sessionRedisOperations:​​指定一组基本Redis操作的接口,由{@link RedisTemplate}实现。不经常使用
  • ​expirationPolicy​​:设置session在Redis中的过期策略
  • ​eventPublisher​​:事件订阅,主要是 SessionCreatedEvent,SessionDestoryEvent,SessionDeleteEvent

Session 在 Redis 中的存储结构

SessionRepository 存储 Session,本质上是在操作 Redis,如下所示:

详解 Spring Session 架构与设计_redis_03

  • ①.整点分钟的session过期集合,根据 ④ 的失效时间填充
  • ②.登录用户,可以获取所有登录系统的用户
  • ③.系统所有session
  • ④.过期session key,session过期时会从此处删除
Session 在 Redis 中的存储

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,这时需要改变前缀。

在 Redis 中创建 Session

创建 Session 时会填充一个唯一的字符串用来标识 Session。在 Redis 中会为 Session 设置以下属性 creationTime、maxInactiveInterval 和 lastAccessedTime 与上文中的创建时间、有效时间、上次访问时间相对应。Session 中填充了两个属性 name 和 mobile。Session 的创建如下 所示:

Session 创建

HMSET spring:session:sessions:33fdd1b6-b496-4b33-9f7d-df96679d32fe creationTime 1404360000000 maxInactiveInterval 1800 lastAccessedTime 1404360000000 sessionAttr:name li 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 中实现 Session 失效

Redis 提供了失效机制,可以为键值对设置失效期。试想一下,用 Redis 实现一个最简单的 Session 失效,可以为存储在 Redis 中的 Session 直接设置失效,时间设置为 ​​1800​​ 即可。但 Spring Session 为什么没有这样做呢?

这是 Spring Session 为应用提供的一个扩展点,当 Session 失效时,Spring Session 可以通过消息订阅的方式通知到应用,应用可能会做出一些自己的逻辑处理。因此 Spring Session 新增加了 Expiration Key,为 Expiration Key 设置失效时间为 ​​1800​​,如下所示:

Expiration Key

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:整点分钟数的集合中

expirations 集合

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 中:

Spring Session 定时任务

@Scheduled(cron = "${spring.session.cleanup.cron.expression:0 * * * * *}")
public void cleanupExpiredSessions() {
this.expirationPolicy.cleanExpiredSessions();
}

每当调用 cleanExpiredSessions()时,都会访问前一分钟的会话,以确保它们在过期时被删除。 在某些情况下, cleanExpiredSessions()方法可能不会在*特定时间内被调用。例如,重新启动服务器时可能会发生这种情况。为了应对这种情况,还会设置Redis会话的到期时间。

定时任务每分钟的 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 是与协议无关的,因此想要在 Web 中使用 Spring Session 需要进行集成。一个很常见的问题是:Spring Session 在 Web 中的入口是哪里?答案是 Filter。

Spring Session 与 Web 集成的时候,需要用到以下 4 个核心组件:SessionRepositoryFilter、SessionRepositoryRequestWrapper、SessionRepositoryResponseWrapper 和 MultiHttpSessionStrategy,它们的协作方式如下:

  1. 当请求到来的时候,SessionRepositoryFilter 会拦截请求,采用包装器模式,将 HttpServletRequest 进行包装为 SessionRepositoryRequestWrapper。
  2. SessionRepositoryRequestWrapper 会覆盖 HttpServletRequest 原本的 getSession()方法。getSession() 会改变 Session 的获取和存储方式,开发人员可以自己定义采用某种方式,例如 Redis、数据库等来获取 Session。用户获取到 Session 之后,可能会对 Session 做出改变,开发人员不需要手动的对 Session 进行提交和持久化,SpringSession 将自动完成。
  3. SessionRepositoryFilter 将 HttpServletResponse 包装为 SessionRepositoryResponseWrapper,并覆盖 SessionRepositoryResponseWrapper 生命周期函数 onResponseCommitted(当请求处理完毕,该函数会被调用)。
  4. 在 onResponseCommitted 函数中,会调用 HttpSessionStrategy 确保 Session 被正确地持久化。这样 Session 在 HTTP 的整个生命周期就完成了。

下面通过解析各组件的源码来说明 Spring Session 如何与 Web 集成。

SessionRepositoryFilter

SessionRepositoryFilter 拦截所有请求,对 HttpServletRequest 进行包装处理生成 SessionRepositoryRequestWrapper,对 HttpServletResponse 进行包装处理生成 SessionRepositoryResponseWrapper。SessionRepositoryFilter 的核心代码如下

doFilterInternal 方法

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();
}
}

注意 SessionRepositoryFilter 必须放置在任何访问或者进行 commit 操作之前,因为只有这样才能保证 J2EE 的 Session 被 Spring Session 提供的 Session 进行复写并进行正确的持久化。

SessionRepositoryRequestWrapper

SessionRepositoryRequestWrapper 是 HttpServletRequest 包装类,并覆盖 getSession 方法。getSession 方法会做如下操作:

  • 调用 MultiHttpSessionStrategy 生成和获取 Session 的唯一标识符 ID。
  • 调用 SessionRepository 生成和获取 Session。

getRequestedSessionId 方法用来获取 Session 的 ID,本质上就是调用 MultiHttpSessionStrategy 来获取

getSession(String id)方法用来获取 Session,本质上是调用 SessionRepository 来查找 Session

SessionRepositoryResponseWrapper

SessionRepositoryResponseWrapper 是 HttpServletResponse 的包装类,覆盖了 onResponseCommitted 方法。主要职责是检测 Session 是否失效,如果失效进行相应处理;确保新创建的 Session 被正确的持久化。

onResponseCommitted 方法本质上调用 SessionRepositoryRequestWrapper 的 commitSession 方法

commitSession 方法会判断 Session 的状态,进行失效、更新等处理。

MultiHttpSessionStrategy

MultiHttpSessionStrategy 继承 RequestResponsePostProcessor 和 HttpSessionStrategy 接口。RequestResponsePostProcessor 接口,允许开发人员对 HttpServletRequest 和 HttpServletResponse 进行一些定制化的操作,例如读取自定义的请求头,进行个性化处理。

HttpSessionStrategy 即 Session 实现策略,上文提到 Session 的失效策略是采用 Cookie 的方式,因此 HttpSessionStrategy 的默认失效方式是 CookieHttpSessionStrategy。

以下是相关参数介绍:

  • ​getRequestedSessionId​​:获取 Session 的 ID,默认从 Cookie 中获取 Session 字段的值。
  • ​onNewSession​​​:当用后台为请求建立了 Session 时,需要通知浏览器等客户端,接收 Session 的 ID。默认通过 Cookie 实现,将 Session 字段填充 Session 的 ID,并放置在​​Set-cookie​​ 响应头中。
  • ​onInvalidateSession​​:当 Session 失效时调用,默认通过 Cookie 的方式,将 Session 字段删除。

结束语

本文分析了 Spring Session 的架构,介绍了采用 Redis 存储 Session 的实现细节,涉及时间监听和如何通过定时任务巧妙地失效 Session。此外,通过源码解析梳理了在 Web 中集成 Spring Session 的流程。

参考资源

参考​​ Spring Session 官方文档​​,了解更多内容。


举报

相关推荐

0 条评论