Session 共享

集群会话方案

前面所讲的会话管理者都是单机上的会话管理,如果当前是集群环境,前面所讲的会话管理方案就会失效。需要注意的是,我们这里讨论的范畴是有状态登录,如果用户采用无状态的认证方案,那么就不涉及会话,也就不存在接下来要讨论的问题。

我们先来看一幅简单的集群架构图,如图7-3所示。

image 2024 04 13 17 09 31 687
Figure 1. 图7-3 简化版的集群架构图

如图7-3所示,如果项目是集群化部署,我们可以采用 Nginx 做反向代理服务器,所有到达 Nginx 上的请求被转发到不同的 Tomcat 实例上,每个 Tomcat 各自保存自已的会话信息。根据前面的讲解,Spring Security 中通过维护一张会话注册表来实现会话的并发管理,现在每个 Tomcat 上都有一张会话注册表,所以如果还按照之前的方式去配置会话并发管理,那必然是不生效的。

为了解决集群环境下的会话问题,我们有三种方案:

  1. Session 复制:多个服务之间互相复制 Session 信息,这样每个服务中都包含有所有的 Session 信息了,Tomcat 通过 IP 组播对这种方案提供支持。但是这种方案占用带宽、有时延,服务数量越多效率越低,所以这种方案使用较少。

  2. Session 粘滞:也叫会话保持,就是在 Nginx 上通过一致性 Hash,将 Hash 结果相同的请求总是分发到一个服务上去。这种方案可以解决一部分集群会话带来的问题,但是无法解决集群中的会话并发管理问题。

  3. Session 共享:Session 共享就是将不同服务的会话统一放在一个地方,所有的服务共享一个会话。一般使用一些 Key-Value 数据库来存储 Session,例如 Memcached 或者 Redis 等,比较常见的方案是使用 Redis 存储,Session 共享方案由于其简便性与稳定性,是目前使用较多的方案。Session 共享架构图如图7-4所示。

image 2024 04 13 17 20 16 050
Figure 2. 图7-4简化版的Session共享架构图

Session 共享目前使用比较多的是 spring-session,利用 spring-session 可以方便地实现 Session 的管理。

实战

首先启动一个 Redis 实例。

新建 Spring Boot 工程,分别引入 Web、Redis、Spring Security 以及 Spring Session 依赖,代码如下:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>
xml

接下来在 application.properties 中配置 Redis 连接信息:

spring.redis.password=123
spring.redis.host=127.0.0.1
spring.redis.port=6379
bash

再来提供一个 SecurityConfig,代码如下:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    FindByIndexNameSessionRepository sessionRepository;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.inMemoryAuthentication()
                .withUser("javaboy")
                .password("{noop}123")
                .roles("admin");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .anyRequest().authenticated()
                .and()
                .formLogin()
                .and()
                .csrf()
                .disable()
                .sessionManagement()
                .maximumSessions(1)
                .sessionRegistry(sessionRegistry());
    }
    @Bean
    SpringSessionBackedSessionRegistry sessionRegistry() {
        return new SpringSessionBackedSessionRegistry(sessionRepository);
    }
}
java

在这段配置中,我们首先注入了一个 FindByIndexNameSessionRepository 对象,这是一个会话的存诸和加载工具。在前面的案例中,会话信息是保存在内存中的,现在会话信息保存在 Redis 中,具体的保存和加载过程则是由 FindByIndexNameSessionRepository 接口的实现类来完成,默认是 RedisIndexedSessionRepository,即我们一开始注入的实际上是一个 RedisIndexedSessionRepository 类型的对象。

接下来我们还配置了一个 SpringSessionBackedSessionRegistry 实例,构建时传入了 sessionRepository。SpringSessionBackedSessionRegistry 继承自 SessionRegistry,用来维护会话信息注册表。

最后在 HttpSecurity 中配置 sessionRegistry 即可,相当于 spring-session 提供的 SpringSessionBackedSessionRegistry 接管了会话信息注册表的维护工作。

需要注意的是,引入了 spring-session 之后,不再需要配置 HttpSessionEventPublisher 实例,因为 spring-session 中通过 SessionRepositoryFilter 将请求对象重新封装为 SessionRepositoryRequestWrapper,并重写了 getSession 方法。在重写的 getSession 方法中,最终返回的是 HttpSessionWrapper 实例,而在 HttpSessionWrapper 定义时,就重写了 invalidate 方法。当调用会话的 invalidate 方法去销毁会话时,就会调用 RedisIndexedSessionRepository 中的方法,从 Redis 中移除对应的会话信息,所以不再需要 HttpSessionEventPublisher 实例。

最后再配置一个测试 Controller:

@RestController
public class HelloController {
    @GetMapping("/")
    public String hello(HttpSession session) {
        return session.getClass().toString();
    }
}
java

在测试接口中返回 HttpSession 的类型以验证我们前面的讲解。

配置完成后,我们对项目进行打包,单击 IntelliJIDEA 右侧的 Maven→Lifecycle→package 进行打包,如图7-5所示。

image 2024 04 13 17 31 43 626
Figure 3. 图7-5 单击按钮对项目进行打包

打包完成后,进入 target 目录下,会有一个 jar,执行如下命令,分别启动两个实例:

java -jar sessionshare-0.0.1-SNAPSHOT.jar --server.port=8080
java -jar sessionshare-0.0.1-SNAPSHOT.jar --server.port=8081
bash

两个实例启动完成后,这两个实例实际上共用了一个会话。接下来准备两个浏览器,先 用浏览器 1 访问 8080 端口的项目,并完成登录操作:然后再用浏览器 2 访问 8081 端口的项目并完成登录操作。当浏览器 2 登录成功后,我们再去刷新浏览器1,此时发现会话已经过期,说明集群环境下的会话管理己经生效。