原文作者:Adib Saikali
会话管理一直是 Java 企业级应用的重要部分。不过在很长的一段时间里,这一部分都被我们认为是一个已解决的问题,并且也没有什么重大的创新出现。
然而,微服务还有可横向伸缩的云原生应用这一现代趋势揭露了现今的会话管理技术在设计上的一些缺陷,挑战着我们在过去 20 多年来对这一设计得出的一些结论。
本文会演示最近发布的 Spring Session API 为了帮助我们克服以前的会话管理方式的一些局限所采取的方法。我们将会先总结一下当前的会话管理技术的问题,然后深入探讨 Spring Session 解决这些问题所采取的策略。最后,我们会总结 Spring Session 的工作方式以及在具体项目里面的一些用法。
Spring Session 为企业级 Java 应用的会话管理领域带来了革新,让我们可以轻松做到:
注意,Spring Session 项目其实并不依赖于 Spring 框架,因此我们甚至能在不使用 Spring 框架的项目里面用到它。
Spring Session 的目的是解决传统的 JavaEE 会话管理技术的各种问题。下面就通过一些例子说明一些这方面的问题。
从云原生应用架构的视角来看,一个应用应该可以通过在一个大型的虚拟机池里运行更多的 Linux 容器来部署更多的实例的方式来得到横向的伸缩。比如,我们能很轻松地将一个这样的应用的 war 文件部署到 Cloud Foundry 或 Heroku 上的 Tomcat 里面,然后在几秒内扩展出 100 个应用程序实例,使得其中每个实例都有 1GB 的 RAM。我们还可以将云平台设置成会根据用户需求自动增减应用程序实例的数量。
很多应用都会把 HTTP 会话状态存储在运行应用代码的 JVM 里面。这很容易实现,而且存取的速度也很快。当一个应用实例加入或退出集群的时候,HTTP 会话的存储会在所有尚存的应用程序实例上重新进行平均的分配。在弹性云环境中,我们会运行数以百计的应用实例,且实例数量可能随时发生快速的增减变化。这就带来了一些问题:
因此,将 HTTP 会话状态存储在运行应用代码的 JVM 之外的数据存储中会更高效。例如可以设置并使用 Redis 来存储上述的 100 个 Tomcat 实例里面的会话状态,那么 Tomcat 实例数量的增减便不会影响到在 Redis 中的会话存储的模式。另外,因为 Redis 是用 C 语言编写的,所以它可以在没有垃圾回收机制影响其运行的前提下,动用数百 GB 甚至 TB 数量级的内存。
对像 Tomcat 这样的开源服务器,找到使用外部数据存储(如 Redis 或 Memcached)的会话管理技术的其他实现是很简单的,但是使用起来的配置过程可能很复杂,并且每个应用服务器的配置过程可能都不一样。对如 WebSphere 和 Weblogic 之类的闭源产品,找到适合它们的会话管理技术的替代实现则通常是不可能的。
Spring Session 为设置插件式的会话数据存储提供了一种独立于具体应用服务器的方法,使得我们能在 Servlet 框架的范畴内实现这样的存储,而不用依赖于具体的应用服务器的 API。这意味着 Spring Session 可以与所有实现了 Servlet 规范的应用服务器(Tomcat,Jetty,WebSphere,WebLogic,JBoss)协同工作,并在所有应用服务器上以完全相同且很容易的方式来进行配置。
我们还可以根据我们的需求选用最适合的外部会话数据存储。这使得 Spring Session 也成了一个能帮助我们将传统的 JavaEE 应用迁移到云端并作为一个符合十二要素的应用的一个理想的迁移工具。
假设你正在 example.com 上运行一个面向大众的 Web 应用,其中一些人类用户创建了多个帐号。例如,用户 Jeff Lebowski 可能有两个帐号 thedude@example.com 和 lebowski@example.com。跟其他 Java Web 应用程序一样,你可以使用 HttpSession 来跟踪各种会话状态,比如当前登录的用户。因此,当用户想从 dude@example.com 切换到 lebowski@example.com 时,就必须注销当前账号并重新登录。
使用 Spring Session 来为每个用户配置多个 HTTP 会话就很简单了。这时 Jeff Lebowski 无需注销和登录就可以在 thedude@example.com 和 lebowski@example.com 之间来回切换。
想象一下,你要构建一个具有复杂的自定义授权体系的 Web 应用,其中具有不同权限的用户会具有不同的应用 UI 样式。
比如说,假设应用有四个安全级别:公开(public)、保密(confidential)、机密(secret)以及绝密(top secret)。在用户登录到应用时,系统会识别这一用户的安全级别,然后只对其显示不高于其安全级别的数据。这样,公开级别的用户可以看到公开级别的文档;具有保密级别的用户能看公开和保密级别的,以此类推。为了让用户界面更加友好,我们的应用也应该能让用户预览应用的 UI 在较低的安全级别下的样子。比如绝密级别用户应该能在秘密模式下预览应用的各项事物的外观。
典型的 Web 应用会将当前用户的身份及其安全级别或角色存储在 HTTP 会话里面。不过,由于 Web 应用的每个用户只有一个会话,因此也只能通过注销再登录的方式来切换用户的角色,或者实现一个用户多个会话这一形式。
凭借 Spring Session,我们就可以很轻松地给每个登录用户创建多个相互独立的会话,预览功能的实现也会因此变得简单。比如当前以绝密等级登录的用户想要预览机密等级下的应用时,就可以对其创建并使用一个新的安全级别为机密的会话。
再想象一个场景,在用户通过 example.com 登录到我们的 Web 应用时,他们能使用通过 Websockets 工作的一个 HTML5 即时聊天客户端进行对话。不过,根据 Servlet 规范,通过 Websockets 发出的请求不会更新会话的过期时间,因此在用户进行聊天的时候,无论他们的聊天有多频繁,会话也可能聊着聊着就没了,然后 Websocket 连接也会因此关闭,聊天也就无法继续了。
又是凭借 Spring Session,我们可以很轻松地确保 Websocket 请求还有常规的 HTTP 请求都能更新会话的过期时间。
再想象一下,我们的应用提供了两种访问方式,一个基于 HTTP 的 RESTful API,另一个是基于 RabbitMQ 的 AMQP 消息。此时,执行处理 AMQP 消息的的线程是无法访问应用服务器的 HttpSession
的,对此我们必须自己写一个解决方案来访问 HTTP 会话里边的数据。
还是凭借 Spring Session,只要我们知道会话的 ID,就可以从应用程序的任意线程访问 Spring Session。Spring Session 比以往的 Servlet HTTP 会话管理器有着功能更加丰富的 API,使得我们只需要知道会话 ID 就能定位我们想要找的会话。比如,我们可以用传入消息的用户标识字段来直接找到对应的会话。
现在传统应用服务器在 HTTP 会话管理方面的局限性已经在不同情境中展示过了,我们再来看看 Spring Session 是如何解决这些问题的。
在实现一个会话管理器的时候,有两个关键问题必须得到解决:
不过在本质上,有个更关键的问题是:如何跨越不同的请求协议来传输一个会话的 ID?
第一个问题对 Spring Session 来说已被各种高可用可伸缩的集群存储(Redis、Gemfire、Apache Geode 等)很好地解决了。因此 Spring Session 也应该定义一组标准接口来使得对底层数据存储的访问可以用不同的数据存储来实现。Spring Session 在定义 Session
和 ExpiringSession
这些基本的关键接口之外,也针对了不同数据存储的访问定义了关键接口 SessionRepository
。
org.springframework.session.Session
是定义会话基本功能的接口,例如属性的设置和删除。这个接口并不依赖于具体的底层技术,因此可以比 Servlet 里面的 HttpSession
适用于更多的情况;org.springframework.session.ExpiringSession
则扩展了 Session 接口。它提供了一些属性,让我们可以设置具有时效性的会话,并查询这个会话是否已经过期。RedisSession
便是这个接口的一个实现范例。org.springframework.session.SessionRepository
定义了创建,保存,删除和查找会话的方法。将 Session 保存到数据存储的实际逻辑便写在这一接口的具体实现中。例如 RedisOperationsSessionRepository
便是这个接口的一个实现,它使用 Redis 来实现了会话的创建、保存以及删除。至于将请求关联到特定会话实例的问题,Spring Session 则假定这一关联的过程取决于特定的协议,因为客户端和服务器在请求 / 响应周期期间就需要对所传输的会话 ID 达成一致。比如,如果客户端发来一个 HTTP 请求,那么会话就可以通过 Cookie 或者 HTTP 报文首部来和请求相关联。如果发来一个 HTTPS 请求,则可用 SSL 的 Session ID 字段来讲会话与请求相关联。若发来的是 JMS 消息,那也可以用消息首部来存储请求和响应间的会话 ID。
对 HTTP 协议的关联操作,Spring 会话定义了一个 HttpSessionStrategy
接口,后者有将 Cookies 和会话关联在一起的 CookieHttpSessionStrategy
和使用了自定义报文首部字段来管理会话的 HeaderHttpSessionStrategy
两种实现。
下面便详细地介绍一下 Spring Session 在 HTTP 协议上的工作方式。
在本文发布时(2015.11.10),Spring Session 1.0.2 在当前的 GA 发行版提供了使用 Redis 的 Spring Session 的一套实现,以及支持任何分布式的 Map(如 Hazelcast)的实现。其实,实现 Spring Session 针对某种数据存储的支持是相对容易的,在开源社区里已经有了很多这样的实现。
基于 HTTP 的 Spring Session 是以一个标准 Servlet 过滤器(filter)的形式实现的。这一过滤器应该截取所有的对 Web 应用的请求,并且也应该在诸多过滤器组成的链中排在第一个。Spring Session 的过滤器会负责确保所有后续的代码里面对 javax.servlet.http.HttpServletRequest.getSession()
方法的调用都会呈递给一个 Spinrg Session 的 HttpSession
实例,而不是应用服务器默认提供的 HttpSession
。
要理解这点,最简单的方法就是查阅 Spring Session 的实际源码。我们首先从用来实现 Spring Session 的标准 Servlet 扩展点(extension points)开始。
在 2001 年,Servlet 2.3 规范引入了 ServletRequestWrapper。该类的 Javadoc 称 ServletRequestWrapper “为 ServletRequest 接口能让开发者继承它来适配一种特别的 Servlet 提供了一种便利的实现。该类采用了包装器,或者说装饰器模式。对该类的 ServletRequest 类的方法的调用会被传至其封装的一个请求对象里去。” 下面这段从 Tomcat 里抽出来的代码就展示了 ServletRequestWrapper 的实现方式。
public class ServletRequestWrapper implements ServletRequest {
private ServletRequest request;
/**
* Creates a ServletRequest adaptor wrapping the given request object.
* 创建一个装有给定的请求对象的 ServletRequest 适配器
* @throws java.lang.IllegalArgumentException if the request is null
* 如果请求对象为空就会抛出空指针异常
*/
public ServletRequestWrapper(ServletRequest request) {
if (request == null) {
throw new IllegalArgumentException("Request cannot be null");
}
this.request = request;
}
public ServletRequest getRequest() {
return this.request;
}
public Object getAttribute(String name) {
return this.request.getAttribute(name);
}
// 为可读性着想, 接下来的代码就略了
}
Servlt 2.3 规范还对 ServletRequestWrapper
定义了一个子类 HttpServletRequestWrapper
。我们可以用它来快速地实现一个自定义的 HttpServletRequest
。下面这段从 Tomcat 里抽出来的代码就展示了 HttpServletRequestWrapper
这个类的实现方式。
public class HttpServletRequestWrapper extends ServletRequestWrapper
implements HttpServletRequest {
public HttpServletRequestWrapper(HttpServletRequest request) {
super(request);
}
private HttpServletRequest _getHttpServletRequest() {
return (HttpServletRequest) super.getRequest();
}
public HttpSession getSession(boolean create) {
return this._getHttpServletRequest().getSession(create);
}
public HttpSession getSession() {
return this._getHttpServletRequest().getSession();
}
// 为可读性着想,接下来的代码就略了
}
因此,我们就可以用这些包装类来编写一些扩展 HttpServletRequest 功能的代码,重载返回 HttpSession 的方法,使得后者返回的是我们存储在外部存储仓库里面的会话。这里就给出一份从 Spring Session 项目提出来的源码就对应了这里提到的东西。为了能对应这里的解释,源码里面原本的注释被我重写了一下,在此不妨也看一看里面的注释。
/*
* Spring Session 项目定义了一个继承了标准 HttpServletRequestWrapper 的类
* 它重载了 HttpServletRequest 里面的所有跟会话有关的方法
*/
private final class SessionRepositoryRequestWrapper
extends HttpServletRequestWrapper {
private HttpSessionWrapper currentSession;
private Boolean requestedSessionIdValid;
private boolean requestedSessionInvalidated;
private final HttpServletResponse response;
private final ServletContext servletContext;
/*
* 构造方法这块非常简单
* 它会接收并设置一些之后会用到的参数,
* 然后完成对 HttpServletRequestWrapper 的代理
*/
private SessionRepositoryRequestWrapper(
HttpServletRequest request,
HttpServletResponse response,
ServletContext servletContext) {
super(request);
this.response = response;
this.servletContext = servletContext;
}
/*
* Spring Session 便在这里用自己对返回存储于外部数据源的会话数据的实现
* 取代了对应用服务器提供的默认方法的代理调用.
*
* 这里的实现会先检查它是不是已经有一个对应的会话.
* 若有那就返回之, 否则就会检查当前的请求附带的会话 ID 是否确实对应着一个会话
* 若有, 那就用这个会话 ID 从 SessionRepository 里边加载这个会话;
* 若外部数据源里没这个会话, 或者这个会话 ID 没对应的会话,
* 那就创建一个新的会话, 并把它存在会话数据存储里面.
*/
@Override
public HttpSession getSession(boolean create) {
if(currentSession != null) {
return currentSession;
}
String requestedSessionId = getRequestedSessionId();
if(requestedSessionId != null) {
S session = sessionRepository.getSession(requestedSessionId);
if(session != null) {
this.requestedSessionIdValid = true;
currentSession = new HttpSessionWrapper(session, getServletContext());
currentSession.setNew(false);
return currentSession;
}
}
if(!create) {
return null;
}
S session = sessionRepository.createSession();
currentSession = new HttpSessionWrapper(session, getServletContext());
return currentSession;
}
@Override
public HttpSession getSession() {
return getSession(true);
}
}
Spring Session 同时定义了一个 ServletFilter
接口的实现类 SessionRepositoryFilter
。这里也会给出这个过滤器的实现的核心部分的源码,并且也会附上一些对应本文内容的注释,不妨也看一看。
/*
* SessionRepositoryFilter 是一个标准 ServletFilter 的实现.
* 其目的是从它的基类扩展出一些功能来.
*/
public class SessionRepositoryFilter < S extends ExpiringSession >
extends OncePerRequestFilter {
/*
* 这一方法就是核心部分.
* 该方法会创建一个我们在上面介绍过的包装请求的实例,
* 然后拿这个包装过的请求再过一遍过滤器链的剩余部分.
* 关键的地方在于,应用在执行位于这个过滤器之后的代码时,
* 如果要获取会话的数据, 那这个包装过的请求就会返回 Spring Session
* 所保存在外部数据源的 HttpServletSession 实例.
*/
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
request.setAttribute(SESSION_REPOSITORY_ATTR, sessionRepository);
SessionRepositoryRequestWrapper wrappedRequest =
new SessionRepositoryRequestWrapper(request,response,servletContext);
SessionRepositoryResponseWrapper wrappedResponse =
new SessionRepositoryResponseWrapper(wrappedRequest, response);
HttpServletRequest strategyRequest =
httpSessionStrategy.wrapRequest(wrappedRequest, wrappedResponse);
HttpServletResponse strategyResponse =
httpSessionStrategy.wrapResponse(wrappedRequest, wrappedResponse);
try {
filterChain.doFilter(strategyRequest, strategyResponse);
} finally {
wrappedRequest.commitSession();
}
}
}
这一节的重点在于,基于 HTTP 的 Spring Session 其实也只是一个用了 Servlet 规范的标准特性来实现功能的经典的 Servlet 过滤器而已。因此,将现有的 Web 应用的 war 文件改成使用 Spring Session 是应该可以不用改动已有代码的。然而,在应用里面用了 javax.servlet.http.HttpSessionListener
的情况则是例外。Spring Session 1.0 并没有对 HttpSessionListener
提供支持,不过 Spring Session 1.1 M1 版本则对其添加了支持。详情见此。
在 Web 项目里面,Spring Session 的设置分为四步:
Spring Session 内置了对 Redis 的支持。安装和设置 redis 的详细信息见此。
完成上述 Spring Session 的设置步骤的常见方式有两种。一种是使用 Spring Boot 来自动设置 Spring Session。另外一种则是手动完成每一个配置步骤。
用 Maven 和 Gradle 等依赖管理工具可以很轻松地将 Spring Session 加入到应用的依赖项目里面。比如说,如果你用的是 Spring Boot + Maven,那么就可以在 pom.xml 里面加上以下依赖项目:
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session</artifactId>
<version>1.0.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-redis</artifactId>
</dependency>
spring-boot-starter-redis
这一依赖项目会确保跟 redis 交互所需的 jar 包都包含在应用里面,这样便可以使用 Spring Boot 来进行自动的配置。至于 spring-session
这一依赖项目则对应 Spring Session 的 jar 包。
设置 Spring Session Servlet 过滤器的过程可以通过 Spring Boot 自动完成,只需要在 Spring Boot 的配置类里面加上 @EnableRedisHttpSession
注解即可。就跟下面这段代码一样:
@SpringBootApplication
@EnableRedisHttpSession
public class ExampleApplication {
public static void main(String[] args) {
SpringApplication.run(ExampleApplication.class, args);
}
}
将下面这些配置信息加到 Spring Boot 的 application.properties 文件即可设置 Spring Session 到 Redis 的连接。
spring.redis.host=localhost
spring.redis.password=secret
spring.redis.port=6379
为了设置和 Redis 的连接,Spring Boot 提供了一套详实的底层架构,使得我们可以在其中任意设置一种跟 Redis 建立连接的方式。你能在 Spring Session 还有 Spring Boot 里面找到按部就班进行的指南。
使用 web.xml 来设置传统的 Web 应用去使用 Spring Session 的教程见此。
设置传统的不带有 web.xml 的 war 文件去使用 Spring Session 的教程见此。
在默认情况下,Spring Session 会使用 HTTP cookie 来存储会话 ID,但是我们也可以将 Spring Session 设置成使用自定义的 HTTP 报文首部字段(例如 x-auth-token: 0dc1f6e1-c7f1-41ac-8ce2-32b6b3e57aa3
)来存储会话 ID,而这在构建 RESTful API 的时候会非常有用。完整教程见此。
在配置了 Spring Session 之后,我们就可以使用标准的 Servlet API 去和它进行交互了。比如下面这段代码就定义了一个使用标准 Servlet 会话 API 来访问会话数据的 servlet。
@WebServlet("/example")
public class Example extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// 使用标准的 servlet API 去获取对应的会话数据
// 这一会话数据就是 Spring Session 存在 Redis
// 或是别的我们所指定的数据源里面的会话数据
HttpSession session = request.getSession();
String value = session.getAttribute(“someAttributeâ€);
}
}
Spring Session 通过使用一个叫做 _s
的会话代号参数来跟踪每个用户的多个会话。假如有个传入请求的 URL 是 http://example.com/doSomething?_s=0
,那么 Spring Session 就会读取 _s
参数的值,然后便会认为这个请求对应的是默认的会话。
如果传入请求的 URL 是 http://example.com/doSomething?_s=1
,那么 Spring Session 就会知道这个请求对应的会话的代号是 1。如果传入请求没有指定参数 _s
,那么 Spring Session 就会把它视为对应默认对话(即 _s = 0
)。
为了让每个浏览器都创建一个新的会话,我们只需像以前那样调用 javax.servlet.http.HttpServletRequest.getSession()
,然后 Spring Session 就会返回对应的会话,或者使用 Servlet 规范的语义创建一个新的会话。下表便给出了 getSession()
方法在同一浏览器的不同的 URL 参数下的具体表现形式:
HTTP 请求 URL | 会话代号 | getSession() 的具体表现 |
---|---|---|
example.com/resource | 0 | 如果存在与代号 0 相关联的会话就返回之,否则就创建一个新会话,然后将其与代号 0 关联起来 |
example.com/resource?_s=1 | 1 | 如果存在与代号 1 相关联的会话就返回之,否则就创建一个新会话,然后将其与代号 1 关联起来 |
example.com/resource?_s=0 | 0 | 如果存在与代号 0 相关联的会话就返回之,否则就创建一个新会话,然后将其与代号 0 关联起来 |
example.com/resource?_s=abc | abc | 如果存在与代号 abc 相关联的会话就返回之,否则就创建一个新会话,然后将其与代号 abc 关联起来 |
如上表所示,会话代号并不局限于整数,只要与发布给该用户的所有其他会话别名不同,即可对一个一个新的会话。然而,整数类型的会话代号应该是最易用的,并且 Spring Session 也给出了 HttpSessionManager
来提供一些处理会话代号的实用方法。
我们可以通过 "org.springframework.session.web.HttpSessionManager"
这个属性名来查找相应属性,进而访问到 HttpSessionManager
。下面这段代码就演示了获得 HttpSessionManager
的引用的方法,以及这个实用方法类的一些主要的方法。
@WebServlet("/example")
public class Example extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request,HttpServletResponse response)
throws ServletException, IOException {
/*
* 通过使用 "org.springframework.session.web.http.HttpSessionManager"
* 这一属性名在请求属性中查找属性
* 来获取一个 Spring Session 的 HttpSessionManager 的引用
*/
HttpSessionManager sessionManager=(HttpSessionManager)request.getAttribute(
"org.springframework.session.web.http.HttpSessionManager");
/*
* 用 HttpSessionManager 来找出 HTTP 请求所对应的会话代号.
* 默认情况下这个会话代号会由 HTTP 请求的 URL 参数 _s 给出。
* 比如 http://localhost:8080/example?_s=1 这个 URL
* 就会让这里的 println() 方法打印 "Requested Session Alias is: 1"
*/
String requestedSessionAlias=sessionManager.getCurrentSessionAlias(request);
System.out.println("Requested Session Alias is: " + requestedSessionAlias);
/*
* 返回一个当前还没被浏览器用在请求参数里的唯一的会话代号.
* 注意这一方法并不会创建一个新的会话,
* 创建新的会话还是要通过 request.getSession() 来进行.
*/
String newSessionAlias = sessionManager.getNewSessionAlias(request);
/*
* 使用刚刚得到的新会话代号构造一个 URL,
* 使其含有 _s 这个参数.
* 比如若 newSessionAlias 的值是 2,
* 那么这个方法就会返回 "/inbox?_s=3"
*/
String encodedURL = sessionManager.encodeURL("/inbox", newSessionAlias);
System.out.println(encodedURL);
/*
* 返回一个会话代号为键, 会话 ID 为值的 Map,
* 以便识别浏览器发来的请求所对应的会话.
*/
Map <String, String> sessionIds = sessionManager.getSessionIds(request);
}
}
Spring Session 为企业级 Java 应用的会话管理领域带来了革新,让我们可以轻松做到:
若你在寻找一种从传统又笨重的应用服务器中解放的方法,但又囿于对应用服务器的会话存储集群功能的依赖,那么 Spring Session 对像 Tomcat、Jetty 还有 Undertow 这样的容器的轻量化来说是很好的一个选择。
Spring Session 教程及指南
Websocket / HttpSession 超时交互
网络研讨会:Spring Session 导论
腾讯分布式微服务TSF围绕应用和微服务的PaaS平台,提供服务全生命周期管理能力和数据化运营支持,提供多维度应用、服务、机器的监控数据,助力服务性能优化;拥抱 Spring Cloud 开源社区技术更新和K8s容器化部署。