分布式websocket即时通信(IM)系统实现websocket集群【第七期】

76 阅读6分钟

前言

这个项目的聊天模块是由netty构建的websocket服务构建的,不满足分布式的情况。当项目需要更高并发的时候可拓展性不足。需要往分布式方向改进,这边文章主要写改写成分布式遇到了哪些困难以及目前的架构,后续会分享已经实现的功能和计划实现的功能。点个关注,后续持续分享。

目前已经写的文章有如下,并且有b站视频讲解版本。 www.bilibili.com/video/BV1d9… 找不到视频可以直接搜索我 目前叫 呆呆呆呆梦

git项目地址 【分布式工具箱】点击可跳转

sprinboot单体项目升级成springcloud项目 【第一期】

前端项目技术选型以及页面展示【第二期】

分布式权限 shiro + jwt + redis【第三期】

给为服务添加运维模块 统一管理【第四期】

微服务数据库模块【第五期】

netty与mq在项目中的使用(第六期)】

netty 模块实现功能重点说明

通过集群来支持高并发,通过六层报文来实现高可靠。通过幂等来解决消息重复

  1. 支持单聊和群聊 ,支持发送表情包
  2. 聊天内容ack机制,然后未送达重试机制(重试三次,三次之后为失败)
  3. 设计websocket报文,保证消息的可靠性
  4. 支持分布式,支持gateway负载均衡,将netty服务端注册进入nacos
  5. 支持websocket授权功能(已经做了相关的截取参数以及改造前端vue)
  6. 接口引用redis设计成幂等 (todo)需要做一下lua表达式
  7. 会话管理(redis结合本地Map) 待完善
  8. 支持心跳功能(待完善)
  9. 设置消息加密(待完善)

一.系统netty相关模块架构

请添加图片描述

1.1将netty注册进入Nacos

注册中心是Nacos,使用到负载均衡需要将自己写的netty服务开放的端口也注册进入Naocs。个误区是springboot已经是一个服务注册进去,网关负载均衡springboot的http服务,但是 websocket服务还没有注册进去。需要自己写一下注册Naocs的逻辑。

主要逻辑在类WebSocketServe里

 /**
     * 注册到 nacos 服务中
     *
     * @param nettyName netty服务名称
     * @param nettyPort netty服务端口
     */
    private void registerNamingService(String nettyName, String nettyPort) {
        try {
//            192.168.56.20:8848 nacosDiscoveryProperties.getServerAddr()
            NamingService namingService = NamingFactory.createNamingService(nacosDiscoveryProperties.getServerAddr());
            InetAddress address = InetAddress.getLocalHost();
            namingService.registerInstance(nettyName, address.getHostAddress(), Integer.parseInt(nettyPort));
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

注册的时机是完全启动好端口,并且检查是存在与redis中

redisTemplate.opsForHash().put(RedisPrefix.WEBSOCKETSERVER,instanceid,nettyPort);
            log.info("设置实例[{}]的netty端口为[{}].",instanceid,nettyPort);
            //注册到Nacos里
            registerNamingService(serverName, nettyPort);

在这里插入图片描述 如图所示,有两个服务在这里插入图片描述

1.2gateway负载均衡

这个地方负载均衡了实例名称,默认按照轮询的方式来去选择服务器。后续可以优化为根据权重实现。这种实现思路可以将用户路由到不同的websocket服务器上面。

        ###负载均衡一下netty的端口nettyChatServe.
        - id: IM_NETTY
          uri: lb:ws://nettyChatServe
          predicates:
            - Path=/websocket**   

1.3 集群下会话的问题

http服务是无状态的,可以轻松路由到其他服务其上面不影响调用结果。但是websocket是长连接。一个用户在一个服务器开启连接后,后续的推送消息和发送消息都在这个服务器上面,就提出了更高的要求。用户属于哪个服务器,就需要给推送到具体的服务器上面来完成后续操作;

目前的解决思路是使用redis记录用户登录的服务器,然后当websocket服务器收到消息的时候判断这个用户是不是在当前服务器上面的会话,如果是本地的会话,则直接进行推送,不是本地的,则直接推送给对应的websocket服务器。 转发推送的这个过程由rocketmq来完成。改进一下模型就是当收到消息要发给另一个人的时候,则统一交给mq,再次之前进行逻辑判断在哪个服务器上面,然后进行推送和消费达到目的。 这个地方是mq分发的逻辑;

public Set execute(SendRequest request,Byte type) {
        checkServer();
        //查询redis中所有的websocket服务
        Set<String> set = redisTemplate.keys(RedisPrefix.PREFIX_SERVERCLIENTS + "*");
        checkServerClients(set);
        //记录查询不存在的客户端
        Set<String> notExist = new HashSet<>();
        //<服务端地址,对应的客户端结果集>
        Map<String,List<String>> hostClientsMap = new HashMap<>(set.size());
        //发送给所有在线的人.
        if (request.getSendToAll()) {
            //根据服务下的设备标识推送
            //2.消息分发给所有的websocket实例处理
            //serverKey => serverclients_10.9.217.160:9003
            for(String serverKey : set){
                //因为更改过serverclient格式,所以需要重新的改一下取的公式
                log.info(serverKey.split("\\_")[2]);
                messageDispatchService.send(serverKey.split("\\_")[2],request);
            }
        }else{
            //根据参数中的客户端标识,找出所在的服务器,先对应的服务器发起推送
            List<String> requestClients = request.getTo();
            //批量查询
//            List<Object> pipeResult = redisTemplate.executePipelined(RedisUtils.getClientHostByClientFromRedis(requestClients));
            for (int i=0;i<requestClients.size();i++) {
                //遍历list 依次存入推送消息
                //根据channelId找到对应的客户端对象所对应websocket服务的实例名
                String channelId = requestClients.get(i);
                //废弃单个查询的方式
                String host = redisTemplate.opsForHash().get(RedisPrefix.PREFIX_CLIENT + channelId,"host")+"";
//                Object hostObj = pipeResult.get(i);
//                if (hostObj==null) {
//                    notExist.add(channelId);
//                    continue;
//                }
//                String host = hostObj.toString();
                if(hostClientsMap.containsKey(host)){
                    hostClientsMap.get(host).add(channelId);
                }else{
                    List<String> clients = new LinkedList<>();
                    clients.add(channelId);
                    hostClientsMap.put(host,clients);
                }
            }
            //host 的是 从yan_client里面取host 然后发送给这个主题 ,mq消费消息的时候定好就没有问题;

            log.info("netty 分发mq消息 不存在的客户端是[{}]", notExist);
            for(Map.Entry<String,List<String>> entry: hostClientsMap.entrySet()){
                request.setTo(entry.getValue());
                //entry.getkey()是mq将要推送过去的主题.
                messageDispatchService.send(entry.getKey(),request);
            }
        }

        return notExist;
    }

    private void checkServerClients(Set<String> set) {
        if (CollectionUtils.isEmpty(set)) {
            throw new ServiceException("没有存在连接的websocket服务");
        }
    }

    private void checkServer() {
        if(redisTemplate.opsForHash().size(RedisPrefix.WEBSOCKETSERVER)<=0){
            throw new ServiceException("没有可用的websocket服务端");
        }
    }

这里是MQ消费的逻辑;

 /**
     * 消费rocketmq的消息,通过channel管道来完成消息的推送
     * 使用返回值 ConsumeStatus的情况下可以确定是不是    ConsumeStatus.CONSUME_SUCCESS;
     * ConsumeStatus作为consumeMsg方法的返回值类型可以提供更好的信息反馈和异常处理,同时可读性也更好。
     * @param content
     * @param msg
     * @return
     */
    @Override
    public boolean consumeMsg(RocketMqContent content, MessageExt msg) {
        try {
            String MqMessage = new String(msg.getBody());
            log.info("RocketMqConsumerService=====消费消息:"+MqMessage);
            //消息内容
            SendRequest request = JSON.parseObject(MqMessage,SendRequest.class);
            if(request.getSendToAll()){
                //遍历该服务上的所有客户端进行推送

                for(String channelId : SessionUtils.getAllOnlineChannel().keySet()){
                    messageSendService.sendMessage(channelId,getMessage(channelId,request,msg));
                }
                return true;
            }
            //根据请求标识进行推送
            for(String channelId : request.getTo()){
                messageSendService.sendMessage(channelId,getMessage(channelId,request,msg));
            }
            return true;
        }catch (Exception e){
            log.error("推送失败.",e);
        }
        return false;
    }

    //构造推送消息体
    private WebsocketMessage getMessage(String channelId, SendRequest request, MessageExt msg) {
        Channel channel =SessionUtils.getChannel(channelId);
        WebsocketMessage websocketMsg = new WebsocketMessage(
                request.getRequestId(),
                channel.attr(AttrConstants.sessionId).get(),
                request.getUniqueMsgid(),
                WebsocketMessage.MsgType.BUSSINESS.code,
                new String[]{channelId},
                request.getMsg(),
                request.getFrom(),
                Integer.parseInt(msg.getUserProperty(NettyConstants.Trigger))
                );
        return websocketMsg;
    }


1.4 rocketMQ 主题以及消费群组

mq监控网址 http://192.168.56.20:9999/#/ rocketMQ group+ip+端口作为消费群组; websocket+ip+端口作为 主题. 主题 在这里插入图片描述

消费端; 在这里插入图片描述

1.5Redis管理

在这里插入图片描述 上图是redis 记录用户信息。

在这里插入图片描述 上图是一台服务上面有几个用户。 在这里插入图片描述 这个是记录了有几台websocket服务。 redis的文档也可以看具体项目文档。