1024
写篇文章庆祝一下
本文介绍
最近公司需求需要使用到服务端的主动推送功能,在学习和研究下整理出这篇文章用于学习和记录,本文只讲技术的基本应用,没有原理介绍。
采用技术:springboot
、websocket
、redis
发布订阅
需求
实现服务端多节点的情况下,主动推送消息到客户端
WebSokcet
WebSocket
介绍:
WeSocket
是一种协议,与Http
是一个等级的,并且也是基于TCP
协议,可以理解为WebSoket
是Http
的优化 常规的场景下,一般都是由客户端使用
HTTP
发送一个Request
,对应的服务端接受到Request
请求后进行处理并返回Response
给客户端进行响应,这是由客户端主动发起的,服务端无法控制这个时机,如果客户端需要长时间的刷新数据的话,传统的方式就要考虑使用ajax
轮询这样的策略进行循环调用HTTP
,并且一次HTTP
请求完整流程需要进行三次握手和四次挥手,十分低效且耗费资源
Websocket
对上述的问题进行了改进,复用了HTTP
的握手通道,客户端和服务器只需要完成一次握手,建立起长连接之后客户端和服务端就可以进行双向数据通信,详情查看参考文章
先来实现在单节点的情况下服务端和客户端的双向通信
使用SpringBoot
来实现WebSokcet
是十分方便的,官方也提供了相关的start
工程进行使用
<!--websocket-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
完整pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.3.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.caihe.websocket</groupId>
<artifactId>websokcet-redis-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>websokcet-redis-demo</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
添加一个websocket
配置类
package com.caihe.websocket.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
/**
* websocket的配置类
*
* @author hecai
* @date 2021/10/24
*/
@Configuration
@EnableWebSocket
public class WebSocketConfiguration {
/**
* 这个配置类的作用是要注入ServerEndpointExporter,会自动注册使用了@ServerEndpoint注解声明的Websocket endpoint
* 如果采用tomcat容器进行部署启动,而不是直接使用springboot的内置容器
* 就不要注入ServerEndpointExporter,因为它将由容器自己提供和管理。
*/
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
创建websocket
核心处理类,用于处理连接和消息的相关操作
package com.caihe.websocket.component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
/**
* websocket处理创建、推送、接受、关闭类
* ServerEndpoint 定义websocket的监听连接地址
*
* @author hecai
* @date 2021/10/24
*/
@Component
@ServerEndpoint("/websocket/{id}")
public class WebSocketServer {
private static Logger logger = LoggerFactory.getLogger(WebSocketServer.class);
/**
* 用来存放每个客户端对应的 Session 对象, session对象存储着连接信息
*/
private static ConcurrentHashMap<Integer, Session> webSocketMap = new ConcurrentHashMap<>();
/**
* 创建连接
*/
@OnOpen
public void onOpen(Session session, @PathParam("id") Integer id) {
if (Objects.nonNull(webSocketMap.get(id))) {
// 如果存在就先移除
webSocketMap.remove(id);
} else {
webSocketMap.put(id, session);
sendMessage(String.format("%s,%s", id, "连接成功"));
}
logger.info(String.format("用户【%s】创建连接成功!", id));
}
/**
* 根据消息体内容发送消息
*/
private void sendMessage(String messageBody) {
String[] split = messageBody.split(",");
Integer id = Integer.parseInt(split[0]);
String message = split[1];
Session session = webSocketMap.get(id);
try {
// 服务端推送消息给监听的客户端
session.getBasicRemote().sendText(message);
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 接受消息
*/
@OnMessage
public void onMessage(String message) {
sendMessage(message);
}
/**
* 关闭连接
*/
@OnClose
public void onClose(@PathParam("id") Integer id) {
try {
webSocketMap.remove(id).close();
logger.info(String.format("用户【%s】关闭连接成功!", id));
} catch (IOException e) {
logger.error(String.format("用户【%s】关闭连接失败!", id));
}
}
/**
* 发生错误
*/
@OnError
public void onError(Session session, Throwable error) {
}
}
有了上述操作后就可以直接启动springboot
应用,借助一个网页websocket
测试工具页面,模拟客户端到服务端的过程,
websocket
前端测试工具页面:http://www.easyswoole.com/wstool.html
监听链接:ws://localhost:8080/websocket/用户id
注意 链接是ws开头 并且将 用户id 替换为数字
springboot
启动后,点击开启连接
就可以进行测试
现在我会在刚刚启动的springboot 8080
端口创建用户1001
和用户1002
的两个连接,然后实现两个用户之间的互相通信,自定义的消息体规则是用户id,消息体
目前就实现了单节点的客户端和服务端的双向通信
但是如果springboot
应用再启动一个9090
来模拟第二个节点,并用1003
用户在9090
上创建连接,这样1001
和1003
是不能进行通信
由上图发现并没有实现互相通信
此时就需要实现两个服务端之间的通信,采用Redis
发布订阅
Redis发布订阅
redis
除了常用做缓存之外,还有发布/订阅
的功能
redis
提供了publish/subscribe
命令来完成发布、订阅操作
这里只告知redis
发布订阅的简单应用,如需了解更多操作可自行百度相关信息
实践一下
举例:我把信道比作公众号
比如我要订阅一个叫caihego
的公众号,使用命令subscribe caihego
进行订阅
现在caihego
公众号想要发送内容为’hello everyone’的文章,使用命令publish caihego 'hello everyone'
发现左边订阅的收到了来自caihego
公众号的文章
使用这种机制,让所有节点的服务端都订阅同一个信道,然后当任意节点收到请求或消息后都可以触发发布机制,这样不管需要通知的用户在哪个节点上都可以收到消息
进行整合
利用上述机制,就可以基本实现多节点下的websocket
通信
代码展示:
yml
配置文件修改:
spring:
redis:
host: xx.xx.xx.xx # 改为自己的redis地址
password: 123456 # 有密码就修改为自己密码 无密码就删掉这行
port: 6379
ssl: false
jedis:
pool:
max-idle: 10
max-wait: 60000
pom.xml
依赖添加:
<!-- redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
因为有修改代码所以贴出所有代码
package com.caihe.websocket.component;
import com.caihe.websocket.config.RedisConfig;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
/**
* websocket处理创建、推送、接受、关闭类
* ServerEndpoint 定义websocket的监听连接地址
*
* @author hecai
* @date 2021/10/24
*/
@Component
@ServerEndpoint("/websocket/{id}")
public class WebSocketServer {
private static Logger logger = LoggerFactory.getLogger(WebSocketServer.class);
/**
* 用来存放每个客户端对应的 Session 对象, session对象存储着连接信息
*/
private static ConcurrentHashMap<Integer, Session> webSocketMap = new ConcurrentHashMap<>();
private static StringRedisTemplate template;
@Autowired
public void setStringRedisTemplate(StringRedisTemplate template) {
WebSocketServer.template = template;
}
/**
* 创建连接
*/
@OnOpen
public void onOpen(Session session, @PathParam("id") Integer id) {
if (Objects.nonNull(webSocketMap.get(id))) {
// 如果存在就先移除
webSocketMap.remove(id);
} else {
webSocketMap.put(id, session);
sendMessage(String.format("%s,%s", id, "连接成功"));
}
logger.info(String.format("用户【%s】创建连接成功!", id));
}
/**
* 根据消息体内容发送消息
*/
public void sendMessage(String messageBody) {
String[] split = messageBody.split(",");
Integer id = Integer.parseInt(split[0]);
String message = split[1];
Session session = webSocketMap.get(id);
try {
// 服务端推送消息给监听的客户端
session.getBasicRemote().sendText(message);
} catch (IOException e) {
e.printStackTrace();
}
}
/**
* 接受消息
*/
@OnMessage
public void onMessage(String message) {
template.convertAndSend(RedisConfig.REDIS_CHANNEL, message);
}
/**
* 关闭连接
*/
@OnClose
public void onClose(@PathParam("id") Integer id) {
try {
webSocketMap.remove(id).close();
logger.info(String.format("用户【%s】关闭连接成功!", id));
} catch (IOException e) {
logger.error(String.format("用户【%s】关闭连接失败!", id));
}
}
/**
* 发生错误
*/
@OnError
public void onError(Session session, Throwable error) {
}
}
package com.caihe.websocket.config;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.listener.PatternTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;
/**
* redis配置
*/
@Configuration
@EnableCaching
public class RedisConfig {
/**
* 定义信道名称
*/
public static final String REDIS_CHANNEL = "caihego";
@Bean
RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory, MessageListenerAdapter listenerAdapter) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
// 订阅消息频道
container.addMessageListener(listenerAdapter, new PatternTopic(REDIS_CHANNEL));
return container;
}
@Bean
MessageListenerAdapter listenerAdapter(RedisReceiver receiver) {
// 消息监听适配器
return new MessageListenerAdapter(receiver, "onMessage");
}
@Bean
StringRedisTemplate template(RedisConnectionFactory connectionFactory) {
return new StringRedisTemplate(connectionFactory);
}
}
package com.caihe.websocket.config;
import com.caihe.websocket.component.WebSocketServer;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
/**
* 消息监听对象,接收订阅消息
*/
@Component
public class RedisReceiver implements MessageListener {
Logger log = LoggerFactory.getLogger(this.getClass());
@Autowired
private WebSocketServer webSocketServer;
/**
* 处理接收到的订阅消息
*/
@Override
public void onMessage(Message message, byte[] pattern) {
// 订阅的频道名称
String channel = new String(message.getChannel());
String msg = "";
try {
msg = new String(message.getBody());
if (!StringUtils.isEmpty(msg)) {
if (RedisConfig.REDIS_CHANNEL.endsWith(channel)) {
webSocketServer.sendMessage(msg);
} else {
// todo 处理其他订阅的消息
}
} else {
log.info("消息内容为空,不处理。");
}
} catch (Exception e) {
log.error("处理消息异常:" + e.toString());
e.printStackTrace();
}
}
}
效果
大家会发现,即使在不同的服务器中,两个用户之间也能互相收到消息,这样就完美的实现了多节点下的websocket
通信啦
其实这会有一个小坑,大家会发现部署到线上去的时候会断连特别严重,几乎无法保证使用的稳定,这是因为一般都会有nginx
在服务器前拦着,由nginx
进行代理分发,需要配置和websocket
相关参数即可,默认断连时间是60s
,需设置proxy_read_timeout
时间长一点,或者添加心跳检测方案
可以参考如下配置:
server {
listen 80;
server_name carrefourzone.senguo.cc;
#error_page 502 /static/502.html;
location /static/ {
root /home/chenming/Carrefour/carrefour.senguo.cc/source;
expires 7d;
}
location / {
proxy_pass_header Server;
proxy_set_header Host $http_host;
proxy_redirect off;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Scheme $scheme;
proxy_pass http://127.0.0.1:9887;
proxy_http_version 1.1;
proxy_set_header Upgrade "websocket";
proxy_set_header Connection "Upgrade";
proxy_read_timeout 600s;
}
}
PS:时间有点赶,想在今天(1024)就写完文章,所以有点粗糙,有问题可提出指正
源码地址:https://gitee.com/searonhe/websocket-redis-demo