[WebSocket]第二章:WebSocket集群分布式改造——实现多人在线聊天室

前言

书接上文,我们开始对我们的小小聊天室进行集群化改造。

上文地址:

[WebSocket入门]手把手搭建WebSocket多人在线聊天室(SpringBoot+WebSocket)

本文内容摘要:

  • 为何要改造为分布式集群
  • 如何改造为分布式集群
    • 用户在聊天室集群如何发消息
    • 用户在聊天室集群如何接收消息
  • 补充知识点:STOMP 简介
  • 功能一:向聊天室集群中的全体用户发消息——Redis的订阅/发布
  • 功能二:集群集群用户上下线通知——Redis订阅发布
  • 功能三:集群用户信息维护——Redis集合
  • WebSocket集群还有哪些可能性

本文源码:(妈妈再也不用担心我无法复现文章代码啦)

https://github.com/qqxx6661/springboot-websocket-demo/releases/tag/%E9%9B%86%E7%BE%A4%E7%89%88

如果您觉得这个教程对您有用,请关注我的技术公众号:Rude3Knife,不定时更新技术点滴。

正文

WebSocket集群/分布式改造:实现多人在线聊天室

为何要改造为分布式集群

分布式就是为了解决单点故障问题,想象一下,如果一个服务器承载了1000个大佬同时聊天,服务器突然挂了,1000个大佬瞬间全部掉线,大概明天你就被大佬们吊起来打了。

当聊天室改为集群后,就算服务器A挂了,服务器B上聊天的大佬们还可以愉快的聊天,并且在前端还能通过代码,让连接A的大佬们快速重连至存活的服务器B,继续和大家愉快的聊天,岂不美哉!

总结一下:实现了分布式WebSocket后,我们可以将流量负载均衡到不同的服务器上并提供一种通信机制让各个服务器能进行消息同步(不然用户A连上服务器A,用户B脸上服务器B,它们发消息的时候对方都没法收到)。

如何改造为分布式集群

当我们要实现分布式的时候,我们则需要在各个机器上共享这些信息,所以我们需要一个Publish/Subscribe的中间件。我们现在使用Redis作为我们的解决方案。

1. 用户在聊天室集群如何发消息

假设我们的聊天室集群有服务器A和B,用户Alice连接在A上,Bob连接在B上、

Alice向聊天室的服务器A发送消息,A服务器必须要将收到的消息转发到Redis,才能保证聊天室集群的所有服务器(也就是A和B)能够拿到消息。否则,只有Alice在的服务器A能够读到消息,用户Bob在的服务器B并不能收到消息,A和B也就无法聊天了。

2. 用户在聊天室集群如何接收消息

说完了发送消息,那么如何保证Alice发的消息,其他所有人都能收到呢,前面我们知道了Alice发送的消息已经被传到了Redis的频道,那么所有服务器都必须订阅这个Redis频道,然后把这个频道的消息转发到自己的用户那里,这样自己服务器所管辖的用户就能收到消息。

补充知识点:STOMP 简介

上期我们搭建了个websocket聊天室demo,并且使用了STOMP协议,但是我并没有介绍到底什么是STOMP协议,同学们会有疑惑,这里对于STOMP有很好地总结:

当直接使用WebSocket时(或SockJS)就很类似于使用TCP套接字来编写Web应用。因为没有高层级的线路协议(wire protocol),因此就需要我们定义应用之间所发送消息的语义,还需要确保连接的两端都能遵循这些语义。

就像HTTP在TCP套接字之上添加了请求-响应模型层一样,STOMP在WebSocket之上提供了一个基于帧的线路格式(frame-based wire format)层,用来定义消息的语义。

与HTTP请求和响应类似,STOMP帧由命令、一个或多个头信息以及负载所组成。例如,如下就是发送数据的一个STOMP帧:

>>> SEND
transaction:tx-0
destination:/app/marco
content-length:20

{"message":"Marco!"}

好了,介绍完了概念,让我们开始动手改造!

功能一:向聊天室集群中的全体用户发消息——Redis的订阅/发布

如果你不熟悉Redis的sub/pub(订阅/发布)功能,请看这里进行简单了解它的用法,很简单:

https://redisbook.readthedocs.io/en/latest/feature/pubsub.html

在我们上篇文章的Demo基础上,我们进行集群改造。上一篇文章的源码见下方:

https://github.com/qqxx6661/springboot-websocket-demo/releases/tag/%E5%8D%95%E6%9C%BA%E7%89%88

1. 添加Redis依赖pom

<!-- redis -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

2. application.properties新增redis配置

当然首先要确保你安装了Redis,windows下安装redis比较麻烦,你可以搜索redis-for-windows下载安装。

# redis 连接配置
spring.redis.database=0
spring.redis.host=127.0.0.1
spring.redis.password=
spring.redis.port=6379
spring.redis.ssl=false
# 空闲连接最大数
spring.redis.jedis.pool.max-idle=10
# 获取连接最大等待时间(s)
spring.redis.jedis.pool.max-wait=60000

3. 在application.properties添加频道名定义

# Redis定义
redis.channel.msgToAll = websocket.msgToAll

4. 新建redis/RedisListenerBean

package cn.monitor4all.springbootwebsocketdemo.redis;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
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;
import org.springframework.stereotype.Component;

import java.net.Inet4Address;
import java.net.InetAddress;

/**
 * Redis订阅频道属性类
 * @author yangzhendong01
 */
@Component
public class RedisListenerBean {

    private static final Logger LOGGER = LoggerFactory.getLogger(RedisListenerBean.class);

    @Value("${server.port}")
    private String serverPort;

    @Value("${redis.channel.msgToAll}")
    private String msgToAll;

    /**
     * redis消息监听器容器
     * 可以添加多个监听不同话题的redis监听器,只需要把消息监听器和相应的消息订阅处理器绑定,该消息监听器
     * 通过反射技术调用消息订阅处理器的相关方法进行一些业务处理
     * @param connectionFactory
     * @param listenerAdapter
     * @return
     */
    @Bean
    RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory, MessageListenerAdapter listenerAdapter) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);

        // 监听msgToAll
        container.addMessageListener(listenerAdapter, new PatternTopic(msgToAll));
        LOGGER.info("Subscribed Redis channel: " + msgToAll);
        return container;
    }
}

可以看到,我们在代码里监听了redis频道msgToAll,这个是在application.properties定义的,当然如果你懒得定义,这里可以写死。

5. 聊天室集群:发消息改造

我们单机聊天室的发送消息Controller是这样的:

@MessageMapping("/chat.sendMessage")
@SendTo("/topic/public")
    public ChatMessage sendMessage(@Payload ChatMessage chatMessage) {
        return chatMessage;

我们前端发给我们消息后,直接给/topic/public转发这个消息,让其他用户收到。

在集群中,我们需要把消息转发给Redis,并且不转发给前端,而是让服务端监听Redis消息,在进行消息发送。

将Controller改为:

@Value("${redis.channel.msgToAll}")
private String msgToAll;

@Autowired
private RedisTemplate<String, String> redisTemplate;
    
@MessageMapping("/chat.sendMessage")
    public void sendMessage(@Payload ChatMessage chatMessage) {
        try {
            redisTemplate.convertAndSend(msgToAll, JsonUtil.parseObjToJson(chatMessage));
        } catch (Exception e) {
            LOGGER.error(e.getMessage(), e);
        }
    }

你会发现我们在代码中使用了JsonUtil将实体类ChatMessage转为了Json发送给了Redis,这个Json工具类需要使用到FaskJson依赖:

  1. pom添加FastJson依赖
<!-- json -->
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.58</version>
</dependency>
  1. 添加Json解析工具类JsonUtil,提供对象转Json,Json转对象的能力
package cn.monitor4all.springbootwebsocketdemo.util;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * JSON 转换
 */
public final class JsonUtil {

    private static final Logger LOGGER = LoggerFactory.getLogger(JsonUtil.class);

    /**
     * 把Java对象转换成json字符串
     *
     * @param object 待转化为JSON字符串的Java对象
     * @return json 串 or null
     */
    public static String parseObjToJson(Object object) {
        String string = null;
        try {
            string = JSONObject.toJSONString(object);
        } catch (Exception e) {
            LOGGER.error(e.getMessage());
        }
        return string;
    }

    /**
     * 将Json字符串信息转换成对应的Java对象
     *
     * @param json json字符串对象
     * @param c    对应的类型
     */
    public static <T> T parseJsonToObj(String json, Class<T> c) {
        try {
            JSONObject jsonObject = JSON.parseObject(json);
            return JSON.toJavaObject(jsonObject, c);
        } catch (Exception e) {
            LOGGER.error(e.getMessage());
        }
        return null;
    }
}

这样,我们接收到用户发送消息的请求时,就将消息转发给了redis的频道websocket.msgToAll

6. 聊天室集群:接收消息改造

单机的聊天室,我们接收消息是通过Controller直接把消息转发到所有人的频道上,这样就能在所有人的聊天框显示。

在集群中,我们需要服务器把消息从Redis中拿出来,并且推送到自己管的用户那边,我们在Service层实现消息的推送。

  • 在处理消息之后发送消息:
    正如前面看到的那样,使用 @MessageMapping 或者 @SubscribeMapping 注解可以处理客户端发送过来的消息,并选择方法是否有返回值。
    如果 @MessageMapping注解的控制器方法有返回值的话,返回值会被发送到消息代理,只不过会添加上"/topic"前缀。可以使用@SendTo 重写消息目的地;
    如果 @SubscribeMapping注解的控制器方法有返回值的话,返回值会直接发送到客户端,不经过代理。如果加上@SendTo 注解的话,则要经过消息代理。
  • 在应用的任意地方发送消息:
    spring-websocket 定义了一个 SimpMessageSendingOperations 接口(或者使用SimpMessagingTemplate ),可以实现自由的向任意目的地发送消息,并且订阅此目的地的所有用户都能收到消息。

我们在service实现发送,需要使用上述第二种方法。

新建类service/ChatService:

package cn.monitor4all.springbootwebsocketdemo.service;

import cn.monitor4all.springbootwebsocketdemo.model.ChatMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.simp.SimpMessageSendingOperations;
import org.springframework.stereotype.Service;

@Service
public class ChatService {

    private static final Logger LOGGER = LoggerFactory.getLogger(ChatService.class);

    @Autowired
    private SimpMessageSendingOperations simpMessageSendingOperations;

    public void sendMsg(@Payload ChatMessage chatMessage) {
        LOGGER.info("Send msg by simpMessageSendingOperations:" + chatMessage.toString());
        simpMessageSendingOperations.convertAndSend("/topic/public", chatMessage);
    }

}

我们在哪里调用这个service呢,我们需要在监听到消息后调用,所以我们就要有下面的Redis监听消息处理专用类

新建类redis/RedisListenerHandle:

package cn.monitor4all.springbootwebsocketdemo.redis;

import cn.monitor4all.springbootwebsocketdemo.model.ChatMessage;
import cn.monitor4all.springbootwebsocketdemo.service.ChatService;
import cn.monitor4all.springbootwebsocketdemo.util.JsonUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;
import org.springframework.stereotype.Component;

/**
 * Redis订阅频道处理类
 * @author yangzhendong01
 */
@Component
public class RedisListenerHandle extends MessageListenerAdapter {

    private static final Logger LOGGER = LoggerFactory.getLogger(RedisListenerHandle.class);

    @Value("${redis.channel.msgToAll}")
    private String msgToAll;

    @Value("${server.port}")
    private String serverPort;

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Autowired
    private ChatService chatService;

    /**
     * 收到监听消息
     * @param message
     * @param bytes
     */
    @Override
    public void onMessage(Message message, byte[] bytes) {
        byte[] body = message.getBody();
        byte[] channel = message.getChannel();
        String rawMsg;
        String topic;
        try {
            rawMsg = redisTemplate.getStringSerializer().deserialize(body);
            topic = redisTemplate.getStringSerializer().deserialize(channel);
            LOGGER.info("Received raw message from topic:" + topic + ", raw message content:" + rawMsg);
        } catch (Exception e) {
            LOGGER.error(e.getMessage(), e);
            return;
        }


        if (msgToAll.equals(topic)) {
            LOGGER.info("Send message to all users:" + rawMsg);
            ChatMessage chatMessage = JsonUtil.parseJsonToObj(rawMsg, ChatMessage.class);
            // 发送消息给所有在线Cid
            chatService.sendMsg(chatMessage);
        } else {
            LOGGER.warn("No further operation with this topic!");
        }
    }
}

7. 看看效果

这样,我们的改造就基本完成了!我们看一下效果

我们将服务器运行在8080上,然后打开localhost:8080,起名Alice进入聊天室

随后,我们在application.properties中将端口server.port=8081

再次运行程序(别忘了开启IDEA的“允许启动多个并行服务”设置,不然会覆盖掉你的8080服务,如下图),在8081启动一个聊天室,起名Bob进入聊天室。

image

如下两图,我们已经可以在不同端口的两个聊天室,互相聊天了!(注意看url)

image
image

在互相发送消息是,我们还可以使用命令行监听下Redis的频道websocket.msgToAll,可以看到双方传送的消息。如下图:

image

我们还可以打开Chrome的F12控制台,查看前端的控制台发送消息的log,如下图:

image

大功告成了吗?

功能实现了,但是并不完美!你会发现,Bob的加入并没有提醒Bob进入了聊天室(在单机版是有的),这是因为我们在“加入聊天室”的代码还没有修改,在加入时,只有Bob的服务器B里的其他用户知道Bob加入了聊天室。我们还能再进一步!

功能二/功能三:集群用户上下线通知,集群用户信息存储

我们需要弥补上面的不足,将用户上线下线的广播发送到所有服务器上。

此外,我还希望以后能够查询集群中所有的在线用户,我们在redis中添加一个set,来保存用户名,这样就可以随时得到在线用户的数量和名称。

1. 在application.properties添加频道名定义

# Redis定义
redis.channel.userStatus = websocket.userStatus
redis.set.onlineUsers = websocket.onlineUsers

我们增加两个定义

  • 第一个是新增redis频道websocket.userStatus用来广播用户上下线消息

  • 第二个是redis的set,用来保存在线用户信息

2. 在RedisListenerBean添加新频道监听

container.addMessageListener(listenerAdapter, new PatternTopic(userStatus));

3. 在ChatService中添加

public void alertUserStatus(@Payload ChatMessage chatMessage) {
        LOGGER.info("Alert user online by simpMessageSendingOperations:" + chatMessage.toString());
        simpMessageSendingOperations.convertAndSend("/topic/public", chatMessage);
    }

在service中我们向本服务器的用户广播消息,用户上线或者下线的消息都通过这里传达。

4. 修改ChatController中的addUser方法

@MessageMapping("/chat.addUser")
    public void addUser(@Payload ChatMessage chatMessage, SimpMessageHeaderAccessor headerAccessor) {

        LOGGER.info("User added in Chatroom:" + chatMessage.getSender());
        try {
            headerAccessor.getSessionAttributes().put("username", chatMessage.getSender());
            redisTemplate.opsForSet().add(onlineUsers, chatMessage.getSender());
            redisTemplate.convertAndSend(userStatus, JsonUtil.parseObjToJson(chatMessage));
        } catch (Exception e) {
            LOGGER.error(e.getMessage(), e);
        }
    }

我们修改了addUser方法,在这里往redis中广播用户上线的消息,并把用户名username写入redis的set中(websocket.onlineUsers)

5. 修改WebSocketEventListener中的handleWebSocketDisconnectListener方法

@EventListener
    public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {

        StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());

        String username = (String) headerAccessor.getSessionAttributes().get("username");

        if(username != null) {
            LOGGER.info("User Disconnected : " + username);
            ChatMessage chatMessage = new ChatMessage();
            chatMessage.setType(ChatMessage.MessageType.LEAVE);
            chatMessage.setSender(username);
            try {
                redisTemplate.opsForSet().remove(onlineUsers, username);
                redisTemplate.convertAndSend(userStatus, JsonUtil.parseObjToJson(chatMessage));
            } catch (Exception e) {
                LOGGER.error(e.getMessage(), e);
            }

        }
    }

在用户关闭网页时,websocket会调用该方法,我们在这里需要把用户从redis的在线用户set里删除,并且向集群发送广播,说明该用户退出聊天室。

6. 修改Redis监听类RedisListenerHandle

 else if (userStatus.equals(topic)) {
            ChatMessage chatMessage = JsonUtil.parseJsonToObj(rawMsg, ChatMessage.class);
            if (chatMessage != null) {
                chatService.alertUserStatus(chatMessage);
            }

在监听类中我们接受了来自userStatus频道的消息,并调用service

7. 看看效果

image
image

此外,我们还可以在Reids中查询到用户信息:

image

WebSocket集群还有哪些可能性

有了这两篇文章的基础, 我们当然还能实现以下的功能:

  • 某用户A单独私信给某用户B,或者私信给某用户群(用户B和C)
  • 系统提供外部调用接口,给指定用户/用户群发送消息,实现消息推送
  • 系统提供外部接口,实时获取用户数据(人数/用户信息)

感兴趣的同学可以自己试试看。

参考文献

深入浅出Websocket(二)分布式Websocket集群

https://juejin.im/post/5ab84609f265da237d02fcb6

Spring消息之STOMP:

https://www.cnblogs.com/jmcui/p/8999998.html

总结

我们在本文中把单机版的聊天室改为了分布式聊天室,大大提高了聊天室可用性。

本文工程源代码:

单机版:

https://github.com/qqxx6661/springboot-websocket-demo/releases/tag/%E5%8D%95%E6%9C%BA%E7%89%88

集群版:

https://github.com/qqxx6661/springboot-websocket-demo/releases/tag/%E9%9B%86%E7%BE%A4%E7%89%88

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,117评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,328评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,839评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,007评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,384评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,629评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,880评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,593评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,313评论 1 243
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,575评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,066评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,392评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,052评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,082评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,844评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,662评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,575评论 2 270

推荐阅读更多精彩内容