【实战】SpringBoot整合Websocket、Redis实现Websocket集群负载均衡 ...

金歌  论坛元老 | 2024-8-29 01:28:24 | 显示全部楼层 | 阅读模式
打印 上一主题 下一主题

主题 1056|帖子 1056|积分 3168

前言

信赖很多同学都用过websocket来实现服务端主动向客户端推送消息吧,基本上全部的管理类系统都会有这个功能。由于有websocket的存在,使得前后的主动交互变得容易和低本钱。其实在JAVA领域用SpringBoot框架集成Websoket照旧很简单的,本日我们重点不是集成而是通过Redis的发布订阅实现Websocket集群通讯,当然有条件的也可以用MQ代替。
技术积累

什么是Websocket

WebSocket是一种在单个TCP连接上举行全双工通讯的协议。WebSocket通讯协议于2011年被IETF定为标准RFC 6455,并由RFC7936增补规范。WebSocket API也被W3C定为标准。
WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只必要完成一次握手,两者之间就直接可以创建持久性的连接,并举行双向数据传输。

什么是Redis发布订阅

Redis 的发布订阅(Pub/Sub)是一种消息通讯模式:发送者(pub)发送消息,订阅者(sub)接收消息。Redis 客户端可以订阅恣意数目的频道。当有新消息通过 PUBLISH 下令发送给频道时,这个消息会被发送给订阅它的全部客户端。

Redis发布订阅与消息队列的区别

Redis的发布订阅(Pub/Sub)和消息队列是两种不同的消息通报模式,它们的紧张区别在于消息的处理方式和使用场景。
消息的处理方式:
在 Redis 的发布订阅模式中,消息是即时的,也就是说,当消息发布后,只有当前在线且订阅了该频道的客户端才能收到这个消息,消息不会被存储,一旦发布,当前没有在线的客户端将无法接收到这个消息。
在消息队列中,消息是持久化的,消息被发送到队列后,会一直在队列中等候被消费,即使没有在线的消费者,消息也不会丢失,消费者下次上线后可以继承从队列中获取到消息。
实战演示

本次演示采用demo情势,仅仅提供演示Websocket集群的实现方式,以及解决消息负载均衡的问题。演示案例重点偏后端实现Websoket集群通讯,不涉及前后端心跳检测,如果应用在生产环境前端必要增加心跳检测与重复创建。
本实战采用原生spring websocket,后续有时间再提供netty版本,以及产线版本。有条件的同学可以自己实现,原理都差不多。
SpringBoot整合Websoket

1、项目结构

2、springcloud 版本
  1. <parent>
  2.     <groupId>org.springframework.boot</groupId>
  3.     <artifactId>spring-boot-starter-parent</artifactId>
  4.     <version>2.3.12.RELEASE</version>
  5.     <relativePath/> <!-- lookup parent from repository -->
  6. </parent>
  7. <properties>
  8.     <java.version>8</java.version>
  9.     <spring-cloud.version>Hoxton.SR12</spring-cloud.version>
  10. </properties>
  11. <dependencyManagement>
  12.     <dependencies>
  13.         <dependency>
  14.             <groupId>org.springframework.cloud</groupId>
  15.             <artifactId>spring-cloud-dependencies</artifactId>
  16.             <version>${spring-cloud.version}</version>
  17.             <type>pom</type>
  18.             <scope>import</scope>
  19.         </dependency>
  20.     </dependencies>
  21. </dependencyManagement>
复制代码
3、maven依赖
  1. <dependency>
  2.     <groupId>com.alibaba</groupId>
  3.     <artifactId>fastjson</artifactId>
  4.     <version>1.2.68</version>
  5. </dependency>
  6. <dependency>
  7.     <groupId>org.springframework.boot</groupId>
  8.     <artifactId>spring-boot-starter-websocket</artifactId>
  9. </dependency>
  10. <!-- 整合thymeleaf前端页面 -->
  11. <dependency>
  12.     <groupId>org.springframework.boot</groupId>
  13.     <artifactId>spring-boot-starter-thymeleaf</artifactId>
  14. </dependency>
复制代码
4、设置文件
  1. server:
  2.   port: 8888
  3. spring:
  4.   profiles:
  5.     active: dev
  6.   mvc:
  7.     pathmatch:
  8.       # Springfox使用的路径匹配是基于AntPathMatcher的,而Spring Boot 2.6.X使用的是PathPatternMatcher
  9.       matching-strategy: ant_path_matcher
  10.   thymeleaf:
  11.     mode: HTML
  12.     encoding: UTF-8
  13.     content-type: text/html
  14.     cache: false
  15.     prefix: classpath:/templates/
复制代码
5、thymeleaf 页面
websocket.html
  1. <!DOCTYPE html>
  2. <html>
  3. <head>
  4.     <meta charset="utf-8">
  5.     <title>websocket通讯</title>
  6. </head>
  7. <script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.js"></script>
  8. <script>
  9.     var socket;
  10.     function openSocket() {
  11.         if (typeof (WebSocket) == "undefined") {
  12.             console.log("您的浏览器不支持WebSocket");
  13.         } else {
  14.             console.log("您的浏览器支持WebSocket");
  15.             //实现化WebSocket对象,指定要连接的服务器地址与端口  建立连接
  16.             //等同于socket = new WebSocket("ws://localhost:8888/xxxx/im/25");
  17.             //var socketUrl="${request.contextPath}/im/"+$("#userId").val();
  18.             var socketUrl = "ws://192.168.1.4:7777/ws/" + $("#userId").val();
  19.             socketUrl = socketUrl.replace("https", "ws").replace("http", "ws");
  20.             console.log(socketUrl);
  21.             if (socket != null) {
  22.                 socket.close();
  23.                 socket = null;
  24.             }
  25.             socket = new WebSocket(socketUrl);
  26.             //打开事件
  27.             socket.onopen = function () {
  28.                 console.log("websocket已打开");
  29.                 //socket.send("这是来自客户端的消息" + location.href + new Date());
  30.             };
  31.             //获得消息事件
  32.             socket.onmessage = function (msg) {
  33.                 console.log("接收消息为:"+msg.data);
  34.             };
  35.             //关闭事件
  36.             socket.onclose = function () {
  37.                 console.log("websocket已关闭");
  38.             };
  39.             //发生了错误事件
  40.             socket.onerror = function () {
  41.                 console.log("websocket发生了错误");
  42.             }
  43.         }
  44.     }
  45.     //心跳检测与重复验证自己实现
  46.     function sendMessage() {
  47.         if (typeof (WebSocket) == "undefined") {
  48.             console.log("您的浏览器不支持WebSocket");
  49.         } else {
  50.             console.log("您的浏览器支持WebSocket");
  51.             console.log('发送消息为:{"fromUserId":"' + $("#userId").val() + '","toUserId":"' + $("#toUserId").val() + '","contentText":"' + $("#contentText").val() + '"}');
  52.             socket.send('{"fromUserId":"' + $("#userId").val() + '","toUserId":"' + $("#toUserId").val() + '","contentText":"' + $("#contentText").val() + '"}');
  53.         }
  54.     }
  55. </script>
  56. <body>
  57. <p>【userId】:<div><input id="userId" name="userId" type="text" value="10"></div>
  58. <p>【toUserId】:<div><input id="toUserId" name="toUserId" type="text" value="20"></div>
  59. <p>【内容】:<div><input id="contentText" name="contentText" type="text" value="hello websocket"></div>
  60. <p>【操作】:<div><button onclick="openSocket()">开启socket</button></div>
  61. <p>【操作】:<div><button onclick="sendMessage()">发送消息</button></div>
  62. </body>
  63. </html>
复制代码
6、消息实体
Message.java
  1. import lombok.Data;
  2. /**
  3. * Message
  4. * @author senfel
  5. * @version 1.0
  6. * @date 2024/5/17 14:39
  7. */
  8. @Data
  9. public class Message {
  10.     /**
  11.      * 消息编码
  12.      */
  13.     private String code;
  14.     /**
  15.      * 来自(保证唯一)
  16.      */
  17.     private String fromUserId;
  18.     /**
  19.      * 去自(保证唯一)
  20.      */
  21.     private String toUserId;
  22.     /**
  23.      * 内容
  24.      */
  25.     private String contentText;
  26. }
复制代码
7、Websocket设置类
WebSocketConfig.java
  1. import org.springframework.context.annotation.Bean;
  2. import org.springframework.stereotype.Component;
  3. import org.springframework.web.socket.server.standard.ServerEndpointExporter;
  4. /**
  5. * WebSocketConfig
  6. * @author senfel
  7. * @version 1.0
  8. * @date 2024/5/16 16:51
  9. */
  10. @Component
  11. public class WebSocketConfig {
  12.     @Bean
  13.     public ServerEndpointExporter serverEndpointExporter() {
  14.         System.out.println("启动websocket支持");
  15.         return new ServerEndpointExporter();
  16.     }
  17. }
  18. Websocket 服务类
  19. WebSocketServer.java
  20. import com.example.ccedemo.config.SpringUtil;
  21. import org.apache.commons.lang3.StringUtils;
  22. import org.slf4j.Logger;
  23. import org.slf4j.LoggerFactory;
  24. import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
  25. import org.springframework.data.redis.core.StringRedisTemplate;
  26. import org.springframework.stereotype.Component;
  27. import javax.websocket.*;
  28. import javax.websocket.server.PathParam;
  29. import javax.websocket.server.ServerEndpoint;
  30. import java.util.concurrent.ConcurrentHashMap;
  31. import java.util.concurrent.CopyOnWriteArraySet;
  32. /**
  33. * WebSocketServer
  34. * @author senfel
  35. * @version 1.0
  36. * @date 2024/5/16 16:59
  37. */
  38. @ConditionalOnClass(value = WebSocketConfig.class)
  39. @Component
  40. @ServerEndpoint("/ws/{deviceId}")
  41. public class WebSocketServer {
  42.     protected Logger logger = LoggerFactory.getLogger(this.getClass());
  43.     /**与某个客户端的连接会话,需要通过它来给客户端发送数据*/
  44.     private Session session;
  45.     /**
  46.      * 设备ID
  47.      */
  48.     private String deviceId;
  49.     /**concurrent包的线程安全Set,用来存放每个客户端对应的MyWebSocket对象。
  50.     虽然@Component默认是单例模式的,但springboot还是会为每个websocket连接初始化一个bean,所以可以用一个静态set保存起来。
  51.     注:底下WebSocket是当前类名*/
  52.     private static CopyOnWriteArraySet<WebSocketServer> webSockets =new CopyOnWriteArraySet<>();
  53.     /**用来存在线连接用户信息*/
  54.     private static ConcurrentHashMap<String,Session> sessionPool = new ConcurrentHashMap<String,Session>();
  55.     /**
  56.      * 链接成功调用的方法
  57.      */
  58.     @OnOpen
  59.     public void onOpen(Session session, @PathParam(value="deviceId")String deviceId) {
  60.         try {
  61.             if(StringUtils.isEmpty(deviceId)||deviceId.equals("undefined")){
  62.                 return;
  63.             }
  64.             this.session = session;
  65.             this.deviceId = deviceId;
  66.             webSockets.add(this);
  67.             sessionPool.put(deviceId, session);
  68.             logger.info("【websocket消息】有新的连接,总数为:"+webSockets.size());
  69.             StringBuffer stringBuffer = new StringBuffer();
  70.             sessionPool.forEach((key, value) -> {
  71.                 stringBuffer.append(key).append(";");
  72.             });
  73.             logger.info("当前服务器连接有客户端有:"+stringBuffer.toString());
  74.         } catch (Exception e) {
  75.         }
  76.     }
  77.     /**
  78.      * 链接关闭调用的方法
  79.      */
  80.     @OnClose
  81.     public void onClose() {
  82.         try {
  83.             webSockets.remove(this);
  84.             sessionPool.remove(this.deviceId);
  85.             logger.info("【websocket消息】连接断开,总数为:"+webSockets.size());
  86.             StringBuffer stringBuffer = new StringBuffer();
  87.             sessionPool.forEach((key, value) -> {
  88.                 stringBuffer.append(key).append(";");
  89.             });
  90.             logger.info("当前服务器连接有客户端有:"+stringBuffer.toString());
  91.         } catch (Exception e) {
  92.         }
  93.     }
  94.     /**
  95.      * 收到客户端消息后调用的方法
  96.      * @param message
  97.      */
  98.     @OnMessage
  99.     public void onMessage(String message) {
  100.         logger.info("【websocket消息】收到客户端消息:"+message);
  101.         SpringUtil.getBean(StringRedisTemplate.class).convertAndSend("webSocketMsgPush",message);
  102.     }
  103.     /** 发送错误时的处理
  104.      * @param session
  105.      * @param error
  106.      */
  107.     @OnError
  108.     public void onError(Session session, Throwable error) {
  109.         logger.error("用户错误,原因:"+error.getMessage());
  110.         error.printStackTrace();
  111.     }
  112.     /**
  113.      * 广播消息
  114.      * @author senfel
  115.      * @date 2024/5/17 17:10
  116.      * @return void
  117.      */
  118.     public void sendAllMessage(String message) {
  119.         logger.info("【websocket消息】广播消息:"+message);
  120.         for(WebSocketServer webSocket : webSockets) {
  121.             try {
  122.                 if(webSocket.session.isOpen()) {
  123.                     webSocket.session.getAsyncRemote().sendText(message);
  124.                 }
  125.             } catch (Exception e) {
  126.                 e.printStackTrace();
  127.             }
  128.         }
  129.     }
  130.     /**
  131.      * 单点消息 单人
  132.      * @param deviceId
  133.      * @param message
  134.      * @author senfel
  135.      * @date 2024/5/17 17:10
  136.      * @return void
  137.      */
  138.     public void sendOneMessage(String deviceId, String message) {
  139.         Session session = sessionPool.get(deviceId);
  140.         if (session != null&&session.isOpen()) {
  141.             try {
  142.                 logger.info("【websocket消息】 单点消息:"+message);
  143.                 session.getAsyncRemote().sendText(message);
  144.             } catch (Exception e) {
  145.                 e.printStackTrace();
  146.             }
  147.         }
  148.     }
  149.     /**
  150.      * 单点消息
  151.      * @param deviceId
  152.      * @param object
  153.      * @author senfel
  154.      * @date 2024/5/17 17:10
  155.      * @return void
  156.      */
  157.     public void sendOneObject(String deviceId, Object object) {
  158.         Session session = sessionPool.get(deviceId);
  159.         if (session != null&&session.isOpen()) {
  160.             try {
  161.                 logger.info("【websocket消息】 单点消息(对象):"+object);
  162.                 session.getAsyncRemote().sendObject(object);
  163.             } catch (Exception e) {
  164.                 e.printStackTrace();
  165.             }
  166.         }
  167.     }
  168.     /**
  169.      * 单点消息(多人)
  170.      * @param deviceIds
  171.      * @param message
  172.      * @author senfel
  173.      * @date 2024/5/17 17:11
  174.      * @return void
  175.      */
  176.     public void sendMoreMessage(String[] deviceIds, String message) {
  177.         for(String deviceId:deviceIds) {
  178.             Session session = sessionPool.get(deviceId);
  179.             if (session != null&&session.isOpen()) {
  180.                 try {
  181.                     logger.info("【websocket消息】 单点消息:"+message);
  182.                     session.getAsyncRemote().sendText(message);
  183.                 } catch (Exception e) {
  184.                     e.printStackTrace();
  185.                 }
  186.             }
  187.         }
  188.     }
  189. }
复制代码
8、controller提供接口
  1. import org.springframework.web.bind.annotation.GetMapping;
  2. import org.springframework.web.bind.annotation.RequestMapping;
  3. import org.springframework.web.bind.annotation.RestController;
  4. import org.springframework.web.servlet.ModelAndView;
  5. /**
  6. * TestController
  7. * @author senfel
  8. * @version 1.0
  9. * @date 2024/5/17 17:49
  10. */
  11. @RestController
  12. @RequestMapping("/api/websocket")
  13. public class BaseController {
  14.     @GetMapping("page")
  15.     public ModelAndView page(Long userId){
  16.         ModelAndView websocket = new ModelAndView("websocket");
  17.         websocket.addObject("userId",userId);
  18.         return websocket;
  19.     }
  20. }
复制代码
Websoket集群负载均衡

大家都知道Websoket是一个长链接,在不断开的情况下服务端与客户端是可以自由通讯的,这是由于服务端缓存了会话。
如果我们后端采用集群部署,那么可能多个用户的缓存会话会分散在各个服务器上。在我们给指定用户推送消息时就有可能调用服务器上并没有这个用户的会话。
以是,我们引入Redis发布订阅,将消息举行转发到全部的服务端,只有有会话缓存的服务端才会乐成推送消息。讲到这里就比力明显了吧,完美解决Websoket负载均衡的问题。
1、maven引入Redis
  1. <dependency>
  2.     <groupId>org.springframework.boot</groupId>
  3.     <artifactId>spring-boot-starter-data-redis</artifactId>
  4. </dependency>
复制代码
2、设置文件
  1. spring:
  2.   redis:
  3.     host: 127.0.0.1
  4.     port: 6379
复制代码
3、Redis设置类
RedisConfig.java
  1. import com.fasterxml.jackson.annotation.JsonAutoDetect;
  2. import com.fasterxml.jackson.annotation.PropertyAccessor;
  3. import com.fasterxml.jackson.databind.ObjectMapper;
  4. import org.springframework.cache.annotation.CachingConfigurerSupport;
  5. import org.springframework.cache.annotation.EnableCaching;
  6. import org.springframework.context.annotation.Bean;
  7. import org.springframework.context.annotation.Configuration;
  8. import org.springframework.context.annotation.Primary;
  9. import org.springframework.data.redis.connection.RedisConnectionFactory;
  10. import org.springframework.data.redis.core.RedisTemplate;
  11. import org.springframework.data.redis.listener.PatternTopic;
  12. import org.springframework.data.redis.listener.RedisMessageListenerContainer;
  13. import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;
  14. import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
  15. import org.springframework.data.redis.serializer.StringRedisSerializer;
  16. /**
  17. * RedisConfig
  18. * @author senfel
  19. * @version 1.0
  20. * @date 2024/5/17 14:31
  21. */
  22. @Configuration
  23. @EnableCaching
  24. public class RedisConfig extends CachingConfigurerSupport {
  25.     @Bean
  26.     @Primary
  27.     public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
  28.         RedisTemplate<String, Object> template = new RedisTemplate<>();
  29.         template.setConnectionFactory(factory);
  30.         Jackson2JsonRedisSerializer jacksonSeial = new Jackson2JsonRedisSerializer(Object.class);
  31.         StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
  32.         ObjectMapper om = new ObjectMapper();
  33.         om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
  34.         om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
  35.         jacksonSeial.setObjectMapper(om);
  36.         template.setValueSerializer(jacksonSeial);
  37.         template.setKeySerializer(stringRedisSerializer);
  38.         template.setHashKeySerializer(stringRedisSerializer);
  39.         template.setHashValueSerializer(jacksonSeial);
  40.         template.afterPropertiesSet();
  41.         return template;
  42.     }
  43.     @Bean
  44.     RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory,
  45.                                             MessageListenerAdapter topicAdapter) {
  46.         RedisMessageListenerContainer container = new RedisMessageListenerContainer();
  47.         container.setConnectionFactory(connectionFactory);
  48.         //订阅了主题 webSocketMsgPush
  49.         container.addMessageListener(topicAdapter, new PatternTopic("webSocketMsgPush"));
  50.         return container;
  51.     }
  52.     /**
  53.      * 消息监听器适配器,绑定消息处理器
  54.      *
  55.      * @return
  56.      */
  57.     @Bean
  58.     MessageListenerAdapter topicAdapter() {
  59.         return new MessageListenerAdapter(new RedisListener());
  60.     }
  61. }
复制代码
4、Redis订阅监听
RedisListener.java
  1. import com.alibaba.fastjson.JSONObject;
  2. import com.example.ccedemo.config.SpringUtil;
  3. import org.springframework.data.redis.connection.Message;
  4. import org.springframework.data.redis.connection.MessageListener;
  5. /**
  6. * RedisListener
  7. * @author senfel
  8. * @version 1.0
  9. * @date 2024/5/17 14:37
  10. */
  11. public class RedisListener implements MessageListener {
  12.     @Override
  13.     public void onMessage(Message msg, byte[] bytes) {
  14.         System.out.println(".监听到需要进行负载转发的消息:" + msg.toString());
  15.         com.example.ccedemo.redissocket.Message message = JSONObject.parseObject(msg.toString(), com.example.ccedemo.redissocket.Message.class);
  16.         SpringUtil.getBean(WebSocketServer.class).sendOneMessage(message.getToUserId(), message.getContentText());
  17.     }
  18. }
复制代码
实战测试

我们本地启动两个服务,分别开启端口8888、9999,然后用nginx袒露7777端口做一个负载均衡。
IDEA启动两台服务端



设置nginx负载均衡

  1. #服务器url变量定义
  2. upstream api_service1 {
  3.     server 192.168.1.4:8888;
  4.         server 192.168.1.4:9999;
  5. }
  6. #nginx配置websocket
  7. map $http_upgrade $connection_upgrade {
  8.     default upgrade;
  9.     '' close;
  10. }
  11. server {
  12.     listen  7777;
  13.         large_client_header_buffers 4 16k;
  14.         client_max_body_size 300m;
  15.         client_body_buffer_size 128k;
  16.         proxy_connect_timeout 600;
  17.         proxy_read_timeout 600;
  18.         proxy_send_timeout 600;
  19.         proxy_buffer_size 64k;
  20.         proxy_buffers   4 32k;
  21.         proxy_busy_buffers_size 64k;
  22.         proxy_temp_file_write_size 64k;
  23.         proxy_http_version 1.1;
  24.                
  25.     root  /demo/page/dist;
  26.     index  index.html;
  27.    
  28.    
  29.     #api
  30.     location /api/ {
  31.         proxy_pass  http://api_service1/api/;
  32.         proxy_set_header Host $http_host;
  33.     }
  34.    
  35.   
  36.     #nginx配置websocket
  37.     location /ws/ {
  38.         proxy_http_version 1.1;
  39.         proxy_pass  http://api_service1/ws/;
  40.         proxy_redirect off;
  41.         proxy_set_header Host $host;  
  42.         proxy_set_header X-Real-IP $remote_addr;
  43.         proxy_read_timeout 3600s;
  44.         proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  45.         proxy_set_header Upgrade $http_upgrade;
  46.         proxy_set_header Connection $connection_upgrade;
  47.     }
  48.    
  49.     #解决页面刷新404
  50.     location / {
  51.         try_files $uri $uri/ @req;
  52.         index index.html;
  53.     }
  54.     location @req {
  55.         rewrite ^.*$ /index.html last;
  56.     }
  57. }
复制代码
浏览器访问模拟对话

1、浏览器开启多个无痕界面
http://192.168.1.4:7777/api/websocket/page
模拟对话:
多个界面的用户ID互补

2、分别开启soket,由于nginx轮询策略会分别注册在两个服务端上

3、客户端相互发送消息验证

由以上图片可知,我们两个客户端相互对话能够接收到对方推送的消息。那么,由此也可以证明我们后端Websocke集群使用Redis发布订阅的方式搭建乐成。

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

金歌

论坛元老
这个人很懒什么都没写!
快速回复 返回顶部 返回列表