基于Redis发布订阅的分布式WebSocket通信

1024写篇文章庆祝一下

本文介绍

最近公司需求需要使用到服务端的主动推送功能,在学习和研究下整理出这篇文章用于学习和记录,本文只讲技术的基本应用,没有原理介绍。

采用技术:springbootwebsocketredis发布订阅

需求

实现服务端多节点的情况下,主动推送消息到客户端

WebSokcet

WebSocket介绍:

WeSocket是一种协议,与Http是一个等级的,并且也是基于TCP协议,可以理解为WebSoketHttp的优化

​ 常规的场景下,一般都是由客户端使用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上创建连接,这样10011003是不能进行通信
在这里插入图片描述

由上图发现并没有实现互相通信

此时就需要实现两个服务端之间的通信,采用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

参考文章

万字长文,一篇吃透WebSocket:概念、原理、易错常识、动手实践

把Redis当作队列来用,真的合适吗?

Nginx代理webSocket时60s自动断开, 怎么保持长连接

  • 8
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值