spring-session(二)与spring-boot整合实战

前两篇介绍了 spring-session 的原理,这篇在理论的基础上再实战。
spring-boot 整合 spring-session 的自动配置可谓是开箱即用,极其简洁和方便。这篇文章即介绍 spring-boot 整合 spring-session,这里只介绍基于 RedisSession 的实战。

原理篇是基于 spring-session v1.2.2 版本,考虑到 RedisSession 模块与 spring-session v2.0.6 版本的差异很小,且能够与 spring-boot v2.0.0 兼容,所以实战篇是基于 spring-boot v2.0.0 基础上配置 spring-session。

源码请戮session-example

实战

搭建 spring-boot 工程这里飘过,传送门:https://start.spring.io/

配置 spring-session

引入 spring-session 的 pom 配置,由于 spring-boot 包含 spring-session 的 starter 模块,所以 pom 中依赖:

<dependency>
	<groupId>org.springframework.session</groupId>
	<artifactId>spring-session-data-redis</artifactId>
</dependency>

编写 spring boot 启动类 SessionExampleApplication

/**
 * 启动类
 *
 * @author huaijin
 */
@SpringBootApplication
public class SessionExampleApplication {
<span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> <span class="hljs-built_in">void</span> <span class="hljs-title function_">main</span>(<span class="hljs-params"><span class="hljs-built_in">String</span>[] args</span>) {
    <span class="hljs-title class_">SpringApplication</span>.<span class="hljs-title function_">run</span>(<span class="hljs-title class_">SessionExampleApplication</span>.<span class="hljs-property">class</span>, args);
}

}

配置 application.yml

spring:
  session:
    redis:
      flush-mode: on_save
      namespace: session.example
      cleanup-cron: 0 * * * * *
    store-type: redis
    timeout: 1800
  redis:
    host: localhost
    port: 6379
    jedis:
      pool:
        max-active: 100
        max-wait: 10
        max-idle: 10
        min-idle: 10
    database: 0
编写 controller

编写登录控制器,登录时创建 session,并将当前登录用户存储 sesion 中。登出时,使 session 失效。

/**
 * 登录控制器
 *
 * @author huaijin
 */
@RestController
public class LoginController {
<span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-type">String</span> <span class="hljs-variable">CURRENT_USER</span> <span class="hljs-operator">=</span> <span class="hljs-string">"currentUser"</span>;

<span class="hljs-comment">/**
 * 登录
 *
 * <span class="hljs-doctag">@param</span> loginVo 登录信息
 *
 * <span class="hljs-doctag">@author</span> huaijin
 */</span>
<span class="hljs-meta">@PostMapping("/login.do")</span>
<span class="hljs-keyword">public</span> String <span class="hljs-title function_">login</span><span class="hljs-params">(<span class="hljs-meta">@RequestBody</span> LoginVo loginVo, HttpServletRequest request)</span> {
    <span class="hljs-type">UserVo</span> <span class="hljs-variable">userVo</span> <span class="hljs-operator">=</span> UserVo.builder().userName(loginVo.getUserName())
            .userPassword(loginVo.getUserPassword()).build();
    <span class="hljs-type">HttpSession</span> <span class="hljs-variable">session</span> <span class="hljs-operator">=</span> request.getSession();
    session.setAttribute(CURRENT_USER, userVo);
    System.out.println(<span class="hljs-string">"create session, sessionId is:"</span> + session.getId());
    <span class="hljs-keyword">return</span> <span class="hljs-string">"ok"</span>;
}

<span class="hljs-comment">/**
 * 登出
 *
 * <span class="hljs-doctag">@author</span> huaijin
 */</span>
<span class="hljs-meta">@PostMapping("/logout.do")</span>
<span class="hljs-keyword">public</span> String <span class="hljs-title function_">logout</span><span class="hljs-params">(HttpServletRequest request)</span> {
    <span class="hljs-type">HttpSession</span> <span class="hljs-variable">session</span> <span class="hljs-operator">=</span> request.getSession(<span class="hljs-literal">false</span>);
    session.invalidate();
    <span class="hljs-keyword">return</span> <span class="hljs-string">"ok"</span>;
}

}

编写查询控制器,在登录创建 session 后,使用将 sessionId 置于 cookie 中访问。如果没有 session 将返回错误。

/**
 * 查询
 *
 * @author huaijin
 */
@RestController
@RequestMapping("/session")
public class QuerySessionController {
<span class="hljs-meta">@GetMapping(<span class="hljs-string">"/query.do"</span>)</span>
<span class="hljs-keyword">public</span> String querySessionId(HttpServletRequest request) {
    HttpSession session = request.getSession(<span class="hljs-literal">false</span>);
    <span class="hljs-keyword">if</span> (session == <span class="hljs-literal">null</span>) {
        <span class="hljs-keyword">return</span> <span class="hljs-string">"error"</span>;
    }
    System.<span class="hljs-keyword">out</span>.println(<span class="hljs-string">"current's user is:"</span> + session.getId() +  <span class="hljs-string">"in session"</span>);
    <span class="hljs-keyword">return</span> <span class="hljs-string">"ok"</span>;
}

}

编写 Session 删除事件监听器

Session 删除事件监听器用于监听登出时使 session 失效的事件源。

/**
 * session 事件监听器
 *
 * @author huaijin
 */
@Component
public class SessionEventListener implements ApplicationListener<SessionDeletedEvent> {
<span class="hljs-keyword">private</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> <span class="hljs-type">String</span> <span class="hljs-variable">CURRENT_USER</span> <span class="hljs-operator">=</span> <span class="hljs-string">"currentUser"</span>;

<span class="hljs-meta">@Override</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">onApplicationEvent</span><span class="hljs-params">(SessionDeletedEvent event)</span> {
    <span class="hljs-type">Session</span> <span class="hljs-variable">session</span> <span class="hljs-operator">=</span> event.getSession();
    <span class="hljs-type">UserVo</span> <span class="hljs-variable">userVo</span> <span class="hljs-operator">=</span> session.getAttribute(CURRENT_USER);
    System.out.println(<span class="hljs-string">"invalid session's user:"</span> + userVo.toString());
}

}

验证测试

编写 spring-boot 测试类,测试 controller,验证 spring-session 是否生效。


/**
 * 测试 Spring-Session:
 * 1. 登录时创建 session
 * 2. 使用 sessionId 能正常访问
 * 3.session 过期销毁,能够监听销毁事件
 *
 * @author huaijin
 */
@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class SpringSessionTest {
<span class="hljs-meta">@Autowired</span>
<span class="hljs-keyword">private</span> MockMvc mockMvc;


<span class="hljs-meta">@Test</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">testLogin</span><span class="hljs-params">()</span> <span class="hljs-keyword">throws</span> Exception {
    <span class="hljs-type">LoginVo</span> <span class="hljs-variable">loginVo</span> <span class="hljs-operator">=</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">LoginVo</span>();
    loginVo.setUserName(<span class="hljs-string">"admin"</span>);
    loginVo.setUserPassword(<span class="hljs-string">"admin@123"</span>);
    <span class="hljs-type">String</span> <span class="hljs-variable">content</span> <span class="hljs-operator">=</span> JSON.toJSONString(loginVo);

    <span class="hljs-comment">// mock登录</span>
    <span class="hljs-type">ResultActions</span> <span class="hljs-variable">actions</span> <span class="hljs-operator">=</span> <span class="hljs-built_in">this</span>.mockMvc.perform(post(<span class="hljs-string">"/login.do"</span>)
            .content(content).contentType(MediaType.APPLICATION_JSON))
            .andExpect(status().isOk()).andExpect(content().string(<span class="hljs-string">"ok"</span>));
    <span class="hljs-type">String</span> <span class="hljs-variable">sessionId</span> <span class="hljs-operator">=</span> actions.andReturn()
            .getResponse().getCookie(<span class="hljs-string">"SESSION"</span>).getValue();

    <span class="hljs-comment">// 使用登录的sessionId mock查询</span>
    <span class="hljs-built_in">this</span>.mockMvc.perform(get(<span class="hljs-string">"/session/query.do"</span>)
            .cookie(<span class="hljs-keyword">new</span> <span class="hljs-title class_">Cookie</span>(<span class="hljs-string">"SESSION"</span>, sessionId)))
            .andExpect(status().isOk()).andExpect(content().string(<span class="hljs-string">"ok"</span>));

    <span class="hljs-comment">// mock登出</span>
    <span class="hljs-built_in">this</span>.mockMvc.perform(post(<span class="hljs-string">"/logout.do"</span>)
            .cookie(<span class="hljs-keyword">new</span> <span class="hljs-title class_">Cookie</span>(<span class="hljs-string">"SESSION"</span>, sessionId)))
            .andExpect(status().isOk()).andExpect(content().string(<span class="hljs-string">"ok"</span>));
}

}

测试类执行结果:

create session, sessionId is:429cb0d3-698a-475a-b3f1-09422acf2e9c
current's user is:429cb0d3-698a-475a-b3f1-09422acf2e9cin session
invalid session's user:UserVo{userName='admin', userPassword='admin@123'

登录时创建 Session,存储当前登录用户。然后在以登录响应返回的 SessionId 查询用户。最后再登出使 Session 过期。

spring-boot 整合 spring-session 自动配置原理

前两篇文章介绍 spring-session 原理时,总结 spring-session 的核心模块。这节中探索 spring-boot 中自动配置如何初始化 spring-session 的各个核心模块。

spring-boot-autoconfigure 模块中包含了 spinrg-session 的自动配置。包 org.springframework.boot.autoconfigure.session 中包含了 spring-session 的所有自动配置项。

其中 RedisSession 的核心配置项是 RedisHttpSessionConfiguration 类。

@Configuration
@ConditionalOnClass({RedisTemplate.class, RedisOperationsSessionRepository.class})
@ConditionalOnMissingBean(SessionRepository.class)
@ConditionalOnBean(RedisConnectionFactory.class)
@Conditional(ServletSessionCondition.class)
@EnableConfigurationProperties(RedisSessionProperties.class)
class RedisSessionConfiguration {
<span class="hljs-meta">@Configuration</span>
<span class="hljs-keyword">public</span> <span class="hljs-keyword">static</span> <span class="hljs-keyword">class</span> <span class="hljs-title class_">SpringBootRedisHttpSessionConfiguration</span>
		<span class="hljs-keyword">extends</span> <span class="hljs-title class_">RedisHttpSessionConfiguration</span> {

	<span class="hljs-comment">// 加载application.yml或者application.properties中自定义的配置项:</span>
	<span class="hljs-comment">// 命名空间:用于作为session redis key的一部分</span>
	<span class="hljs-comment">// flushmode:session写入redis的模式</span>
	<span class="hljs-comment">// 定时任务时间:即访问redis过期键的定时任务的cron表达式</span>
	<span class="hljs-meta">@Autowired</span>
	<span class="hljs-keyword">public</span> <span class="hljs-keyword">void</span> <span class="hljs-title function_">customize</span><span class="hljs-params">(SessionProperties sessionProperties,
			RedisSessionProperties redisSessionProperties)</span> {
		<span class="hljs-type">Duration</span> <span class="hljs-variable">timeout</span> <span class="hljs-operator">=</span> sessionProperties.getTimeout();
		<span class="hljs-keyword">if</span> (timeout != <span class="hljs-literal">null</span>) {
			setMaxInactiveIntervalInSeconds((<span class="hljs-type">int</span>) timeout.getSeconds());
		}
		setRedisNamespace(redisSessionProperties.getNamespace());
		setRedisFlushMode(redisSessionProperties.getFlushMode());
		setCleanupCron(redisSessionProperties.getCleanupCron());
	}

}

}

RedisSessionConfiguration 配置类中嵌套 SpringBootRedisHttpSessionConfiguration 继承了 RedisHttpSessionConfiguration 配置类。首先看下该配置类持有的成员。

@Configuration
@EnableScheduling
public class RedisHttpSessionConfiguration extends SpringHttpSessionConfiguration
		implements BeanClassLoaderAware, EmbeddedValueResolverAware, ImportAware,
		SchedulingConfigurer {
<span class="hljs-comment">// 默认的cron表达式,application.yml可以自定义配置</span>
static <span class="hljs-keyword">final</span> <span class="hljs-type">String</span> <span class="hljs-type">DEFAULT_CLEANUP_CRON</span> = <span class="hljs-string">"0 * * * * *"</span>;

<span class="hljs-comment">// session的有效最大时间间隔, application.yml可以自定义配置</span>
<span class="hljs-keyword">private</span> <span class="hljs-type">Integer</span> maxInactiveIntervalInSeconds = <span class="hljs-type">MapSession</span>.<span class="hljs-type">DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS</span>;

<span class="hljs-comment">// session在redis中的命名空间,主要为了区分session,application.yml可以自定义配置</span>
<span class="hljs-keyword">private</span> <span class="hljs-type">String</span> redisNamespace = <span class="hljs-type">RedisOperationsSessionRepository</span>.<span class="hljs-type">DEFAULT_NAMESPACE</span>;

<span class="hljs-comment">// session写入Redis的模式,application.yml可以自定义配置</span>
<span class="hljs-keyword">private</span> <span class="hljs-type">RedisFlushMode</span> redisFlushMode = <span class="hljs-type">RedisFlushMode</span>.<span class="hljs-type">ON_SAVE</span>;

<span class="hljs-comment">// 访问过期Session集合的定时任务的定时时间,默认是每整分运行任务</span>
<span class="hljs-keyword">private</span> <span class="hljs-type">String</span> cleanupCron = <span class="hljs-type">DEFAULT_CLEANUP_CRON</span>;

<span class="hljs-keyword">private</span> <span class="hljs-type">ConfigureRedisAction</span> configureRedisAction = <span class="hljs-keyword">new</span> <span class="hljs-type">ConfigureNotifyKeyspaceEventsAction</span>();

<span class="hljs-comment">// spring-data-redis的redis连接工厂</span>
<span class="hljs-keyword">private</span> <span class="hljs-type">RedisConnectionFactory</span> redisConnectionFactory;

<span class="hljs-comment">// spring-data-redis的RedisSerializer,用于序列化session中存储的attributes</span>
<span class="hljs-keyword">private</span> <span class="hljs-type">RedisSerializer</span>&lt;<span class="hljs-type">Object</span>&gt; defaultRedisSerializer;

<span class="hljs-comment">// session时间发布者,默认注入的是AppliationContext实例</span>
<span class="hljs-keyword">private</span> <span class="hljs-type">ApplicationEventPublisher</span> applicationEventPublisher;

<span class="hljs-comment">// 访问过期session键的定时任务的调度器</span>
<span class="hljs-keyword">private</span> <span class="hljs-type">Executor</span> redisTaskExecutor;

<span class="hljs-keyword">private</span> <span class="hljs-type">Executor</span> redisSubscriptionExecutor;

<span class="hljs-keyword">private</span> <span class="hljs-type">ClassLoader</span> classLoader;

<span class="hljs-keyword">private</span> <span class="hljs-type">StringValueResolver</span> embeddedValueResolver;

}

该配置类中初始化了 RedisSession 的最为核心模块之一 RedisOperationsSessionRepository。

@Bean
public RedisOperationsSessionRepository sessionRepository() {
	// 创建 RedisOperationsSessionRepository
	RedisTemplate<Object, Object> redisTemplate = createRedisTemplate();
	RedisOperationsSessionRepository sessionRepository = new RedisOperationsSessionRepository(
			redisTemplate);
	// 设置 Session Event 发布者。如果对此迷惑,传送门:https://www.cnblogs.com/lxyit/p/9719542.html
	sessionRepository.setApplicationEventPublisher(this.applicationEventPublisher);
	if (this.defaultRedisSerializer != null) {
		sessionRepository.setDefaultSerializer(this.defaultRedisSerializer);
	}
	// 设置默认的 Session 最大有效期间隔
	sessionRepository
			.setDefaultMaxInactiveInterval(this.maxInactiveIntervalInSeconds);
	// 设置命名空间
	if (StringUtils.hasText(this.redisNamespace)) {
		sessionRepository.setRedisKeyNamespace(this.redisNamespace);
	}
	// 设置写 redis 的模式
	sessionRepository.setRedisFlushMode(this.redisFlushMode);
	return sessionRepository;
}

同时也初始化了 Session 事件监听器 MessageListener 模块

@Bean
public RedisMessageListenerContainer redisMessageListenerContainer() {
	// 创建 MessageListener 容器,这属于 spring-data-redis 范畴,略过
	RedisMessageListenerContainer container = new RedisMessageListenerContainer();
	container.setConnectionFactory(this.redisConnectionFactory);
	if (this.redisTaskExecutor != null) {
		container.setTaskExecutor(this.redisTaskExecutor);
	}
	if (this.redisSubscriptionExecutor != null) {
		container.setSubscriptionExecutor(this.redisSubscriptionExecutor);
	}
	// 模式订阅 redis 的 __keyevent@*:expired 和 __keyevent@*:del 通道,
	// 获取 redis 的键过期和删除事件通知
	container.addMessageListener(sessionRepository(),
			Arrays.asList(new PatternTopic("__keyevent@*:del"),
					new PatternTopic("__keyevent@*:expired")));
	// 模式订阅 redis 的 ${namespace}:event:created:* 通道,当该向该通道发布消息,
	// 则 MessageListener 消费消息并处理
	container.addMessageListener(sessionRepository(),
			Collections.singletonList(new PatternTopic(
					sessionRepository().getSessionCreatedChannelPrefix() + "*")));
	return container;
}

上篇文章中介绍到的 spring-session event 事件原理,spring-session 在启动时监听 Redis 的 channel,使用 Redis 的键空间通知处理 Session 的删除和过期事件和使用 Pub/Sub 模式处理 Session 创建事件。

关于 RedisSession 的存储管理部分已经初始化,但是 spring-session 的另一个基础设施模块 SessionRepositoryFilter 是在 RedisHttpSessionConfiguration 父类 SpringHttpSessionConfiguration 中初始化。

@Bean
public <S extends Session> SessionRepositoryFilter<? extends Session> springSessionRepositoryFilter(
		SessionRepository<S> sessionRepository) {
	SessionRepositoryFilter<S> sessionRepositoryFilter = new SessionRepositoryFilter<>(
			sessionRepository);
	sessionRepositoryFilter.setServletContext(this.servletContext);
	sessionRepositoryFilter.setHttpSessionIdResolver(this.httpSessionIdResolver);
	return sessionRepositoryFilter;
}

spring-boot 整合 spring-session 配置的层次:

RedisSessionConfiguration
	|_ _ SpringBootRedisHttpSessionConfiguration
			|_ _ RedisHttpSessionConfiguration
					|_ _ SpringHttpSessionConfiguration

回顾思考 spring-boot 自动配置 spring-session,非常合理。

  • SpringHttpSessionConfiguration 是 spring-session 本身的配置类,与 spring-boot 无关,毕竟 spring-session 也可以整合单纯的 spring 项目,只需要使用该 spring-session 的配置类即可。
  • RedisHttpSessionConfiguration 用于配置 spring-session 的 Redission,毕竟 spring-session 还支持其他的各种 session:Map/JDBC/MogonDB 等,将其从 SpringHttpSessionConfiguration 隔离开来,遵循开闭原则和接口隔离原则。但是其必须依赖基础的 SpringHttpSessionConfiguration,所以使用了继承。RedisHttpSessionConfiguration 是 spring-session 和 spring-data-redis 整合配置,需要依赖 spring-data-redis。
  • SpringBootRedisHttpSessionConfiguration 才是 spring-boot 中关键配置
  • RedisSessionConfiguration 主要用于处理自定义配置,将 application.yml 或者 application.properties 的配置载入。

Tips:
配置类也有相当强的设计模式。遵循开闭原则:对修改关闭,对扩展开放。遵循接口隔离原则:变化的就要单独分离,使用不同的接口隔离。SpringHttpSessionConfiguration 和 RedisHttpSessionConfiguration 的设计深深体现这两大原则。

参考

Spring Session