一、使用Redis共享Session原理
所有服务器的session信息都存储到了同一个Redis集群中,即所有的服务都将 Session 的信息存储到 Redis 集群中,无论是对 Session 的注销、更新都会同步到集群中,达到了 Session 共享的目的。
Cookie 保存在客户端浏览器中,而 Session 保存在服务器上。客户端浏览器访问服务器的时候,服务器把客户端信息以某种形式记录在服务器上,这就是 Session。客户端浏览器再次访问时只需要从该 Session 中查找该客户的状态就可以了。
共享数据都会放到外部缓存容器中。
二、SessionManager的配置
重要提示:
JDK序列化策略来序列化SimpleSession,因此这就是为什么我们把SimpleSession转成字节存储的原因。所以说,RedisTemplate的value的序列化策略必须要使用JdkSerializationRedisSerializer。
为了能够让多个服务器共享Session,我们需要把Session存储到外部的缓存设备。
我们需要让session在集群中共享,就需要替换Shiro默认的sessionManager。我们需要使用DefaultWebSessionManager作为SessionManager。
<!-- securityManager 对象-->
<bean id="securityManager" class="org.apache.shiro.web.mgt.DefaultWebSecurityManager">
...
<!-- 引入sessionManager-->
<property name="sessionManager" ref="sessionManager"/>
</bean>
<!-- 会话管理器 ,时间单位是毫秒-->
<bean id="sessionManager" class="org.apache.shiro.web.session.mgt.DefaultWebSessionManager">
<!--去掉URL中的JSESSIONID-->
<property name="sessionIdUrlRewritingEnabled" value="false"/>
<!-- 会话存活时间(毫秒),最好与Redis中缓存时间一致 -->
<property name="globalSessionTimeout" value="600000"/><!-- 10分钟 -->
<!-- 是否删除无效的session-->
<property name="deleteInvalidSessions" value="true"/>
<!-- 扫描session线程,负责清理超时会话 -->
<property name="sessionValidationSchedulerEnabled" value="true"/>
<!-- 使用的是QuartZ组件来定时清理-->
<property name="sessionValidationScheduler" ref="sessionValidationScheduler"/>
<!-- session需要使用会话cookie模版-->
<property name="sessionIdCookieEnabled" value="true"/>
<property name="sessionIdCookie" ref="sessionIdCookie"/>
<!-- 对session进行增删错改查的实现类
,如果不自己注入sessionDAO,defaultWebSessionManager会使用MemorySessionDAO用内存做为默认实现类-->
<!-- javascript:void(0) -->
<property name="sessionDAO" ref="sessionDAO"/>
</bean>
<!-- 会话验证调度器 ,时间单位是毫秒-->
<bean id="sessionValidationScheduler" class="org.apache.shiro.session.mgt.
quartz.QuartzSessionValidationScheduler">
<property name="sessionValidationInterval" value="30000"/>
<property name="sessionManager" ref="sessionManager"/>
</bean>
<!-- 会话 ID 生成器 -->
<bean id="sessionIdGenerator" class="org.apache.shiro.session.mgt.eis.JavaUuidSessionIdGenerator"/>
<!-- 自定义session会话存储的实现类 ,使用Redis来存储共享session,达到分布式部署目的-->
<bean id="sessionDAO" class="com.jay.shiro.RedisSessionDao">
<property name="redisService" ref="redisService"/>
<!-- Session的过期时间,单位秒。session将存储在Redis集群中实现共享-->
<!-- Redis设置半小时的缓存失效时间 -->
<property name="expireSeconds" value="600000"/>
</bean>
SessionManager中的SessionDao是自定义session会话存储的实现类 ,使用Redis来存储共享session,达到分布式部署目的。SessionDao的代码如下。
package com.jay.shiro;
import com.jay.redis.RedisService;
import org.apache.shiro.session.Session;
import org.apache.shiro.session.UnknownSessionException;
import org.apache.shiro.session.mgt.eis.AbstractSessionDAO;
import org.apache.shiro.util.CollectionUtils;
import org.slf4j.Logger;
import java.io.Serializable;
import java.nio.charset.Charset;
import java.util.Collection;
import java.util.Set;
import static com.google.common.collect.Sets.newHashSet;
import static com.jay.util.DateTransformTools.DEFAULT_FORMAT;
import static com.jay.util.DateTransformTools.dateToDateStr;
import static org.slf4j.LoggerFactory.getLogger;
/**
* @author jay.zhou
* @date 2019/1/15
* @time 13:29
*/
public final class RedisSessionDao extends AbstractSessionDAO {
private static final Logger LOGGER = getLogger(RedisSessionDao.class);
/**
* 此编码需要与 RedisServiceImpl 类中编码一致
* 用于解析每个session的Key
*/
private static final String DEFAULT_CHARSET = "UTF-8";
/**
* Redis接口服务
*/
private RedisService redisService;
/**
* 过期时间
*/
private Long expireSeconds;
/**
* shiro-redis的session对象前缀
*/
private String keyPrefix = "shiro_redis_session:";
public RedisService getRedisService() {
return redisService;
}
public void setRedisService(RedisService redisService) {
this.redisService = redisService;
}
public Long getExpireSeconds() {
return expireSeconds;
}
public void setExpireSeconds(Long expireSeconds) {
this.expireSeconds = expireSeconds;
}
public String getKeyPrefix() {
return keyPrefix;
}
public void setKeyPrefix(String keyPrefix) {
this.keyPrefix = keyPrefix;
}
@Override
public void update(Session session) throws UnknownSessionException {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("更新Session:{}", session.getId());
}
this.saveSession(session);
}
@Override
public void delete(Session session) {
if (session == null || session.getId() == null) {
LOGGER.error("session对象(或者sessionId)为空.");
return;
}
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("删除Session:{}", session.getId());
}
//通过sessionId删除session
redisService.del(this.getByteKey(session.getId()));
}
/**
* 统计当前活动的session
*
* @return 当前活动的session
*/
@Override
public Collection<Session> getActiveSessions() {
final Set<Session> sessions = newHashSet();
//获取缓存中匹配key值的所有键
final Set<byte[]> keys = redisService.keys(this.keyPrefix + "*");
if (!CollectionUtils.isEmpty(keys)) {
for (byte[] key : keys) {
//添加到set集合中
byte[] bytes = redisService.get(key);
Session session = SerializerUtil.deserialize(bytes);
sessions.add(session);
}
}
//shiro的session为我们提供了大量的API接口
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("==========>>统计活动Session(开始)总计活动Session:{}条.<<==========", sessions.size());
for (Session session : sessions) {
LOGGER.debug("ID:{}", session.getId());
LOGGER.debug("有效期:{}秒", session.getTimeout() / 1000);
LOGGER.debug("创建时间:{}", dateToDateStr(session.getStartTimestamp(), DEFAULT_FORMAT));
LOGGER.debug("上次使用时间:{}", dateToDateStr(session.getStartTimestamp(), DEFAULT_FORMAT));
LOGGER.debug(".......................................................................");
}
LOGGER.debug("==========>>统计活动Session(结束)总计活动Session:{}条.<<==========", sessions.size());
}
return sessions;
}
@Override
protected Serializable doCreate(Session session) {
//分配sessionId
final Serializable sessionId = this.generateSessionId(session);
this.assignSessionId(session, sessionId);
//保存session并存储到Redis集群中
this.saveSession(session);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("创建Session:{}", sessionId);
}
return sessionId;
}
@Override
protected Session doReadSession(Serializable sessionId) {
if (sessionId == null) {
LOGGER.error("sessionId为空.");
return null;
}
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("读取Session:{}", sessionId);
}
//与saveSession是反操作,通过sessionId获取Key的字节数据
final byte[] key = this.getByteKey(sessionId);
//再通过key的字节数据找到value的字节数据
final byte[] value = redisService.get(key);
//最后再反序列化得到session对象
return SerializerUtil.deserialize(value);
}
/**
* 保存session
* sessionId -> key[]
* session -> value[]
*
* @param session Session对象
* @throws UnknownSessionException 未知Session异常
*/
private void saveSession(Session session) throws UnknownSessionException {
if (session == null || session.getId() == null) {
LOGGER.error("session对象(或者sessionId)为空.");
return;
}
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("保存Session:{}", session.getId());
}
//sessionId -> key[]
final byte[] key = getByteKey(session.getId());
//session -> value[]
final byte[] value = SerializerUtil.serialize(session);
session.setTimeout(getExpireSeconds());
//save To Redis
this.redisService.setEx(key, value, getExpireSeconds());
}
/**
* 获得byte[]型的key
*
* @param sessionId sessionId
* @return byte[]型的key
*/
private byte[] getByteKey(Serializable sessionId) {
final String preKey = this.keyPrefix + sessionId;
return preKey.getBytes(Charset.forName(DEFAULT_CHARSET));
}
}
三、测试
我们需要将项目复制为两个,第一个项目的端口是8080,第二个项目的端口改为 9090,依次启动两个项目。测试的时候,确保你的Redis服务器是开着的。
在8080端口项目中尝试访问受限制页面,会被重定向到登录页面。在登录页面输入jay / 123456,登录成功后。在9090端口项目一样点击第一个超链接尝试访问受限制页面,这次发现可以成功请求到后台JSON数据。然后在9090端口尝试退出登录,再回到8080端口的项目尝试访问受限制页面,发现用户已经退出,请求被重定向到登录页面。
上述现象说明,使用Redis实现分布式Session共享成功。
大宇能够成功搭建一个分布式项目,很大一部分原因就是站在巨人的肩膀上。特此鸣谢下方博客与博主。
参考文章: Apache shiro集群实现 (六)分布式集群系统下的高可用session解决方案---Session共享
Shiro 分布式架构下 Session 的共享实现
Shiro在Spring的会话管理(session)
序列化工具
四、源码下载
本章节项目源码:点击我下载源码
----------------------------------------------------分割线-------------------------------------------------------