memcahe实现账号账号不能同时登录功能

前前后后忙活了两个星期终于上线了,来总结一下我这弱微的经验,用来纪念我这坎坷的历程。
用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、导航都可以),有兴趣的可以直接把弹窗加到公共模块里面请求触发弹窗提示,恩,就是这么简单~
同一时间同一账号只能一人使用,如果其他人使用账号,之前用户会被踢掉,在首页弹出下线提示;

当然这种模式不是及时推送掉线信息的那种,有所弊端,看情况采用。
只是这种比较简单,实现没有什么困难的,按需处理就可以了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值