分布式websocket实战
分布式系统websocket实战
近期spring cloud项目中用到websocket,记录一下踩坑之路。
一、websocket简介
1 . 什么是WebSocket?
WebSocket是Html5提供的一种能在单个TCP连接上进行全双工通讯的协议
2. 为什么要使用WebSocket?
使用WebSocket最大的好处就是客户端能主动向服务端发送消息,服务端也能主动向客户端发送消息,能最大程度的保证信息交换的实时性。
WebSocket只需要建立一次连接,客户端和服务端只需要交换一次请求头和响应头就可以无数次交换信息。
3.WebSocket在分布式集群环境中的问题和解决思路
单机情况下,当websocket需要给用户推送消息时,由于用户已经与websocket服务建立连接,消息推送能够成功。
集群环境下,可能会遇到这样问题:给用户页面推送消息的websocket服务未必是与该用户建立websocket连接的服务。
解决方案思路1: 考虑websocket是否可以在多台机器上共享,实现数据共享,是否可以将websocketsession序列化后存储到redis里面?
在Spring所集成的WebSocket里面,每个ws连接都有一个对应的session:WebSocketSession,在Spring WebSocket中,我们建立ws连接之后可以通过类似这样的方式进行与客户端的通信。但是 ws的session无法序列化到redis, 因此在集群中,我们无法将所有WebSocketSession都缓存到redis进行session共享。
解决方案思路2: 确保和用户建立连接的websocket服务就是接收到消息的服务。将连接的服务端的ip存到redis里,gateway根据参数指定转发对应的服务器上, 还是能做到点对点,只要所有websocket工程都不会宕机。如果指定服务器宕机了,消息还是会发送失败。
解决方案思路3:只要确保对于业务模块发送的消息,所有的websocket服务都能收到消息,只要做到了这一点,与用户建立连接websocket自然也能接收到消息。(而且,这种方式相对单台服务收到消息还有一个在处理多点登陆场景下的优势。对于允许多点登录的系统,同一用户可以在多处进行登录,同一用户与多个服务拥有多个websocket连接,这就要求我们保证多台用户消费同一台业务模块的消息。)
按照此思路,引用消息队列,那么每一个websocket消息,我们在集群的每个节点上都进行推送,订阅了该消息的连接,不管有多少个,最终都能收到这个消息。
二、websocket实现用户下线通知实战
1 . 设计思路
项目中用户token储存在redis当中,并设置了过期时间,监听过期key,通过websocket通知到前端即可实现。
1)监听redis中token有效期是否到期
2)token到期后,利用spring cloud stream rabbit发送通知(生产者)
3)mq接收对应的通知消息,websocket向客户端发送消息(消费者)
4) 前端使用sockjs接收消息,并定向通知
2. redis监听
1)开启事件
redis 对事件的监听默认是关闭的,因为这会消耗性能
修改redis.config 文件
2)redis监听规则
事件是用 __keyspace@DB__:KeyPattern 或者 __keyevent@DB__:OpsType 的格式来发布消息的。
DB表示在第几个库;KeyPattern则是表示需要监控的键模式(可以用通配符,如:__key*__:*);OpsType则表示操作类型。因此,如果想要订阅特殊的Key上的事件,应该是订阅keyspace。
比如说,对 0 号数据库的键 mykey 执行 DEL 命令时, 系统将分发两条消息, 相当于执行以下两个 PUBLISH 命令:
PUBLISH __keyspace@0__:sampleKey del
PUBLISH __keyevent@0__:del sampleKey
订阅第一个频道 __keyspace@0__:mykey 可以接收 0 号数据库中所有修改键 mykey 的事件, 而订阅第二个频道 __keyevent@0__:del 则可以接收 0 号数据库中所有执行 del 命令的键。
3)监听器配置
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.listener.PatternTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;
/**
* redis监听器
*/
@Configuration
public class RedisSubListenerConfig {
// 初始化监听器
@Bean
RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory,
MessageListenerAdapter listenerAdapter) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
// 新增订阅频道及订阅者,订阅者必须有相关方法处理收到的消息
// __keyevent@*__:expired 订阅Redis的所有数据库的键值失效事件
container.addMessageListener(listenerAdapter, new PatternTopic("__keyevent@*__:expired"));
return container;
}
// 利用反射来创建监听到消息之后的执行方法
@Bean
MessageListenerAdapter listenerAdapter(RedisReceiver redisReceiver) {
return new MessageListenerAdapter(redisReceiver, "receiveMessage");
}
}
4)监听后执行方法
/**
* redis监听事件接收处理器
*/
@Component
public class RedisReceiver {
@Autowired
private IUserProducerService userProducerService;
// 收到通道的消息之后执行的方法
public void receiveMessage(Object message) {
}
}
5)重复监听问题
集群环境存在重复监听的问题,可利用redis的getset 命令方法进行解决
思路是:在过期回调事件中利用getset设置 [ key(当前监听到的过期key)+".lock"作为新的key ], 字符串"1"作为value,当某一个工程触发回调事件时,由于时第一次进入,此时 getset方法返回null(),由于redis是单线程,所以其他工厂虽然也到了这个方法这里,但是此时getset时返回的是我们设置的value值,所以通过判断,如果“1”.equals(返回的value)直接return掉,不往下执行,如果 ! “1”.equals(返回的value)则往下执行;当下面的步骤都执行完成了,再从redis删除掉这条数据,因为每一次过期回调都会利用getset产生一条数据,以免数据量多大造成积压。
@Override
public <T> T getAndSet(final String key, T value, long second) {
T oldValue = null;
try {
// 设置指定 key 的值,并返回 key 的旧值
// 返回给定 key 的旧值。 当 key 没有旧值时,即 key 不存在时,返回 null 。
//当 key 存在但不是字符串类型时,返回一个错误。
oldValue = (T) redisTemplate.opsForValue().getAndSet(key, value);
// 充当锁时加上过期时间,避免数据锁积压
if(second > 0){
redisTemplate.expire(key, second, TimeUnit.SECONDS);
}
} catch (Exception e) {
e.printStackTrace();
}
return oldValue;
}
import javax.annotation.Resource;
import org.springframework.stereotype.Component;
import com.zjrc.common.config.redis.IRedisService;
import com.zjrc.common.constant.CommonConstants;
import com.zjrc.websocket.mq.producer.IUserProducerService;
import cn.hutool.core.util.ObjectUtil;
/**
* redis监听事件接收处理器
*/
@Component
public class RedisReceiver {
@Resource
private IUserProducerService userProducerService;
@Resource
private IRedisService redisService;
// 收到通道的消息之后执行的方法
public void receiveMessage(Object message) {
if (ObjectUtil.isNull(message)) {
return;
}
String key = message.toString(); // 获取到过期的key
// 只处理token的key
if (key.indexOf(CommonConstants.CacheKey.REDIS_LOGIN_ACCESS_TOKEN) < 0) {
return;
}
// 插入一条数据,以充当锁使用
String oldLock = redisService.getAndSet(key + ".lock", "1", 10);
if ("1".equals(oldLock)) {
return;
}
// 取用户号码当做用户标识
String mobile = key.replace(CommonConstants.CacheKey.REDIS_LOGIN_ACCESS_TOKEN, "");
// 生产登陆超时消息
userProducerService.userLoginTimeout(mobile);
// 锁用完之后就删除,避免数据锁积压
redisService.del(key + ".lock");
}
}
3. spring cloud stream rabbit消息队列
1)简介
Spring Cloud Stream是一个构建消息驱动微服务的框架,遵循“智能端点和哑管道”的原则。端点之间的通信由消息中间件(如RabbitMQ或Apache Kafka)驱动。服务通过这些端点或信道发布事件来进行通信。
应用程序通过input(相当于consumer)、**output(相当于producer)**来与Spring Cloud Stream中Binder交互,而Binder负责与消息中间件交互;因此,我们只需关注如何与Binder交互即可,而无需关注与具体消息中间件的交互。
2)Maven依赖
添加Spring Cloud Stream与RabbitMQ消息中间件的依赖
<!--spring cloud stream-rabbit begin-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-stream-rabbit</artifactId>
</dependency>
<!--spring cloud stream-rabbit end-->
3)配置文件
生产和消费如果不是在同一个服务上,只要配置对应的生产或者消费的信息,目前测试项目放到一起了。
spring:
cloud:
stream:
bindings:
userInput: #input和output 名称需要与代码中@Input和@Output设置的通道保持一致(坑呀!)
destination: test_rabbit #Exchange名称,input与output需保持一致
binder: local_rabbit
userOutput:
destination: test_rabbit
binder: local_rabbit
binders:
local_rabbit:
type: rabbit
environment: #配置rabbimq连接环境
spring:
rabbitmq:
host: localhost
port: 5672
username: guest
password: guest
启动之后可以在rabbitmq可视化界面Exchange中看到定义的test_rabbit
4)生产者
定义生产的通道
import org.springframework.cloud.stream.annotation.Output;
import org.springframework.messaging.MessageChannel;
/**
* 用户发送通道(生产者)
*/
public interface UserSendChannel {
String USER_OUTPUT = "userOutput";
@Output(UserSendChannel.USER_OUTPUT)
MessageChannel userOutput();
}
生产消息并将消息发送给消费者
在这里插入代码片import javax.annotation.Resource;
import org.springframework.cloud.stream.annotation.EnableBinding;
import org.springframework.cloud.stream.annotation.Output;
import org.springframework.integration.support.MessageBuilder;
import org.springframework.messaging.MessageChannel;
import org.springframework.stereotype.Service;
import com.google.gson.JsonObject;
import com.zjrc.websocket.mq.channel.UserSendChannel;
@Service
@EnableBinding(UserSendChannel.class)
public class UserProducerServiceIml implements IUserProducerService {
@Resource
@Output(UserSendChannel.USER_OUTPUT)
private MessageChannel channel;
/**
* 登陆超时提示(生产者)
*
* @param userTags 用户标识
*/
@Override
public void userLoginTimeout(String userTags) {
// 组装消息
JsonObject jsonObject = new JsonObject();
jsonObject.addProperty("mobile", userTags);
jsonObject.addProperty("message", "该用户token已过期,请重新登录");
// 发送消息给消费者
channel.send(MessageBuilder.withPayload(jsonObject.toString()).build());
System.out.println("消息发送成功" + jsonObject.toString());
}
}
5)消费者
消费通道
import org.springframework.cloud.stream.annotation.Input;
import org.springframework.messaging.SubscribableChannel;
/**
* 用户接收通道(消费者)
*/
public interface UserReceiverChannel {
String USER_INPUT = "userInput";
@Input(UserReceiverChannel.USER_INPUT)
SubscribableChannel userInput();
}
接收消息
import org.springframework.cloud.stream.annotation.EnableBinding;
import org.springframework.cloud.stream.annotation.StreamListener;
import org.springframework.messaging.Message;
import org.springframework.stereotype.Component;
import com.zjrc.websocket.mq.channel.UserReceiverChannel;
/**
* 用户消息消费者
*/
@Component
@EnableBinding(UserReceiverChannel.class)
public class UserConsumer {
/**
* 接收消息
*
* @param message
*/
@StreamListener(UserReceiverChannel.USER_INPUT)
public void receive(Message<String> message) {
System.out.println(message.getPayload());
}
}
最终在rabbit界面Queues中查看到这个队列的生成和消费情况
4. websocket
1)Maven依赖
<!--websocket begin-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!--websocket end-->
2)WebSocket配置文件
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
/**
* websocket配置
*/
@Configuration
@EnableWebSocketMessageBroker // 注解开启STOMP协议来传输基于代理的消息,此时控制器支持使用
public class WebSocketAutoConfig implements WebSocketMessageBrokerConfigurer {
/**
* 将"/mq"路径注册为STOMP端点 PS:端点的作用——客户端在订阅或发布消息到目的地址前,要连接该端点。
*
* @param registry
*/
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/mq") // 开启mq端点
.setAllowedOrigins("*") // 允许跨域访问
.withSockJS(); // 使用sockJS
}
/**
* 配置了一个简单的消息代理,如果不重载,默认情况下回自动配置一个简单的内存消息代理,用来处理以"/topic"为前缀的消息。这里重载configureMessageBroker()方法,
* 消息代理将会处理前缀为"/topic"和"/queue"的消息。
*
* @param registry
*/
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 应用程序以/app为前缀,代理目的地以/topic、/user为前缀
registry.enableSimpleBroker("/topic", "/user"); // 向客户端发消息topic用来广播,user用来实现p2p
registry.setApplicationDestinationPrefixes("/app"); // 客户端向服务器端发送时的主题上面需要加"/app"作为前缀
registry.setUserDestinationPrefix("/user"); // 给指定用户发送一对一的主题前缀是"/user"
}
}
3)sockjs
前端通过sockjs来进行websock连接
<template>
<div>
<button @click="send">发消息</button>
</div>
</template>
<script>
import SockJS from "sockjs-client";
import Stomp from "stompjs";
export default {
data() {
return {
stompClient: "",
timer: ""
};
},
methods: {
initWebSocket() {
this.connection();
let that = this;
// 断开重连机制,尝试发送消息,捕获异常发生时重连
this.timer = setInterval(() => {
try {
that.stompClient.send("test");
} catch (err) {
console.log("断线了: " + err);
that.connection();
}
}, 5000);
},
connection() {
// 建立连接对象
let socket = new SockJS("http://localhost:8000/websocket/mq");
// 获取STOMP子协议的客户端对象
this.stompClient = Stomp.over(socket);
// 定义客户端的认证信息,按需求配置
let headers = {
Authorization: ""
};
// 向服务器发起websocket连接
this.stompClient.connect(
headers,
() => {
// this.stompClient.subscribe('/topic/userOnlineStatus', (msg) => { // 订阅服务端提供的某个topic
// console.log('广播成功')
// console.log(msg); // msg.body存放的是服务端发送给我们的信息
// },headers);
var userId = 1;
this.stompClient.subscribe(
"/user/" + userId + "/userOnlineStatus",
msg => {
// 订阅服务端提供的某个topic
console.log("一对一发送成功");
console.log(msg); // msg.body存放的是服务端发送给我们的信息
},
headers
);
this.stompClient.send(
"/app/chat.addUser",
headers,
JSON.stringify({ sender: "", chatType: "JOIN" })
); //用户加入接口
},
err => {
// 连接发生错误时的处理函数
console.log("失败");
console.log(err);
}
);
}, //连接 后台
send() {
this.stompClient.send(
"/subscribe",
{},
JSON.stringify({ name: 1111111 })
);
},
disconnect() {
if (this.stompClient) {
this.stompClient.disconnect();
}
} // 断开连接
},
mounted() {
this.initWebSocket();
},
beforeDestroy: function() {
// 页面离开时断开连接,清除定时器
this.disconnect();
clearInterval(this.timer);
}
};
</script>
<style></style>
SockJS客户端开始时会发送一个GET类型的"/info"请求从服务器去获取基本信息, 这个请求之后SockJS必须决定使用哪种传输,可能是WebSocket,如果不是的话,在大部分浏览器中会使用HTTP Streaming或者HTTP长轮询。
由于项目所有请求走的都是网关gateway,因此在网关这层必须要特殊处理这个"/info" 请求
4)网关配置
网关路由配置需要加上这两个配置,注意顺序不能调换。
spring:
cloud:
gateway:
routes:
- id: websocket_sockjs_route
uri: lb://zjce-service-websocket
predicates:
- Path=/websocket/mq/info** # 将对应请求路径的请求转发到 zjce-service-websocket服务
- id: zjce-service-websocket
uri: lb:ws://zjce-service-websocket
predicates:
- Path=/websocket/** # 将对应请求路径的请求转换成ws协议到zjce-service-websocket服务
gateway内置 WebsocketRoutingFilter Websocket 路由网关过滤器。
private void changeSchemeIfIsWebSocketUpgrade(ServerWebExchange exchange) {
// Check the Upgrade
URI requestUrl = exchange.getRequiredAttribute(GATEWAY_REQUEST_URL_ATTR);
String scheme = requestUrl.getScheme().toLowerCase();
String upgrade = exchange.getRequest().getHeaders().getUpgrade();
// change the scheme if the socket client send a "http" or "https"
if ("WebSocket".equalsIgnoreCase(upgrade) && ("http".equals(scheme) || "https".equals(scheme))) {
String wsScheme = convertHttpToWs(scheme);
URI wsRequestUrl = UriComponentsBuilder.fromUri(requestUrl).scheme(wsScheme).build().toUri();
exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, wsRequestUrl);
if (log.isTraceEnabled()) {
log.trace("changeSchemeTo:[" + wsRequestUrl + "]");
}
}
}
查看源码可以看到,匹配到WebSocket请求后,将http或https协议升级为ws或wss协议
配置成功后可以看到前端info请求成功,并返回websocket:true
5)发送websocket消息
结合前面的消息消费者,将接受到的消息通过websocket发送到
import javax.annotation.Resource;
import org.springframework.cloud.stream.annotation.EnableBinding;
import org.springframework.cloud.stream.annotation.StreamListener;
import org.springframework.messaging.Message;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Component;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.zjrc.websocket.mq.channel.UserReceiverChannel;
/**
* 用户消息消费者
*/
@Component
@EnableBinding(UserReceiverChannel.class)
public class UserConsumer {
@Resource
private SimpMessagingTemplate template;
// 广播消息
private final static String DESTINATION = "/topic/userOnlineStatus";
// 点对点消息
private final static String USER_DESTINATION = "/userOnlineStatus";
/**
* 接收消息并向客户端指定用户发送消息
*
* @param message
*/
@StreamListener(UserReceiverChannel.USER_INPUT)
public void receive(Message<String> message) {
System.out.println(message.getPayload());
// 接收转换消息
String dataStr = message.getPayload();
JSONObject data = JSON.parseObject(dataStr);
String mobile = (String) data.get("mobile"); // 客户端对应的用户
String messageStr = (String) data.get("message"); // 客户端接收的消息
// 向客户端广播消息
template.convertAndSend(DESTINATION, messageStr);
// 向客户端指定用户发送消息
template.convertAndSendToUser(mobile, USER_DESTINATION, messageStr);
System.out.println("消费成功" + messageStr);
}
}
6)最终效果
前端console打印后台发送的消息