Spring Session
我们知道HTTP协议本身是无状态的,但Web应用中我们经常需要保存用户的登录信息、购物车信息等,这些需要我们的服务端缓存用户的状态,Servlet规范提供的HttpSession接口便能解决这个问题,例如Tomcat默认就是将会话信息保存在内存中。然而如果我们的Web应用有多个实例,假如用户的两次请求连接到了不同的Tomcat服务器实例上,那么如何保证两次的Session是一致的呢?这个问题实际有至少3种解决方案:
Session复制:Tomcat的Cluster(集群)模式实现了Session复制功能,通过配置后可以保证用户访问时会话状态的一致性
Session统一存储:采用数据库、Redis等方式集中读写Session,这样就不存在会话状态一致性的问题了
基于网关路由:让客户端在一次会话内路由到同一个服务器上
在实际开发中,其实第一种方案使用极少,大多数都是采用第二种方案,存储一般采用性能较高的KV内存数据库,例如Redis等。第三种方案主要用于长连接的有状态服务,比如游戏服务端等,这里不做讨论。
然而直接使用Redis客户端实现操作Session还是有些麻烦,Spring Session框架为我们将这些操作封装了一下,我们可以直接使用原来ServletAPI中HttpSession的写法读写Session,会话数据就会自动更新到对应配置的存储后端,除此之外,Spring Session框架也为WebSocket和WebFlux环境提供了统一的Session支持。这篇笔记我们使用Redis数据库,简单学习Spring Session的配置和使用。
引入Maven依赖
这里我们以SpringBoot2.x工程为例进行介绍,我们引入以下两个依赖。
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
使用Spring Session
我们使用Spring Session框架前,需要先配置一下Session的存储后端类型、超时时间以及Redis的连接信息。在application.properties中我们写入以下配置。
spring.session.store-type=redis
spring.session.timeout=3600
spring.redis.host=localhost
spring.redis.port=32768
spring.redis.password=redispw
spring.redis.database=0
有关spring.session更多的配置我们可以参考官方文档,这里就不过多说明了。配置完成后,此时SpringSession框架其实就已经生效了,我们这里编写以下例子进行试验。
package com.gacfox.demo.demosession.comtroller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpSession;
@RestController
public class DemoController {
@GetMapping(value = "/api/setSession")
public void setSession(HttpServletRequest request) {
HttpSession session = request.getSession();
session.setAttribute("key", "hello");
}
@GetMapping(value = "/api/getSession")
public String getSession(HttpServletRequest request) {
HttpSession session = request.getSession();
return (String) session.getAttribute("key");
}
}
代码非常简单,我们编写了一个Controller,其中setSession()方法向Session写入一个键值对,getSession()取出该值并返回。观察代码我们可以发现,其实我们使用的完全是原来的ServletAPI中HttpSession的写法。
此时我们可以查询Redis数据库,观察其中写入的内容。这里我们使用keys *命令查询后,观察到了如下3个Key。
spring:session:sessions:expires:5e6c4c1d-84d3-4886-9001-3222f836380b
spring:session:expirations:1674211320000
spring:session:sessions:5e6c4c1d-84d3-4886-9001-3222f836380b
其中spring:session:sessions:xxx是一个Hash类型,其中包含了我们写入的key: hello信息。
自定义Cookie名
Spring Session默认使用的SessionID Cookie名为SESSION,我们也可以自定义配置该Cookie名。
server.servlet.session.cookie.name=MY-SESSION-ID
使用Header传递SessionID
默认情况下,Spring Session使用Cookie设置和传递SessionID,但有些时候Cookie限制较多,我们可能希望使用HTTP Header,Spring Session也支持该种方式,我们可以按照以下例子配置一个名为HttpSessionIdResolver的Bean,配置后Spring Session就会采用HTTP Header方式传递SessionID代替Cookie方式。
package com.gacfox.demo.demosession.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.session.web.http.HeaderHttpSessionIdResolver;
import org.springframework.session.web.http.HttpSessionIdResolver;
@Configuration
public class SessionConfig {
@Bean
public HttpSessionIdResolver httpSessionIdResolver() {
return new HeaderHttpSessionIdResolver("APP-TOKEN");
}
}
代码中,我们指定SessionID的请求头键名为APP-TOKEN,此时我们对服务端发起请求,如果请求头中没有携带APP-TOKEN,服务端就会在响应头中返回该值作为SessionID供客户端缓存;如果请求头中携带APP-TOKEN,服务端就会识别并关联Session信息。
注意:HTTP协议中Header是忽略大小写的,SpringSession的Header名指定也是忽略大小写的,所以这里写APP-TOKEN、App-Token、app-token都没有任何区别。在浏览器前端使用FetchAPI等方式获取Header也是忽略大小写的,因此通常我们不必关心大小写,但不能排除某些代码实现不严谨的客户端存在问题。
自定义SessionIdResolver
如果Cookie和Header都无法满足我们的需求,或者我们需要组合多种SessionID传递方式,我们也可以实现HttpSessionIdResolver接口,自定义我们自己的SessionIdResolver。代码参考官方实现的HeaderHttpSessionIdResolver这个类即可。下面例子代码中,实现了同时支持Header和URL传递SessionID的功能。
package com.gacfox.demo.demosession.config;
import java.util.Collections;
import java.util.List;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.session.web.http.HttpSessionIdResolver;
import org.springframework.util.StringUtils;
public class MyHttpSessionIdResolver implements HttpSessionIdResolver {
@Override
public List<String> resolveSessionIds(HttpServletRequest request) {
String headerValue = request.getHeader("Token");
if (StringUtils.hasText(headerValue)) {
return Collections.singletonList(headerValue);
} else {
String getParamValue = request.getParameter("token");
if (StringUtils.hasText(getParamValue)) {
return Collections.singletonList(getParamValue);
}
}
return Collections.emptyList();
}
@Override
public void setSessionId(HttpServletRequest request, HttpServletResponse response, String sessionId) {
response.setHeader("Token", sessionId);
}
@Override
public void expireSession(HttpServletRequest request, HttpServletResponse response) {
response.setHeader("Token", "");
}
}
Spring Session实现原理
上面代码中,我们可以看到Spring Session并没有引入什么新的API来读写Session,我们使用时和原来ServletAPI是一样的,那么这是如何实现的呢?其实Spring Session中包含了一个Filter,它会拦截所有的请求,并对HttpServletRequest对象进行包装,我们Controller代码中注入的已经是包装后的对象了。

通过断点调试,我们可以看到此时注入的对象类型是SessionRepositoryFilter$SessionRepositoryRequestWrapper,它是Spring Session提供的。此时,我们再调用request.getSession()方法,获取到的也自然是封装后的对象了。