【Spring】Spring Session

每个客户端(浏览器)在与服务器端产生连接后,都会在服务器端为该客户端创建一个独有的 Session 对象。Session 就是 Tomcat 服务器内存中保存的一个 Map 对象,所有 Session 对象都放到一个 SessionManager 里进行管理,不同的 Session 代表与不同的客户端进行的会话。

Session 和 Cookie 的关系:

  • 在某个客户端(浏览器)第一次访问服务器时,将创建一个 Session 对象,并保存到服务器端
  • 同时令客户端保存一个 jsessionid = sessionId 的 Cookie。其 key 值是固定的 jsessionid,value 是 sessionId。浏览器关闭前该 Cookie 将一直存在
  • Cookie 中还保存着一个重要信息:Domain(域名)。该值保存着该 Cookie 可以访问的网站域名。当访问一个网站时,浏览器会从目前存活的所有 Cookie 中选出 Domain 匹配当前网站的那些 Cookie,并在访问该网站时在请求头里带上这些 Cookie。
  • 之后 Cookie 存在期间每次访问对应 Domain 的服务器都将带上 Cookie 信息(在请求头中)
  • 浏览器关闭后,清除掉 Cookie,服务器端清除掉 Session

Cookie 是浏览器负责保存Session 是服务器负责保存。Cookie 中保存着 Session 信息,对应唯一的一个 Session。

示意图:

image-20220113161932370

关于 Session 和 Cookie 的完整介绍见文章【JavaWeb】Session 和 Cookie

Session 共享问题

在分布式下存在着 Session 共享问题:

  • 不同微服务间无法共享 Session:因为每个 Session 都是存储在当前微服务的内存中的,所以无法获取其他微服务内存中的 Session 里数据,即:Session 不能跨不同的域名共享
  • 在集群环境下同一个服务的不同实例也无法共享:在负载均衡算法作用下,可能第一次访问节点1,将数据存储到了节点1的 Session 里。而第二次访问节点2时,其内并没有保存节点1里的 Session 数据,所以仍然无法共享

二者的共同原因是:Session 是保存在服务器的内存中的,A 服务内存中的 Session 数据显然无法被 B 服务访问到:

image-20220113162829509

转发不需要考虑 Session 共享问题。因为转发是可以直接在请求域中传递数据的,根本不需要保存到 Session。只有重定向才需要从 Session 中取数据

Session 共享问题解决方案

方案一:Session 复制(同步)

image-20220113211409465

缺点是每个服务都需要保存其他所有服务的 Session 数据,消耗了大量空间。并且 Session 同步占用了大量的网络宽带,降低了服务器集群的业务处理能力。不推荐使用。

方案二:客户端存储

image-20220113214632057

这种方式的缺点也很明显,同样不推荐使用。

方案三:一致性 Hash

image-20220113214753521

在负载均衡时,使用 ip_hash 策略将同一个 ip 的请求负载均衡到同一个服务节点上,这样就能保证同一个 ip 每次都能访问到同一台服务器上的 Session 了。这种方案的缺点不是很大,可以考虑使用。

方案四:统一存储到 Redis(推荐)

image-20220113215228971

为了做到多个微服务间共享 Session,我们可以把所有微服务的 Session 都统一存储到 Redis 中。这样就可以同时解决两种共享问题,既能让同一服务的不同实例访问到彼此的 Session,也能让不同的微服务也能访问到彼此的 Session。

该方案的缺点就是增加了一次网络调用,并且需要修改代码,例如将原本获取 Session 的方法 getSession() 修改成从 Redis 中读取。不过这些缺点可以使用 Spring Session 完美解决

方案五:不同服务的子域 Session 共享(推荐)

image-20220113215710544

通过方案四,我们实现了所有微服务都可以通过 sessionId 从 Redis 中查询某个 Session 数据。但是问题又来了,其他微服务如何得知要查询的 sessionId 是多少呢? 此时就需要先解释一下浏览器是如何让后端服务知道是要访问哪个 sessionId 的:

Domain:每个 Cookie 都有一个 Domain(域名)。该值保存着该 Cookie 可以访问的网站域名。当访问一个网站时,浏览器会从目前存活的所有 Cookie 中选出 Domain 匹配当前网站的那些 Cookie,并在访问该网站时在请求头里带上这些 Cookie。这些 Cookie 里就保存了 jsessionId = sessionid 信息,也就是其要向该 sessionId 对应的 Session 获取数据。这样,浏览器在发出请求时,就会在请求头里携带上该 Cookie,从而携带了要查询的 sessionId,这样后端服务就可以根据该 Id 去 Redis 中查询出对应的 Session 数据了

那么我们只需要保证所有微服务都拥有同一份 Cookie 即可。在某个服务给浏览器发放 Cookie 时,需要指定 Domain当前服务域名的父域的值,这样浏览器在访问该父域的其他子域时也能带上该 Cookie,也就可以获取到当前服务的 Session 数据(Session 存储在 Redis 中,所有服务都可以根据 sessionId 获取到 Session 数据)了。

关于 Domian 域名:

  • 父域:yunmall.com
  • 子域: auth.yunmall.comorder.yunmall.com

例如认证服务 mall-auth-server 的域 auth.yunmall.com 在发放 Session 时,需要设置 Domain 为父域 yunmall.com。这样浏览器在访问其他微服务时也可以带上此 Cookie,也就可以获取到认证服务存储的 Session 数据了。

在 JavaWeb 原生 API 中指定父域的方式:

1
new Cookie("JSESSIONID", ".....").setDomain("yunmall.com");

下面将介绍如何使用 Spring Session 框架快速实现微服务间的 Session 共享。

Spring Session

Spring Session 是 Spring 的项目之一,它提供了一套创建和管理 Servlet HttpSession 的完美方案。Spring Session 提供了 API 和实现,用于管理用户的 Session 信息。除此之外,它还提供了如下特性:

  • 将 session 所保存的状态卸载到特定的外部 session 存储汇总,如 Redis 中,他们能够以独立于应用服务器的方式提供高质量的集群。
  • 控制 sessionid 如何在客户端和服务器之间进行交换,这样的话就能很容易地编写 Restful API ,因为它可以从 HTTP 头信息中获取 sessionid ,而不必再依赖于 cookie。
  • 在非 Web 请求的处理代码中,能够访问 session 数据,比如在 JMS 消息的处理代码中。
  • 支持每个浏览器上使用多个 session,从而能够很容易地构建更加丰富的终端用户体验。
  • 当用户使用 WebSocket 发送请求的时候,能够保持 HttpSession 处于活跃状态。

配置与使用

  1. 导入 Maven 依赖
1
2
3
4
5
6
7
8
9
10
11
<!-- 整合 Spring Session -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>

<!-- 需要 Redis 依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
  1. 配置文件中指定 Session 存储到 Redis 中,并且配置 Redis 信息
1
2
3
4
5
6
7
8
9
spring:
application:
name: yunmall-auth-server
redis:
host: yuyunzhao.cn
port: 6379
password: zhaoyuyun # 设置密码防止被别人利用
session:
store-type: redis
  1. 配置 Session 过期时间
1
2
3
4
5
server:
port: 20000
servlet:
session:
timeout: 30m # Session 30分钟后过期
  1. 在主启动类上添加注解 @EnableRedisHttpSession 开启 Spring Session 功能
1
2
3
4
5
6
7
8
9
@EnableRedisHttpSession  // 整合 Redis 作为 Session 存储地点
@EnableFeignClients
@EnableDiscoveryClient
@SpringBootApplication
public class MallAuthServerApplication {
public static void main(String[] args) {
SpringApplication.run(MallAuthServerApplication.class, args);
}
}
  1. 编写自定义配置类,更改容器中默认的 Spring Session 序列化方式与 Cookie 保存内容。指定存储到 Redis 中序列化方式为 JSON 格式(默认是 JDK 序列化方式),指定 Cookie 中保存的 Domain 为父域 yunmall.com
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Configuration
public class MallSessionConfig {
/**
* 自定义存储到 Redis 中序列化方式为 JSON 格式,默认是 JDK 序列化方式
*/
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
// 使用 JSON 序列化方式
return new GenericJackson2JsonRedisSerializer();
}
/**
* 自定义 Cookie 名和父域
*/
@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer serializer = new DefaultCookieSerializer();
// 设置 Cookie 名
serializer.setCookieName("YUNSESSIONID");
// 设置父域
serializer.setDomainName("yunmall.com");
return serializer;
}
}

如果使用默认的 JDK 序列化方式保存对象,则必须要给要保存的 POJO 实现序列化接口 Serializable

  1. 向 Session 中存储 POJO
1
2
3
4
5
6
7
8
@RequestMapping("/oauth2.0/weibo/success")
public String authorize(String code, HttpSession session) {
MemberResponseVo memberResponseVo = new MemberResponseVo();
// 将 POJO 直接存储到 Session 中,Spring Session 会使用我们自定义的 JSON 序列化器将该对象转换成 JSON 字符串后保存到 Redis 中
session.setAttribute("loginUser", memberResponseVo);
// 重定向到父域
return "redirect:http://yunmall.com";
}

Spring Session 会使用我们自定义的 JSON 序列化器将该对象转换成 JSON 字符串后保存到 Redis 中。同时将该 Session 的 id 以 Cookie 的形式返回给浏览器进行保存。在 Redis 中查看保存结果:

image-20220116100901147

可以看到,POJO 已经成功保存到了 Redis 中,这样其他微服务同样可以在进行上述配置也访问到该数据(其他想访问到 Redis 中 Session 数据的服务必须也得配置 Spring Session)。

  1. 该 Session 的 id 信息将以 Cookie 的形式返回给浏览器进行保存,并且保存的 Cookie 的 Domain 是父域 yunmall.com

image-20220116101358917

这样在重定向到商品服务 mall-product 的首页时,浏览器会带着该 Cookie 进行访问(因为 Domain 匹配上要访问的 URL 了)。这样就可以根据该 Cookie 里存的 sessionid 去 Redis 中查找出对应的 Session 数据,从而成功访问到认证服务存储的 MemberRespVo 数据,并渲染到页面上:

1
2
3
4
5
<li>
<a href="http://auth.yunmall.com/login.html">
你好,[[${session.loginUser.username}]]
</a>
</li>

Spring Session 核心原理

Spring Session 的实现使用了装饰器模式,核心原理是:

  • 将普通的 HttpRequest 进行了包装,将其包装成了 SessionRepositoryRequestWrapper 类型的对象
  • 并且向容器中注入了一个过滤器 SessionRepositoryFilter,在 Controller 的方法执行前先拦截请求,将原生的 HttpRequest 包装成了 SessionRepositoryRequestWrapper
  • 这样 Controller 层在调用HttpRequest.getSession() 时,真正在执行的就是包装后的 SessionRepositoryRequestWrappergetSession() 方法了。
  • 根据我们选择的 Redis 配置 RedisHttpSessionConfiguration,该方法的真正执行逻辑是根据 Cookie 中的 sessionid 去 Redis 里查询该 Session 的真实数据。从而做到了与业务代码的解耦。

过滤器里的代码:

image-20220118205023238