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";
}
}