SpringBoot项目使用监听器实现对在线用户的管理

文章目录

  • 需求
  • 思路
  • 实现
    • 在线用户监听
      • 实现方向
      • 具体过程
    • 用户踢下线
      • 实现方向
      • 具体过程
  • 存在的问题

需求

JavaWeb项目(SpringBoot),需要实现一个查看用户列表时,显示哪个用户在线,同时有将该用户踢下线的功能.以及需要统计当前在线用户的数量.
每部分功能其实都有很多种做法,这里只记录我实际用到的做法,选取了适合我项目的方法,其实在不同的场景都有更好的方法.
参考资料:
监听Session属性: https://www.cnblogs.com/siv8/p/5904105.html
根据ID获取Session: https://blog.csdn.net/sihai12345/article/details/81098765

思路

由于一开始提的需求是统计在线用户数量,方法有很多,比如监听Session统计Session数量.但是组长发我的一篇博客说,这种方法当同一终端开启多个浏览器时,会造成统计多次.那篇博客推荐使用IP进行统计,可是IP也存在一个问题就是内网内是同一外网IP,也会造成统计错误.
考虑到我们的项目需要实现用户下线功能,和某用户上线状态的显示,所以我觉得其实Session方法更好,至于同一终端多个浏览器登录同一个用户的方法,可以靠约束用户只能单一登录实现.当同一账号被第二次登录时,将前一个登陆的账号顶掉即可.
所以,实现功能主要分为两个部分

  1. 实现Session监听,统计在线用户数量.由于需要统计的是已登录的用户,所以需要监听的是Session属性的变化,使用HttpSessionBindingListener来实现,而不是HttpSessionListener.
  2. 根据用户的SessionID获取到其Session,并将其登录属性移除,实现踢下线功能.

实现

在线用户监听

实现方向

做的时候找了很多资料,大部分都是使用HttpSessionListener,重写sessionCreated方法和sessionDestoryed方法,实现对session的监听.这种方法监听到的是每一个会话,但业务中是存在登录的,我们只需要监听真正登录了的会话即可,查阅了其他资料,决定使用HttpSessionBindingListener来监听特定属性绑定到session.

具体过程

参考的这篇博客的做法:https://www.cnblogs.com/siv8/p/5904105.html
具体实现是自定义一个UserOnlineListener类实现HttpSessionBindingListener接口,重写其中的valueBoundvalueUnbound方法,在属性绑定和解绑时执行不同的操作.实现每一个会话都对应一个监听器.在application中定义一个在线用户集合userOnlineList.当将监听器对象绑定/解绑到当前会话上时,将当前登录的用户从application中定义的添加/移除.

  1. 自定义监听器实现HttpSessionBindingListener
package com.jytd.sdntest.common.listener;

import javax.servlet.ServletContext;
import javax.servlet.http.HttpSession;
import javax.servlet.http.HttpSessionBindingEvent;
import javax.servlet.http.HttpSessionBindingListener;
import java.util.ArrayList;
import java.util.List;

public class UserOnlineListener implements HttpSessionBindingListener {
     

    private Long userId;

    public UserOnlineListener(Long userId){
     
        this.userId=userId;
    }

    @Override
    public void valueBound(HttpSessionBindingEvent event) {
     
        HttpSession session=event.getSession();
        ServletContext application=session.getServletContext();
        //从application获取当前登录用户列表
        List<Long> userOnlineList= (List<Long>) application.getAttribute("userOnlineList");
        //如果该属性不存在,则初始化
        if(userOnlineList==null){
     
            userOnlineList=new ArrayList<>();
            application.setAttribute("userOnlineList",userOnlineList);
        }
        //将当前用户添加至用户列表
        userOnlineList.add(this.userId);
        System.out.println("session属性绑定=======>"+this.userId);
    }

    @Override
    public void valueUnbound(HttpSessionBindingEvent event) {
     
        HttpSession session=event.getSession();
        ServletContext application=session.getServletContext();
        //从application获取当前登录用户列表
        List<Long> userOnlineList= (List<Long>) application.getAttribute("userOnlineList");
        //将该用户从列表中移除
        userOnlineList.remove(this.userId);
        System.out.println("session属性解除绑定=======>"+this.userId);
    }
}

在类中定义一个属性,userId,表示当前登录用户的ID,需要其他也可以定义.之后重写其构造方法.

  1. 修改登录接口
    在登录接口中,在用户登录之后,以用户ID为参数新建该类的对象,并将对象绑定到用户的session中,代码如下:
	@SysLog("用户登录验证")
    @RequestMapping(method = RequestMethod.POST, value = "/userLogin")
    @ApiOperation(value = "用户登录验证", notes = "用户登录验证", httpMethod = "POST")
    public R userLongin(UserQo userQo, HttpSession httpSession) {
     
        //查询用户
        CommonUserNode commonUserNode = commonUserService.findUserByUserNameAndPwd(userQo);
        //判断是否为空
        if (commonUserNode == null) {
     
            return R.error("用户名或密码有误!");
        }
        //判断用户状态
        int state = commonUserNode.getState().intValue();
        if (state == 1) {
     
            return R.error("该账号已被冻结!");
        }
        httpSession.setAttribute("loginUser", commonUserNode);
        httpSession.setAttribute("userOnlineListener", new UserOnlineListener(commonUserNode.getId()));
        System.out.println("当前登录用户的sessionId"+httpSession.getId());
        return R.ok(commonUserNode);
    }

主要是httpSession.setAttribute("userOnlineListener", new UserOnlineListener(commonUserNode.getId()));这一行,首先调用构造方法新建对象,之后执行valueBound方法,将当前登录用户加入到application中定义的用户列表属性中.
valueUnbound在这几种情况下会调用:
执行session.invalidate()时;
session超时,自动销毁时;
执行session.setAttribute(“userOnlineListener”, “其他对象”);或session.removeAttribute(“userOnlineListener”);将listener从session中删除时

  1. 获取在线用户
    写好这些,只要从application中获取到之前定义好的userOnlineList即可,我这里写的这个接口也只是一个简单的测试,返回的是在线用户的ID集合,后续可能要根据角色统计每种角色的在线用户数量,需要跟数据库有关联,还没考虑好到底怎么做.
   @SysLog("获取当前在线用户数量及列表")
   @RequestMapping(method = RequestMethod.POST, value = "/getUserOnline")
   @ApiOperation(value = "获取当前在线用户数量及列表", notes = "获取当前在线用户数量及列表", httpMethod = "POST")
   public R getUserOnline(HttpServletRequest request) {
     
       HttpSession session=request.getSession();
       ServletContext application=session.getServletContext();
       List<Long> userOnlineList= (List<Long>) application.getAttribute("userOnlineList");
       if(userOnlineList!=null){
     
           System.out.println("在线用户数:"+userOnlineList.size());
       }
       return R.ok(userOnlineList);
   } 

用户踢下线

实现方向

所谓将用户踢下线,其实就是将该用户此次session中的登录属性移除,同时使用拦截器,在用户每次请求时验证其登录状态,当验证不通过跳转回登录页面.
那么将移除session,就是通过sessionId获取到该session对象,再将该属性移除即可.
但根据sessionId获取session对象这个过程昨天耗费了一些时间.这里也要仔细记录一下.

具体过程

  1. 登录拦截器
    这个拦截器也不是最终版
    定义一个LoginInterceptor实现HandlerInterceptor接口,重写preHandle方法,在其中实现登录验证的逻辑.
    由于之前提需求是要求我如果一个用户被冻结(即修改其数据库中的某个属性),也将该用户踢下线,所以我暂时的解决办法是登录验证时从库中查询其状态,如果是冻结状态则直接拦截器验证不通过.
    这种方法的问题是每请求一次就要查一次数据库中的用户数据,十分繁琐,暂时还没决定好最终的方案.
package com.jytd.sdntest.common.config;

import com.jytd.sdntest.common.utils.Util;
import com.jytd.sdntest.sdn.node.domain.CommonUserNode;
import com.jytd.sdntest.sdn.node.repository.CommonUserRepository;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.support.WebApplicationContextUtils;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.util.Optional;

/**
 * 登录拦截器
 */
public class LoginInterceptor implements HandlerInterceptor {
     

    private CommonUserRepository commonUserRepository;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
     
        if(commonUserRepository==null){
     
            BeanFactory beanFactory= WebApplicationContextUtils.getRequiredWebApplicationContext(request.getServletContext());
            commonUserRepository=beanFactory.getBean(CommonUserRepository.class);
        }
        HttpSession session=request.getSession();
        CommonUserNode loginUser=(CommonUserNode)session.getAttribute("loginUser");
        if(Util.isEmpty(loginUser)){
     
            //用户未登录则返回主页面
            response.sendRedirect("/index");
            return false;
        }else{
     

            Optional<CommonUserNode> checkUser=commonUserRepository.findById(loginUser.getId());
            if(checkUser.get().getState().equals(1)){
     
                //用户被冻结,返回主页面
                response.sendRedirect("/index");
                return false;
            }
        }
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
     
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
     
    }
}

拦截器定义好之后,在WebMvcConfigurer中将拦截器注册.
我是定义了一个WebConfig实现WebMvcConfigurer接口,通过addInterceptors方法注册拦截器,同时该接口还可以配置静态资源访问路径(addResourceHandlers方法)

package com.jytd.sdntest.common.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {
     

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
     
        // 注册拦截器
        LoginInterceptor loginInterceptor = new LoginInterceptor();
        InterceptorRegistration loginRegistry = registry.addInterceptor(loginInterceptor);
        // 拦截路径
        loginRegistry.addPathPatterns("/**");
        // 排除路径
        loginRegistry.excludePathPatterns("/");
        loginRegistry.excludePathPatterns("/commonuser/userLogin");
       ………………其他自行添加
    }

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
     
        registry.addResourceHandler("/statics/**").addResourceLocations("classpath:/statics/");
    }
}

完成之后,即可对指定请求和资源进行登录拦截,如果验证不通过则无法访问.

  1. 存储用户sessionId
    这一步其实我还没考虑太清楚,到底是用前面的application来存储,还是直接读写数据库,还是使用缓存数据库redis来实现.目前来看最好用的应该是直接读写数据库,因为如果直接读写,对于我列表查看相关接口也方便前端去判断.但是这个方法应该是效率最低的一种.
    总之,是我需要通过一种方式,将用户ID及其session的ID存储下来,当需要将用户踢下线时,由前端传参给我,用户ID或者sessionID均可,我最终拿到sessionID,获取到session对象,将其属性移除

  2. 根据sessionId获取session对象
    获取session对象,如果是shiro框架的话,会比较好办,但是现在项目里并没有用.
    我参考的是:https://blog.csdn.net/sihai12345/article/details/81098765 这篇博客.

首先是自带方法,

HttpSession session = session.getSessionContext().getSession(sessionId);  

但这种方法不被建议,而且我用这种方法并不能获取得到.所以考虑换其他方法.

于是我参照另一篇博客,自定义了一个SessionContext
首先定义一个MySessionContext,在类中定义一个Map来保存session对象,key是sessionId,value就是session对象,同时该类要是用单例模式.

package com.jytd.sdntest.common.utils;

import javax.servlet.http.HttpSession;
import java.util.HashMap;

public class MySessionContext {
     

    private static MySessionContext instance;

    private HashMap<String, HttpSession> sessionMap;

    private MySessionContext(){
     
        sessionMap=new HashMap<String, HttpSession>();
    }

    public static MySessionContext getInstance(){
     
        if(instance==null){
     
            instance=new MySessionContext();
        }
        return instance;
    }

    public synchronized void addSession(HttpSession session){
     
        if(session!=null){
     
            sessionMap.put(session.getId(),session);
        }
    }

    public synchronized void delSession(HttpSession session){
     
        if(session!=null){
     
            sessionMap.remove(session.getId());
        }
    }

    public synchronized HttpSession getSession(String sessionId){
     
        if(sessionId!=null){
     
            return sessionMap.get(sessionId);
        }else{
     
            return null;
        }
    }
}

之后,配置前面提到过的HttpSessionListener监听器,实现对session的监听,新增/结束一个会话时,调用MySessionContextaddSession/delSession方法

package com.jytd.sdntest.common.listener;

import com.jytd.sdntest.common.utils.MySessionContext;

import javax.servlet.annotation.WebListener;
import javax.servlet.http.HttpSession;
import javax.servlet.http.HttpSessionEvent;
import javax.servlet.http.HttpSessionListener;

@WebListener
public class SessionListener implements HttpSessionListener {
     

    private MySessionContext mySessionContext=MySessionContext.getInstance();

    @Override
    public void sessionCreated(HttpSessionEvent se) {
     
        HttpSession session=se.getSession();
        mySessionContext.addSession(session);
    }

    @Override
    public void sessionDestroyed(HttpSessionEvent se) {
     
        HttpSession session=se.getSession();
        mySessionContext.delSession(session);
    }
}

Springboot中,监听器使用@WebListener注解即可,但要注意,记得在启动类上添加@ServletComponentScan进行扫描注入.

  1. 移除session属性实现下线功能
    配置好这些之后,在需要用到的地方直接使用MySessionContext.getInstance()方法获取对象,在调用getSession方法即可获取到session对象,之后将其属性移除即可.
	@SysLog("根据Session将用户踢下线")
    @RequestMapping(method = RequestMethod.POST, value = "/kickUserOffline")
    @ApiOperation(value = "根据Session将用户踢下线", notes = "根据Session将用户踢下线", httpMethod = "POST")
    public R kickUserOffline(String sessionId,HttpServletRequest request){
     
        MySessionContext mySessionContext=MySessionContext.getInstance();
        HttpSession session=mySessionContext.getSession(sessionId);
        session.removeAttribute("loginUser");
        session.removeAttribute("userOnlineListener");
        return R.ok("下线成功");
    }

存在的问题

最主要的问题.就是如何存储sessionId的问题.
除了获取在线用户,我还有一个需求就是,我要获取用户列表,这个用户列表是我从数据库中查的.如果用户在线,前端要在列表的"操作"这列,显示"下线"按钮,所以我必须在查用户列表同时将其在线状态查出.
暂时有以下三种方案

  1. 存入application
    像我存储在线用户一样,将在线用户ID和其sessionID同时作为属性存入application中,比如可以存储为用户ID为key,sessionID为value的map的数据结构.再对该map进行操作.

  2. 存入redis缓存
    redis与application类似,只不过数据结构不太相同,待定

  3. 直接读写数据库
    直接读写数据库对我的业务来说最为方便,在数据库中,新建属性"sessionId",用户登录时.则修改该属性为当前sessionId;退出登录则将属性置为空.前端查询时,判断该属性是否存在,来控制按钮显隐.踢下线时也将sessionId传入后端即可…
    但该方法的弊端是效率太低,频繁数据库读写,终归不太好.

前两种方法效率更高,但查询列表时需要后端对用户列表和在线用户列表进行整合.
目前还没定下来方案.

而且,在线用户数量统计,还要根据角色统计不同角色的在线数量,我的HttpSessionBindingListener还需要修改,如果使用读写数据库的方法,我可以通过直接查询数据库来实现在线用户数的统计.
具体取舍,还需要再进行实验和测试.

你可能感兴趣的:(学习记录)