前前后后忙活了两个星期终于上线了,来总结一下我这弱微的经验,用来纪念我这坎坷的历程。
用memcache实现账号登陆限制,即账号使用限制,同一时间同一账号只能一人使用,如果其他人使用账号,之前用户会被踢掉,在首页弹出下线提示;
从头开始整理一下思路,分享一下过程:
一开始想的太复杂了,总是想着用memcache代替shiro框架的session,后来从头想了想并没有那么复杂。
开发环境
我们是多个tomcat之间做的负载均衡,因为tomcat之间不能同步session,所以之前公司弄得memcache同步session,但是遗留的一个坑就是没有完全放弃tomcat里面的session,所以如果我要完全放弃tomcat里面的session,用memcache管理session,不仅工作量巨大,还得自己写个安全框架了……等一系列乱七八糟的东西都将到来。
实现原理
**同一用户每次登陆的sessionId是不同的**,用户登陆时我便通过存储当前账号的sessionId到memcache中,用户操作时通过一个Filter判断memcache中存储的 sessionId 和本地的是否一致,不一致则证明当前账号已在别处登陆,即自己被挤掉了,然后设置session失效,掉线弹窗提示,结束。
总结一句话就是,一个账号只能绑定一个sessionId,懂这句话就行了
1.memcache-java-client的使用
这个是最最基础的了,如果不会请自行百度,我只是简单的贴一下我自己的代码,不做过多的讲解;
还有memcache的原理,在内存中是以key-value键值对存在的数据。
pom.xml文件直接导包:
<!--memCache-java客户端-->
<dependency>
<groupId>com.whalin</groupId>
<artifactId>Memcached-Java-Client</artifactId>
<version>3.0.2</version>
</dependency>
MemCacheManager管理类,用来提供对memcache的操作
package com.bmkit.util.memcache;
import com.bmkit.util.ServiceConfigUtil;
import com.whalin.MemCached.MemCachedClient;
import com.whalin.MemCached.SockIOPool;
import java.util.ArrayList;
import java.util.Date;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
/**
* Created by jiaohongwei on 16-7-22. 用于初始化memcache链接,提供基础的memcache操作方法
*/
public class MemCacheManager {
// 创建全局的唯一实例
private static MemCachedClient mcc = new MemCachedClient();
protected static MemCacheManager memCacheManager = new MemCacheManager();
// 设置与缓存服务器的连接池
static {
String[] servers = {127.0.0.1:11211,127.0.0.1:11212};
Integer[] weights = {7,3};
// 获取socke连接池的实例对象
SockIOPool pool = SockIOPool.getInstance();
// 设置服务器信息
pool.setServers(servers);
pool.setWeights(weights);
// 设置初始连接数、最小和最大连接数以及最大处理时间
pool.setInitConn(5);
pool.setMinConn(5);
pool.setMaxConn(250);
pool.setMaxIdle(1000 * 60 * 60 * 6);//6个小时
/*设置连接池维护线程的睡眠时间
设置为0,维护线程不启动
维护线程主要通过log输出socket的运行状况,监测连接数目及空闲等待时间等参数以控制连接创建和关闭。*/
pool.setMaintSleep(30);
// 设置TCP的参数,连接超时等
/* Tcp的规则就是在发送一个包之前,本地机器会等待远程主机
对上一次发送的包的确认信息到来;这个方法就可以关闭套接字的缓存,
以至这个包准备好了就发;
设置是否使用Nagle算法,因为我们的通讯数据量通常都比较大(相对TCP控制数据)而且要求响应及时,因此该值需要设置为false*/
pool.setNagle(false);
//连接建立后对超时的控制
pool.setSocketTO(3000);
pool.setSocketConnectTO(0);
// 初始化连接池
pool.initialize();
}
/**
* 保护型构造方法,不允许实例化!
*/
protected MemCacheManager() {
}
/**
* 获取唯一实例.
*/
public static MemCacheManager getInstance() {
return memCacheManager;
}
/**
* 添加一个指定的值到缓存中.
*
* 1、memcache::add 方法:add方法用于向memcache服务器添加一个要缓存的数据。
*
* 注意:如果memcache服务器中已经存在要存储的key,此时add方法调用失败。
*
* 2、memcache::set 方法:set方法用于设置一个指定key的缓存内容,set方法是add方法和replace方法的集合体。
*
* 注意:
*
* 1)、如果要设置的key不存在时,则set方法与add方法的效果一致;
*
* 2)、如果要设置的key已经存在时,则set方法与replace方法效果一样。
*/
public boolean add(String key, Object value) {
return mcc.add(key, value);
}
public boolean add(String key, Object value, Date expiry) {
return mcc.add(key, value, expiry);
}
public boolean add(String key, Object value, int milliseconds) {
return mcc.add(key, value, milliseconds);
}
public boolean set(String key, Object value) {
return mcc.set(key, value);
}
public boolean set(String key, Object value, int milliseconds) {
return mcc.set(key, value, milliseconds);
}
public boolean set(String key, Object value, Date expiry) {
return mcc.set(key, value, expiry);
}
public boolean replace(String key, Object value) {
return mcc.replace(key, value);
}
public boolean replace(String key, Object value, Date expiry) {
return mcc.replace(key, value, expiry);
}
public boolean replace(String key, Object value, int milliseconds) {
return mcc.replace(key, value, milliseconds);
}
public boolean remove(String key) {
return mcc.delete(key);
}
/**
* 根据指定的关键字获取对象.
*/
public Object get(String key) {
return mcc.get(key);
}
public static void main(String[] argc) {
// 获取所有的key
// getAllKeys(mcc);
// 获取所有的key+value;
// getKeysForMap();
}
public static List<String> getAllKeys(MemCachedClient memCachedClient) {
System.out.println("开始获取没有挂掉服务器中所有的key.......");
List<String> list = new ArrayList<String>();
Map<String, Map<String, String>> items = memCachedClient.statsItems();
for (Iterator<String> itemIt = items.keySet().iterator(); itemIt.hasNext(); ) {
String itemKey = itemIt.next();
Map<String, String> maps = items.get(itemKey);
for (Iterator<String> mapsIt = maps.keySet().iterator(); mapsIt.hasNext(); ) {
String mapsKey = mapsIt.next();
String mapsValue = maps.get(mapsKey);
if (mapsKey.endsWith("number")) { //memcached key 类型 item_str:integer:number_str
String[] arr = mapsKey.split(":");
int slabNumber = Integer.valueOf(arr[1].trim());
int limit = Integer.valueOf(mapsValue.trim());
Map<String, Map<String, String>> dumpMaps = memCachedClient.statsCacheDump(slabNumber, limit);
for (Iterator<String> dumpIt = dumpMaps.keySet().iterator(); dumpIt.hasNext(); ) {
String dumpKey = dumpIt.next();
Map<String, String> allMap = dumpMaps.get(dumpKey);
for (Iterator<String> allIt = allMap.keySet().iterator(); allIt.hasNext(); ) {
String allKey = allIt.next();
String key = allKey.trim();
list.add(key);
System.out.println(key);
// System.out.println(mcc.get(key));
// mcc.delete(key);
System.out.println("-------------------------------------");
}
}
}
}
}
System.out.println("获取没有挂掉服务器中所有的key完成.......");
return list;
}
public static void getKeysForMap() {
//遍历statsItems 获取items:2:number=14
Map<String, Map<String, String>> statsItems = mcc.statsItems();
Map<String, String> statsItems_sub = null;
String statsItems_sub_key = null;
int items_number = 0;
String server = null;
//根据items:2:number=14,调用statsCacheDump,获取每个item中的key
Map<String, Map<String, String>> statsCacheDump = null;
Map<String, String> statsCacheDump_sub = null;
String statsCacheDumpsub_key = null;
String statsCacheDumpsub_key_value = null;
for (Iterator iterator = statsItems.keySet().iterator(); iterator.hasNext(); ) {
server = (String) iterator.next();
statsItems_sub = statsItems.get(server);
// System.out.println(server+"==="+statsItems_sub);
for (Iterator iterator_item = statsItems_sub.keySet().iterator(); iterator_item.hasNext(); ) {
statsItems_sub_key = (String) iterator_item.next();
// System.out.println(statsItems_sub_key+":=:"+bb);
// items:2:number=14
if (statsItems_sub_key.toUpperCase().startsWith("items:".toUpperCase()) && statsItems_sub_key.toUpperCase().endsWith(":number".toUpperCase())) {
items_number = Integer.parseInt(statsItems_sub.get(statsItems_sub_key).trim());
// System.out.println(statsItems_sub_key+":=:"+items_number);
statsCacheDump = mcc.statsCacheDump(new String[]{server}, Integer.parseInt(statsItems_sub_key.split(":")[1].trim()), items_number);
for (Iterator statsCacheDump_iterator = statsCacheDump.keySet().iterator(); statsCacheDump_iterator.hasNext(); ) {
statsCacheDump_sub = statsCacheDump.get(statsCacheDump_iterator.next());
//System.out.println(statsCacheDump_sub);
for (Iterator iterator_keys = statsCacheDump_sub.keySet().iterator(); iterator_keys.hasNext(); ) {
statsCacheDumpsub_key = (String) iterator_keys.next();
statsCacheDumpsub_key_value = statsCacheDump_sub.get(statsCacheDumpsub_key);
System.out.println("键:" + statsCacheDumpsub_key);//key是中文被编码了,是客户端在set之前编码的,服务端中文key存的是密文
Object value = memCacheManager.get(statsCacheDumpsub_key);
System.out.println("值:" + value);
System.out.println("------------------------------------------------------------");
}
}
}
}
}
}
}
2.MemCacheFilter的配置和使用
package com.bmkit.general.filter;
import com.bmkit.entity.db.User;
import com.bmkit.util.EncodingUtil;
import com.bmkit.util.Encryption.EncryptionMD5;
import com.bmkit.util.date.DateUtil;
import com.bmkit.util.memcache.MemCacheManager;
import org.apache.log4j.Logger;
import java.io.IOException;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* Created by jiaohongwei on 16-8-10.
* 用于限制用户登录,账号不能同时登录
*/
public class MemCacheFilter implements Filter {
private static Logger log = Logger.getLogger(MemCacheFilter.class);
@Override
public void init(FilterConfig filterConfig) throws ServletException {
}
@Override
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) servletRequest;
HttpServletResponse response = (HttpServletResponse) servletResponse;
User user = (User) request.getSession().getAttribute("curr_user");
//这里可以配置其他用户不受限制,可以放在配置文件中读取
if (user == null || user.getUsername().equals("admin")) {//管理员可多处登陆
chain.doFilter(servletRequest, servletResponse);
return;
}
String sessionId = request.getSession().getId();
String value = (String) MemCacheManager.getInstance().get(EncryptionMD5.getMD5String(user.getId()));
//如果本地的sessionId和memcache中的不一致则掉线
if (value != null && !value.equals(sessionId)) {
String prompt = DateUtil.getDate();
String base64Prompt= EncodingUtil.getBASE64(prompt);//将掉线时间64位加密
request.getSession().setAttribute("prompt", base64Prompt);//放在Session中
response.setHeader("prompt", base64Prompt);//将掉线时间放在Header里面返回前端
log.info("该账号已在别处登录,请核实。username:" + user.getUsername() + "; login_ip:" + request.getRemoteAddr() + "; login_time:" + prompt);
} else {
//md5加密key,设置有效期30分钟
//key=MD5(userId);value=sessionId;
MemCacheManager.getInstance().set(EncryptionMD5.getMD5String(user.getId()), sessionId, 30 * 60 * 1000);
}
chain.doFilter(request, response);
}
@Override
public void destroy() {
}
}
3.重写jQuery的aJax错误处理
这里可以注意到,我在这个过滤器中并没有直接设置session失效,其实是可以的,不过由于种种原因放弃了,变成前端调用方法使session失效;
主要原因就是如果在这里直接就让session失效,用户之前的请求会失败,从而就会直接走shiro框架的session失效机制,跳转首页;流程是这么一个可是现实却不是如此,前端如果是ajax请求的话便不会跳转页面,出现重大bug,导致页面所有请求都失败,而且还不会给用户提示任何信息,所以这一条直接被封杀了;
于是乎就出现了后面的修改jQuery源码的悲催历程;
因为ajax不会跳转到首页,所以我重写了jQuery的 ajaxSetup 里面的 error 和complete 方法,代码如下:
直接打开jQuery的js源文件,搜索 ajaxSetup 找到和下面类似(包含 accepts:……)的就行;然后添加上success\error\complete 这几个方法就可以了,其实很简单,但当初我可是把这源码从头看到尾……半天才找到在哪里改……
n.ajaxSetup({
success: function (data) {/*所有ajax请求成功后触发*/},
error: function (xhr, status, e) {/*请求失败遇到异常触发*/
var value=xhr.getResponseHeader("prompt");
if (value){/*自定义请求头,如果不是null则证明用户被挤掉,跳到首页*/
window.location.replace("/external/login/index?prompt="+value);
}
},
complete: function (xhr, status) {/*完成请求后触发。即在success或error触发后触发*/
var value=xhr.getResponseHeader("prompt");
if (value){/*自定义请求头,如果不是null则证明用户被挤掉,跳到首页*/
window.location.replace("/external/login/index?prompt="+value);
}
},
beforeSend: function (xhr) {/*发送请求前触发*/
},
accepts: {script: "text/javascript, application/javascript, application/ecmascript, application/x-ecmascript"},
contents: {script: /(?:java|ecma)script/},
converters: {
"text script": function (a) {
return n.globalEval(a), a
}
}
}),
到这里你可能会说,这 不就可以直接跳转页面了,然后在Filter设置session立刻失效,答案是错!
你如果设置了,你就会发现掉线之前的请求都会失败,然后重点在这里,如果你现在掉线了,但是你还不知道的情况下清空下缓存刷新界面(ctrl+F5),你就蒙蔽了,页面 所有的js和css都加载不进来了,直接就是显示的html代码,至于这个原因,我到现在都没有搞明白,而且还不是百分百复现,有个别时候还是正确的;有知道的可以给我说说为什么。
然后我以为这样就已经可以了,然后一调试发现,前端的请求不光有ajax 还特么由vue的!!!原谅我太菜了,看了回vue的就崩了(get请求貌似是基于ajax的,post就好像是表单提交的),然后我就放弃不修改vue的请求的源码了(因为以后都要用路由,用了路由之后就好弄多了)。
重点是前端用的vue 请求现在都没有用路由(这里不懂的可以百度vue.js和vue-resource,前端框架),这特么真是个巨大的坑,出于无奈,最后协商还是不解决vue请求的跳转页面,同时之前的 用户不立即掉线在这里也发挥了作用,就是我能保证用户操作vue请求可以正常请求,不会报错,也不会跳到首页提示掉线,两全其美。如果之前在Filter中设置session立刻失效,就会导致页面的vue请求瘫痪,这个也是大家不想看到的。
4.登陆时存储sessionId
这个就需要在你们的登陆成功之后的代码中加入以下方法把用户的sessionId存到memcache 中
if (!currentUser.getUsername().equals("admin")) {//管理员可多处登陆
//获取当前用户的sessionId
String sessionId = request.getSession().getId();
//将sessionId存在memcache中,时效为半小时(30 * 60 * 1000)
MemCacheManager.getInstance().set(EncryptionMD5.getMD5String(currentUser.getId()), sessionId, 30 * 60 * 1000);
}
5.在index.jsp首页做操作
先写一个弹出框,这里就不再贴代码了,然后我说以下首页的处理流程
用户掉线之后依据链接跳到首页,然后我从路径后面的参数中获得掉线的时间,弹出登录框,如果没有时间则正常登陆;
<script>
//这里是给jQuey加了个获取url参数的方法
$(function () {
(function ($) {
$.getUrlParam = function (name) {
var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)");
var r = window.location.search.substr(1).match(reg);
if (r != null) return unescape(r[2]);
return null;
}
})(jQuery);
})
$(document).ready(function () {
var value=$.getUrlParam('prompt');
if (value != null && value != 'null') {
var prompt = biomarker.decode64(value);//参数解码
//掉方法让用户的session失效
$.ajax({
type: 'GET',
async: false,
cache: false,
url: '/user/login/judgeOnline',
dataType: '',
data: {},
success: function (data) {
}
});
$('.prompt-time').html(prompt);
//这里让弹出框出来 ,代码省略
//……
});
}
});
</script>
后台方法:
@RequestMapping(value = "/judgeOnline")
@ResponseBody
public String judgeOnline(HttpServletRequest request) {
Object prompt = SecurityUtils.getSubject().getSession().getAttribute("prompt");
if (prompt != null) {
request.getSession().invalidate();
return prompt.toString();
}
return null;
}
至此,所有流程结束,功能正常实现;
其实可以做到在当前页面弹出框提示,但是需要一个公共元素(例如footer、导航都可以),有兴趣的可以直接把弹窗加到公共模块里面请求触发弹窗提示,恩,就是这么简单~
同一时间同一账号只能一人使用,如果其他人使用账号,之前用户会被踢掉,在首页弹出下线提示;
当然这种模式不是及时推送掉线信息的那种,有所弊端,看情况采用。
只是这种比较简单,实现没有什么困难的,按需处理就可以了。