场景:
最近接到一个需求,为了方便用户使用,系统的同一个用户账号可以在5个浏览器是登录,第六个登录的把第一个挤掉,而且用户登录后长时间不操作是需要自动过期的,也就是说需要有session过期时间;
从需求来看,用户被强制下线有两种可能,一个是session过期,二个是被后面登录的挤掉;第6个登录的把第1个登录的挤掉,那么我们就需要统计登录个数以及找到最早登录的信息;这么一想就联想到了Redis,可以使用Redis的String和ZSet数据结构来实现;
String存储的作用:session过期,需要重新登录;
ZSet存储的作用:后面登录的挤掉最先登录的;
ZSet具有排序的功能;用户登录后就是一个会话,就会产生一个session,不同的session不同的sessionid,所以:
一、使用sessionID作为key,userName作为value并且设置过期时间存储到redis的String类型的数据结构里;
二、然后把userName作为key,sessionID作为value,存储到ZSet类型的数据结构里,使用系统时间作为分数,设置过期时间;这样,ZSet就会根据时间进行排序,每次登录都先根据userName统计一次ZSet中存储的的个数,如果大于等于5,就删除最早存储的一个。(set里面,更新一个value 剩下的四个value过期时间也都更新,所以,只要同一个账号在过期时间之前登录,set的过期时间就刷新了,可以说,只要string里面存储的sessionId不过期,对应的set就不过期)
登录时通过redis的zcart方法判断ZSet中username对应的登录数量,如果大于等于5就清除最早登录的一组数据,然后把当前登录的信息存储到redis中。否则,直接存储到redis当中即可。
1、ZSet数据存储结构
2、ZString数据存储结构
连接redis这里就不写了,可以使用JedisCluster,也可以使用spring-data-redis针对jedis提供的一个高度封装的“RedisTemplate”类(简单方便),这里使用的是JedisCluster,为了方便操作,这里写了两个操作工具类:
package com.zhh.redis.util;
import org.apache.log4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import redis.clients.jedis.JedisCluster;
/**
* Redis 操作工具类 :String数据结构
* @author zhaoheng
* 2018-06-27
*/
@Component
public class RedisJpaTemplateString {
private static final Logger log = Logger.getLogger(RedisJpaTemplateString.class.getName());
@Autowired
private JedisCluster jedisCluster;
/**
* 默认过期时间 (单位:秒)
*/
private int expireTime = 24*60*60;
/**
* 用于隔开缓存前缀与缓存键值
*/
private static final String KEY_SPLIT = ":";
/**
* 设置缓存
* @param prefix 缓存前缀(用于区分缓存,防止缓存键值重复)
* @param key 缓存key
* @param value 缓存value
*/
public void setWithPrefix(String prefix, String key, String value) {
jedisCluster.set(prefix + KEY_SPLIT + key, value);
log.info("RedisJpaTemplateString:set cache key="+prefix + KEY_SPLIT + key+",value="+value);
}
/**
* 设置缓存,并且自己指定过期时间
* @param prefix
* @param key
* @param value
* @param expireTime 过期时间(秒)
*/
public void setWithExpireTime(String prefix, String key, String value, int expireTime) {
jedisCluster.setex(prefix + KEY_SPLIT + key, expireTime, value);
log.info("RedisJpaTemplateString:setWithExpireTime cache key="+prefix + KEY_SPLIT + key+",value="+value+",expireTime="+expireTime);
}
/**
* 设置缓存,并且由配置文件指定过期时间
* @param prefix
* @param key
* @param value
*/
public void setWithExpireTime(String prefix, String key, String value) {
jedisCluster.setex(prefix + KEY_SPLIT + key, expireTime, value);
log.info("RedisJpaTemplateString:setWithExpireTime cache key="+prefix + KEY_SPLIT + key+",value="+value+",expireTime="+expireTime);
}
/**
* 设置 key 的过期时间(如果已经存在过期时间则刷新过期时间)
* @param prefix 缓存前缀
* @param key 缓存key
* @param expireTime 过期时间
* @return 设置成功返回 1
*/
public Long setOrRefurbishExpireTime(String prefix, String key, int expireTime) {
long number = jedisCluster.expire(prefix + KEY_SPLIT + key, expireTime);
log.info("RedisJpaTemplateString:setWithExpireTime cache key="+prefix + KEY_SPLIT + key+" expireTime="+expireTime+" 执行返回结果:"+number);
return number;
}
/**
* 获取指定key的缓存
* @param prefix
* @param key
*/
public String getWithPrefix(String prefix, String key) {
String value = jedisCluster.get(prefix + KEY_SPLIT + key);
log.info("RedisJpaTemplateString:get cache key="+prefix + KEY_SPLIT + key+",value="+value);
return value;
}
/**
* 删除指定key的缓存
* @param prefix
* @param key
*/
public void deleteWithPrefix(String prefix, String key) {
jedisCluster.del(prefix + KEY_SPLIT + key);
log.info("RedisJpaTemplateString:delete cache key="+prefix + KEY_SPLIT + key);
}
/**
* 检查给定 key 是否存在 若 key 存在返回 true ,否则返回false
* @param prefix 缓存前缀
* @param key 缓存key
* @return
*/
public Boolean exists(String prefix, String key) {
Boolean flag = jedisCluster.exists(prefix + KEY_SPLIT + key);
log.info("RedisJpaTemplateString:exists cache key="+prefix + KEY_SPLIT + key+" 执行返回结果:"+flag);
return flag;
}
public JedisCluster getJedisCluster() {
return jedisCluster;
}
public void setJedisCluster(JedisCluster jedisCluster) {
this.jedisCluster = jedisCluster;
}
public int getExpireTime() {
return expireTime;
}
public void setExpireTime(int expireTime) {
this.expireTime = expireTime;
}
}
package com.zhh.redis.util;
import java.util.Set;
import org.apache.log4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import redis.clients.jedis.JedisCluster;
/**
* Redis 操作工具类 :ZSet数据结构
* @author zhaoheng
* 2018-06-26
*/
@Component
public class RedisJpaTemplateZSet {
private static final Logger log = Logger.getLogger(RedisJpaTemplateZSet.class.getName());
@Autowired
private JedisCluster jedisCluster;
private static final String KEY_SPLIT = ":"; //用于隔开缓存前缀与缓存键值
/**
* 将带有给定分值的成员添加到有序列表里面
* @param prefix 缓存前缀(用于区分缓存,防止缓存键值重复)
* @param key 缓存key
* @param value 缓存value
* @param score 用于排名的数值(分数)
* @return 返回添加成功的数量
*/
public Long add(String prefix,String key, String value, double score) {
long number = jedisCluster.zadd(prefix + KEY_SPLIT + key, score, value);
log.info("ZSetOperationsImpl:add cache key="+prefix + KEY_SPLIT + key+",value="+value+" score="+score+" 执行返回结果:"+number);
return number;
}
/**
* 返回有序集合包含的成员数量
* @param prefix 缓存前缀
* @param key 缓存key
* @return
*/
public Long size(String prefix,String key) {
long number = jedisCluster.zcard(prefix + KEY_SPLIT + key);
log.info("ZSetOperationsImpl:size cache key="+prefix + KEY_SPLIT + key+" 执行返回结果:"+number);
return number;
}
/**
* 返回有序集合中排名介于start和end之间的成员,包括start和end在内
* @param prefix 缓存前缀
* @param key 缓存key
* @param start 排名区间开始值
* @param end 排名区间结束值
* @return
*/
public Set range(String prefix, String key, long start, long end) {
Set set = jedisCluster.zrange(prefix + KEY_SPLIT + key, start, end);
log.info("ZSetOperationsImpl:range cache key="+prefix + KEY_SPLIT + key+" start="+start+" end="+end);
return set;
}
/**
* 移除有序集合中排名介于start和end之间的所有成员,包括start和end在内,并返回被移除成员的数量
* @param prefix 缓存前缀
* @param key 缓存key
* @param start
* @param end
* @return 返回被移除的个数
*/
public Long removeRange(String prefix, String key, long start, long end) {
long number = jedisCluster.zremrangeByRank(prefix + KEY_SPLIT + key, start, end);
log.info("ZSetOperationsImpl:removeRange cache key="+prefix + KEY_SPLIT + key+" start="+start+" end="+end+" 执行返回结果:"+number);
return number;
}
/**
* 从有序集合里面移除给定的成员,并返回被移除成员的数量
* @param prefix 缓存前缀
* @param key 缓存key
* @param value 缓存value
* @return
*/
public Long remove(String prefix, String key, String... value) {
long number = jedisCluster.zrem(prefix + KEY_SPLIT + key, value);
log.info("ZSetOperationsImpl:remove cache key="+prefix + KEY_SPLIT + key+" value="+value+" 执行返回结果:"+number);
return number;
}
/**
* 设置 key 的过期时间(如果已经存在过期时间则刷新过期时间)
* @param prefix 缓存前缀
* @param key 缓存key
* @param expireTime 过期时间
* @return 设置成功返回 1
*/
public Long setOrRefurbishExpireTime(String prefix, String key, int expireTime) {
long number = jedisCluster.expire(prefix + KEY_SPLIT + key, expireTime);
log.info("ZSetOperationsImpl:setOrRefurbishExpireTime cache key="+prefix + KEY_SPLIT + key+" expireTime="+expireTime+" 执行返回结果:"+number);
return number;
}
/**
* 删除指定key的缓存
* @param prefix 缓存前缀
* @param key 缓存key
* @return 删除成功返回 1
*/
public Long delete(String prefix, String key) {
long number = jedisCluster.del(prefix + KEY_SPLIT + key);
log.info("ZSetOperationsImpl:delete cache key="+prefix + KEY_SPLIT + key+" 执行返回结果:"+number);
return number;
}
/**
* 检查给定 key 是否存在 若 key 存在返回 true ,否则返回false
* @param prefix 缓存前缀
* @param key 缓存key
* @return
*/
public Boolean exists(String prefix, String key) {
Boolean flag = jedisCluster.exists(prefix + KEY_SPLIT + key);
log.info("ZSetOperationsImpl:exists cache key="+prefix + KEY_SPLIT + key+" 执行返回结果:"+flag);
return flag;
}
}
public class RedisCodes {
/**
* prefix 缓存前缀(用于区分缓存,防止缓存键值重复) 在用户登录信息存入redis时使用
*/
private final String MERCHANT_PREFIX_LOGIN = "LOGIN_";
/**
* session过期时间(单位:秒)
*/
private final int SESSION_EXPIRE_TIME = 3600;
}
package com.zhh.crm.filter;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Set;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import org.apache.commons.lang.StringUtils;
import org.apache.log4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.context.support.WebApplicationContextUtils;
import com.zhh.redis.util.RedisJpaTemplateString;
import com.zhh.redis.util.RedisJpaTemplateZSet;
import com.zhh.redis.util.code.RedisCodes;
import com.zhh.web.common.vo.PiccUser;
/**
* CRM SESSION超时过滤器,当SESSION超时之后,将由此过滤器返回一个异常编码:600.
* @author zhaoheng
*/
public class CopyOfCrmSessionFilter implements Filter {
private ApplicationContext wac;
private static Logger logger = Logger.getLogger(CopyOfCrmSessionFilter.class);
/*
// Filter中不能使用此方式来获取Bean,因为filter依赖于servlet,此时Servlet还未加载
@Autowired
RedisJpaTemplateZSet opsForZSet;
@Autowired
RedisJpaTemplateString rTemplate;*/
/**
* prefix 缓存前缀(用于区分缓存,防止缓存键值重复) 在用户登录信息存入redis时使用
*/
private final String prefix = RedisCodes.MERCHANT_PREFIX_LOGIN;
/**
* session过期时间(单位:秒)
*/
private final int expireTime = RedisCodes.SESSION_EXPIRE_TIME;
public void destroy() {}
public void doFilter(ServletRequest arg0, ServletResponse arg1,
FilterChain arg2) throws IOException, ServletException {
logger.info("开始执行方法:判断session是否超时或还未登陆系统,如果超时或还未登陆系统跳转到登陆页面");
HttpServletRequest request = (HttpServletRequest) arg0;
HttpServletResponse response = (HttpServletResponse) arg1;
RedisJpaTemplateZSet opsForZSet = wac.getBean(RedisJpaTemplateZSet.class);
RedisJpaTemplateString rTemplate = wac.getBean(RedisJpaTemplateString.class);
HttpSession session = request.getSession();
String sessionId = session.getId();
String merchantLoginName = (String) session.getAttribute("merchantLoginName");
// 把相关信息存入redis
this.saveRedisCache(opsForZSet, rTemplate, sessionId, merchantLoginName);
// 判断String里面的session是否过期(是否存在)
boolean flag = rTemplate.exists(prefix,sessionId);
if (flag) { // 未过期
// 刷新过期时间
rTemplate .setOrRefurbishExpireTime(prefix, sessionId, expireTime);
opsForZSet.setOrRefurbishExpireTime(prefix, merchantLoginName, expireTime);
arg2.doFilter(request, response);
}else{ // 已过期
logger.info("session过期,清空redis ZSet中的相应信息");
opsForZSet.remove(prefix, merchantLoginName, sessionId);
}
}
public void init(FilterConfig arg0) throws ServletException {
ServletContext context = arg0.getServletContext();
wac = WebApplicationContextUtils .getWebApplicationContext(context);
}
/**
* 把用户登录信息(sessionID和userName)存入redis中
* @param sessionId session唯一标识
* @param userName 登录用户名
*/
public void saveRedisCache(RedisJpaTemplateZSet opsForZSet,RedisJpaTemplateString rTemplate,String sessionId,String userName) {
logger.info("把用户登录信息(sessionID和userName)存入redis中开始 sessionId:"+sessionId+" userName:"+userName);
long count = opsForZSet.size(prefix, userName);
logger.info("登录前查询正在登录中的个数:"+count);
// 如果大于等于 5个,先清除第一个再插入新登录的,否则直接插入新登录的数据信息
if (count >= 5) {
Set set = opsForZSet.range(prefix, userName, 0, 0);
// 第一步:删除zset里面登录时间最早的信息
opsForZSet.removeRange(prefix, userName, 0, 0);
// 第二步:删除string里面登录时间最早的信息
for (String zsetVal : set) {
rTemplate.deleteWithPrefix(prefix, zsetVal);
}
// 第三步: sessionId 和 userName存放到redis的string里面(新登录的信息)
rTemplate.setWithExpireTime(prefix, sessionId, userName,expireTime);
// 第四步: 登录状态存储到redis的zSet(新登录的信息)
opsForZSet.add(prefix, userName, sessionId,System.currentTimeMillis());
// 第五步:设置zSet的过期时间
opsForZSet.setOrRefurbishExpireTime(prefix, userName, expireTime);
} else {
rTemplate.setWithExpireTime(prefix, sessionId, userName,expireTime);
opsForZSet.add(prefix, userName, sessionId,System.currentTimeMillis());
opsForZSet.setOrRefurbishExpireTime(prefix, userName, expireTime);
}
logger.info("把用户登录信息(sessionID和userName)存入redis中结束。 ");
}
}
在清空session的地方调用以下方法用于清空redis里面的存储信息
/**
* 用户退出登录删除redis中的session信息
* @param sessionId session唯一标识
* @param userName 用户登录名
*/
public void deleteRedisCache(String sessionId,String userName) {
log.info("用户退出登录,清空redis中相应的存储信息开始 sessionId:"+sessionId+" userName:"+userName);
// 存储前缀,可以自己定义 MERCHANT_PREFIX_LOGIN = "LOGIN_"
final String prefix = RedisCodes.MERCHANT_PREFIX_LOGIN;
rTemplate.deleteWithPrefix(prefix,sessionId);// 删除string里面的信息
opsForZSet.remove(prefix, userName, sessionId);//删除zset里面的信息
log.info("用户退出登录,清空redis中相应的存储信息结束");
}
因为我们设置了session过期时间,所以,就算用户不点击退出,到期后redis将自动清空过期信息。
注意:
因为过滤器依赖与servlet容器,我们看一下web.xml标签的加载顺序:context-param -> listener -> filter -> servlet ,也就是说,filter执行的时候servlet还未加载,所以filter里面不能获取IOC容器里面的Bean,所以文中就使用如下方法来获取上下文信息:
private ApplicationContext wac;
// 通过Spring提供的工具类获取ApplicationContext对象
public void init(FilterConfig arg0) throws ServletException {
ServletContext context = arg0.getServletContext();
wac = WebApplicationContextUtils .getWebApplicationContext(context);
}
public void doFilter(ServletRequest arg0, ServletResponse arg1,
FilterChain arg2) throws IOException, ServletException {
RedisJpaTemplateZSet opsForZSet = wac.getBean(RedisJpaTemplateZSet.class);
RedisJpaTemplateString rTemplate = wac.getBean(RedisJpaTemplateString.class);
}