websocket实现同一账号强制下线消息推送+系统维护通知功能

1. 实现技术

基于springboot  +服务端 websocket  +客户端  stomp

2. 实现

1.引入pom文件

        <!-- SpringWebSocket依赖 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>

2.编写服务端的websocket 配置

package com.zz.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.AbstractWebSocketMessageBrokerConfigurer;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;

/**
 * 通过EnableWebSocketMessageBroker 开启使用STOMP协议来传输基于代理(message broker)的消息,此时浏览器支持使用@MessageMapping 就像支持@RequestMapping一样。
 */
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {

    /**
     * endPoint 注册协议节点,并映射指定的URl
     *
     * @param registry
     */
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) { //endPoint 注册协议节点,并映射指定的URl

        registry.addEndpoint("/chat")
                // 重点:不加此处重要的配置,可能导致拦截器引入不到spring容器中
                // 坑点 .setHandshakeHandler(new MyPrincipalHandshakeHandler())  
                // 注入不了session 和 redis 的参数。
                .setHandshakeHandler(createMyPrincipalHandshakeHandler())
                .setAllowedOrigins("*")
                .withSockJS();
    }

    /**
     * 配置消息代理(message broker)
     *
     * @param registry
     */
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {//配置消息代理(message broker)
        //点对点式增加一个/queue 消息代理
        registry.enableSimpleBroker("/queue", "/topic");
        // 全局使用的消息前缀(客户端订阅路径上会体现出来)
        registry.setApplicationDestinationPrefixes("/app");
        // 点对点使用的订阅前缀(客户端订阅路径上会体现出来),不设置的话,默认也是/user/
        registry.setUserDestinationPrefix("/user/");

    }

    // 重点:@Bean 注解的使用,将拦截器引入到sprng容器中,并且下方的RedisTemplate和 
    // HttpSession才能引入容器,不然这2值会显示 null 
    @Bean
    public MyPrincipalHandshakeHandler createMyPrincipalHandshakeHandler() {
        return new MyPrincipalHandshakeHandler();
    }

}

3.编写一个用户,实现 Principal 接口, webscoket通过 Principal 传递用户信息,进行前后端连接

package com.zz.vo;

import lombok.Data;

import java.security.Principal;
import java.util.Date;
import java.util.List;
import java.util.Map;

@Data
public class SocketUser implements Principal{

    private static final long serialVersionUID = 1L;

    private String userId;

    private String userName;

    private Date lastLogin;

    public SocketUser(){}

    public SocketUser(SocketUser socketUser){}

    @Override
    public String getName() {
        return this.userName;
    }

    public SocketUser(String userName){
        this.userName = userName;
    }

    public String getUserName() {
        return userName;
    }

    public void setUserName(String userName) {
        this.userName = userName;
    }

}

4.编写拦截器 目的修改消息通道的用户信息,达到同一账户,可以根据时间戳或者session区分,当然还可以添加角色等信息

package com.zz.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.support.DefaultHandshakeHandler;

import javax.annotation.Resource;
import javax.servlet.http.HttpSession;
import java.security.Principal;zz
import java.util.Map;
import java.util.concurrent.TimeUnit;

@Configuration
public class MyPrincipalHandshakeHandler extends DefaultHandshakeHandler{


    @Resource
    private RedisTemplate redisTemplate;

    @Resource
    private HttpSession httpSession;

    @Override
    protected Principal determineUser(ServerHttpRequest request, WebSocketHandler wsHandler, Map<String, Object> attributes) {
        String sessionId = httpSession.getId();
        // 我的安全框架用的 Shiro 这个看你自己用的技术 
        SysUserDO user = ShiroUtils.getUser();
        SocketUser socketUser = new SocketUser();
        // 我将用户信息放到 redis 中。将用户信息添加一个前缀,方便定位到websocket数据
        // 也可用用一个全局的map,或者放到数据库中
        String temp = "wSocket" + "_" + user.getUserName() + "_" + "_" + sessionId;
        // 用户信息为key,value为1,过期时间1小时
        redisTemplate.opsForValue().set(temp, "1", 1, TimeUnit.HOURS);
        socketUser.setUserName(temp);
        return socketUser;
    }

}

多说两句:此处重点是 RedisTemplate 和 HttpSession 引入问题。 核心是在第一步的 .setHandshakeHandler(createMyPrincipalHandshakeHandler()) 和 @Bean

5.实现controller层


import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.stereotype.Controller;

import java.security.Principal;
import java.util.Set;


@Controller
public class WebSocketController {

    @Autowired
    SimpMessagingTemplate template;

	@Autowired
	private RedisTemplate redisTemplate;


    /**
     * 接收用户信息
     * */
    @MessageMapping(value = "/singleLogout")
    public void principal(StompHeaderAccessor stompHeaderAccessor) {
		Principal user = stompHeaderAccessor.getUser();
		String userName = user.getName();
		String[] names = userName.split("_");
		Set<String> keys = redisTemplate.keys(names[0]+"_" + names[1] + "*");
		for (String key: keys) {
			if(!key.equals(userName)){
				//发送消息给指定用户
				template.convertAndSendToUser(key, "/queue/message","您的账户在别处登录, 您被强制下线");
				// 发送完消息直接删除,防止,之后重复发送通知
				Boolean deleteFlag = redisTemplate.delete(key);
			}
		}

    }

    /**
    * 扩展功能
    * 系统维护通知功能
    * 此接口触发的时机,可以选择,修改了系统表中的维护开关而触发
    **/
	@MessageMapping(value = "/notice")
	public void maintenanceNotice(StompHeaderAccessor stompHeaderAccessor) {
        // 可根据自己业务逻辑编写,比如读取数据库一个标示,判断,是否要推送
        String status = "1";
		if(status.equals("1")){
            // 获取所有的登录用户, 发送消息
			Set<String> keys = redisTemplate.keys("wSocket" + "*");
			for (String key : keys) {
				String  val =(String)redisTemplate.opsForValue().get(key);
				String[] keySplit = key.split("_");
				if (keySplit != null && val.equals("1")) {
                    // 通知过后,修改值为2,就不会在通知了
					redisTemplate.opsForValue().getAndSet(key,"2");
					// 发送消息给指定用户
					template.convertAndSendToUser(key, "/queue/message", "系统即将进入维护状态,请尽快退出系统");
				}

			}
		}

	}

}

6.客户端 使用STOMP 进行通信

需要2个 js

链接: https://pan.baidu.com/s/1YNuw5hSzrtovqCazXZ9Etw

提取码: 4tvk

<script src="/js/webSocket/sockjs.min.js"></script>
<script src="/js/webSocket/stomp.min.js"></script>
var stompClient = null;
var socket = null;
$(function () {
    // 消息通知
    socket = new SockJS("http://localhost:8000/chat");
    stompClient = Stomp.over(socket);
    connect();
    //发送websocket通知,通信连接后再发送,延时3秒发送
    setTimeout("sendMsg()",3000);

});

function sendMsg(){
    stompClient.send("/app/singleLogout", {}, {});
    stompClient.send("/app/notice", {}, {});
}

//订阅消息F
function subscribe() {
    stompClient.subscribe('/user/queue/message', function (response) {
        console.log("/user/queue/message 你接收到的消息为:" + response.body);
    });

}

function connect() {

    stompClient.connect({
            name: '我叫某某某' // 携带客户端信息,可写可不可
        },
        function connectCallback(frame) {
            subscribe();

        },
        function errorCallBack(error) {
        console.log("连接出错了")

        });
}

使用 websocket + stomp 的消息推送功能就可以使用了。

我的 同一账号同时登录 强制 之前的账号退出的思路:

用户登录系统成功 - 》通信建立连接,订阅消息 -》 此时拦截器修改用户信息,添加到redis或者全局map或者数据库中 -》 

当第二个用户进入系统时,重复上述操作,

之后加入逻辑判断 根据 前缀+用户名 得到同一账户的多条登录信息。

判断和当前用户相同的用户,全部发送消息。 

如果需要几分钟退出系统的操作,可以加 定时器和清除session的逻辑进去。

系统维护通知思路:

用户登录系统同样需要拦截修改信息,放入缓存中。

当设置系统维护为维护状态:分在线和不在线2中情况处理:

1.用户在线:当调用修改接口,完成后,判断此时维护状态,获取 websocket前缀的所有信息,遍历推送消息。

2.用户不在线:用户登录时就订阅此消息,每次登录都查询维护状态进行判断,是维护状态则发送消息。

踩坑实记

1.上文在前后端不分离的项目上是没问题的,但是迁移到一个新的前后端分离(springboot+vue

)的项目时,websocket的send 始终连不到后台,最后发现由于配置文件中配置了接口前缀,导致接口不过来,此时修改前端不起作用。

需要修改 websocket 的消息前缀即可解决。

 

2.nginx 部署vue时,会报Handshake failed due to invalid Upgrade header: null 这个错误

需要在nginx配置文件中配置

proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";

如下

server {
        listen       8083;
        server_name  localhost;
        client_max_body_size   40m;
        charset utf-8;
        add_header Access-Control-Allow-Origin "*";

        location / {
              root   /var/opt//vue/dist; 
              index  index.html;
         try_files $uri $uri/ @router;
         #try_files $uri $uri/ /index.html =404;
         
        }

        location @router {
          rewrite ^.*$ /index.html last;
        } 

        location /api {
              proxy_pass http://172.168.10.101:8085;
              proxy_set_header Upgrade $http_upgrade;
              proxy_set_header Connection "upgrade";

         
        }
    }

  • 0
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
对于保存消息,可以使用数据库来存储历史消息,例如使用MySQL或者MongoDB等数据库。可以创建一个消息实体类,将消息内容、发送者、接收者、发送时间等信息存储到数据库中。 在Java后端的WebSocketHandler实现类中,当接收到WebSocket消息时,可以将消息保存到数据库中。可以使用JPA或者MyBatis等框架来操作数据库。具体实现步骤如下: 1. 创建一个消息实体类,例如: ```java @Entity @Table(name = "message") public class Message { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "content") private String content; @Column(name = "sender") private String sender; @Column(name = "receiver") private String receiver; @Column(name = "send_time") private Date sendTime; // getters and setters } ``` 2. 在WebSocketHandler实现类中,当接收到WebSocket消息时,可以将消息保存到数据库中。例如: ```java @Component public class MyWebSocketHandler implements WebSocketHandler { @Autowired private MessageRepository messageRepository; @Override public void handleMessage(WebSocketSession session, WebSocketMessage<?> message) throws Exception { String content = message.getPayload().toString(); String sender = (String) session.getAttributes().get("sender"); String receiver = (String) session.getAttributes().get("receiver"); Date sendTime = new Date(); Message msg = new Message(); msg.setContent(content); msg.setSender(sender); msg.setReceiver(receiver); msg.setSendTime(sendTime); messageRepository.save(msg); // 将消息保存到数据库中 // 处理消息并向客户端发送响应消息 } } ``` 3. 在消息列表页面中,可以从数据库中查询历史消息并显示在页面上。例如: ```java @Controller public class MessageController { @Autowired private MessageRepository messageRepository; @GetMapping("/messages") public String listMessages(Model model) { List<Message> messages = messageRepository.findAll(); model.addAttribute("messages", messages); return "messages"; } } ``` 4. 在前端的WebSocket对象上,可以通过监听onmessage事件,在接收到WebSocket消息后将消息显示在页面上。例如: ```javascript var ws = new WebSocket("ws://localhost:8080/ws"); ws.onmessage = function(event) { var message = event.data; // 将消息显示在页面上 }; ``` 这样就可以实现Java后端和前端之间的WebSocket消息推送和保存消息了。需要注意的是,在实际应用中还需要处理WebSocket连接的异常情况、消息的格式和安全性等问题。同时,还需要考虑如何对历史消息进行分页和排序等操作。
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值