【IM】网页聊天室(wss)

262 阅读5分钟

「这是我参与2022首次更文挑战的第18天,活动详情查看:2022首次更文挑战

一、前言

简易网页聊天室有如下需求:

  1. 支持简单的文本发送
  2. 消息实时接收
  3. 支持消息未读数(总未读和会话未读)

先来回顾下,保证消息实时性的三种常见方式:

  1. 短轮询:客户端定时去请求服务端拉取消息
  2. 长轮询:当请求没有获取到新消息时,并不会马上结束返回,而是会在服务端 “悬挂(hang)”
  3. 长连接:当有新消息产生,服务端直接向客户端推送

前文 已经对网页聊天室有大概了解,现在采用长连接方式(WebSocket),同时会加上相对高级的功能:

  • 应用层心跳
  • ACK 机制
  • 双端 idle 超时断连
  • 客户端断线后的自动重连

2022-02-0514-44-56.png



二、实战

技术实现:项目地址

  • 数据表
  • 接口
  • 业务逻辑实现

(1)websocket 使用

前端代码如下:

  • onopen 事件:进行用户信息上绑定
  • onmessage 事件:对接收到所有该连接上的数据进行处理
  • onerror 事件:错误事件
  • onclose事件:连接关闭
// 1. 初始化:建立 websocket 连接
function init() {
    if (window.WebSocket) {
        websocket = new WebSocket("ws://127.0.0.1:8080");
        websocket.onmessage = function (event) {
            // 处理所有 web 端接收到的数据
            onmsg(event);
        };

        websocket.onopen = function () {
            bind();
            heartBeat.start();
        }

        websocket.onclose = function () {
            // 重连操作
            reconnect();
        };

        websocket.onerror = function () {
            // 重连操作
            reconnect();
        };

    } else {
        alert("您的浏览器不支持WebSocket协议!");
    }
}

// 上线
function bind() { 
    if (window.WebSocket) {
        if (websocket.readyState == WebSocket.OPEN) {
            var bindJson = '{ "type": 1, "data": {"uid":' + $("#sender_id").val() + ' }}';
            websocket.send(bindJson);
        }
    } else {
        return;
    }
}

举个栗子,发送消息格式:

var sendMsgJson = '{ "type": 3, "data": {"senderUid":' + sender_id + ',"recipientUid":' + recipient_id + ', "content":"' + msg_content + '","msgType":1  }}';

websocket.send(sendMsgJson);

后端采用 Netty 实现 WebSocket Server,代码如下:


EventLoopGroup bossGroup =
                    new EpollEventLoopGroup(serverConfig.bossThreads, new DefaultThreadFactory("WebSocketBossGroup", true));
                    
EventLoopGroup workerGroup =
                    new EpollEventLoopGroup(serverConfig.workerThreads, new DefaultThreadFactory("WebSocketWorkerGroup", true));

ServerBootstrap serverBootstrap = new ServerBootstrap().group(bossGroup, workerGroup).channel(EpollServerSocketChannel.class);

ChannelInitializer<SocketChannel> initializer = new ChannelInitializer<SocketChannel>() {
    @Override
    protected void initChannel(SocketChannel ch) throws Exception {
        ChannelPipeline pipeline = ch.pipeline();
        // 先添加WebSocket相关的编解码器和协议处理器
        pipeline.addLast(new HttpServerCodec());
        pipeline.addLast(new HttpObjectAggregator(65536));
        pipeline.addLast(new LoggingHandler(LogLevel.DEBUG));
        pipeline.addLast(new WebSocketServerProtocolHandler("/", null, true));
        // 再添加服务端业务消息的总处理器
        pipeline.addLast(websocketRouterHandler);
        // 服务端添加一个idle处理器,如果一段时间Socket中没有消息传输,服务端会强制断开
        pipeline.addLast(new IdleStateHandler(0, 0, serverConfig.getAllIdleSecond()));
        pipeline.addLast(closeIdleChannelHandler);
    }
}

serverBootstrap.childHandler(initializer);
serverBootstrap.bind(serverConfig.port).sync(

(2)核心消息收发逻辑处理

前端发送消息,通过 websocket.send(),对应代码如下:

function sendMsg(event) {
    event.preventDefault();
 
    var sendMsgJson = '{ "type": 3, "data": {"senderUid":' + sender_id + ',"recipientUid":' + recipient_id + ', "content":"' + msg_content + '","msgType":1  }}';
 
 websocket.send(sendMsgJson);
    return false;
}

对接服务端接收消息:

  1. WebSocketFrame 格式的数据中,解析出消息(收发者 Id、内容)
  2. 通过 messageService.sendNewMsg:处理消息存储、未读数等。
  3. 通过 writeAndFlush 方法把消息发送方的客户端
@ChannelHandler.Sharable
@Component
public class WebsocketRouterHandler extends SimpleChannelInboundHandler<WebSocketFrame> {
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, WebSocketFrame frame) 
        throws Exception {
        if (frame instanceof TextWebSocketFrame) {
            String msg = ((TextWebSocketFrame) frame).text();
            JSONObject msgJson = JSONObject.parseObject(msg);
            int type = msgJson.getIntValue("type");
            JSONObject data = msgJson.getJSONObject("data");
            switch (type) {
                case 0:// 心跳
                    ctx.writeAndFlush(new TextWebSocketFrame("{\"type\":0,\"timeout\":" + timeout + "}"));
                    break;
                case 1:// 上线消息
                    ctx.writeAndFlush(new TextWebSocketFrame("{\"type\":1,\"status\":\"success\"}"));
                    break;
                case 2: // 查询消息
                    ctx.writeAndFlush(new TextWebSocketFrame(msgs));
                    break; 
                case 3: // 发消息
                    MessageVO messageContent = messageService.sendNewMsg(senderUid, recipientUid, content, msgType);
                    break;
                case 5: // 查总未读
                    long unreadOwnerUid = data.getLong("uid");
                    long totalUnread = messageService.queryTotalUnread(unreadOwnerUid);
                    ctx.writeAndFlush(new TextWebSocketFrame("{\"type\":5,\"data\":{\"unread\":" + totalUnread + "}}"));
                    break;

                case 6: // 处理ack
                    long tid = data.getLong("tid");
                    ConcurrentHashMap<Long, JSONObject> nonAckedMap = ctx.channel().attr(NON_ACKED_MAP).get();
                    nonAckedMap.remove(tid);
                    break;
            }
        }
    }
}

// 推送消息
public void pushMsg(long recipientUid, JSONObject message) {
    Channel channel = userChannel.get(recipientUid);
    if (channel != null && channel.isActive() && channel.isWritable()) {
        AtomicLong generator = channel.attr(TID_GENERATOR).get();
        long tid = generator.incrementAndGet();
        message.put("tid", tid);
        channel.writeAndFlush(new TextWebSocketFrame(message.toJSONString()))
            .addListener(future -> {
            if (future.isCancelled()) {
                logger.warn("future has been cancelled. {}, channel: {}", 
                            message, channel);
            } else if (future.isSuccess()) {
                // 将消息放到等待 ACK 的队列里
                // 服务端会同时创建一个定时器,在一定的时间后,会触发“检查当前消息是否被 ACK”的逻辑。
                addMsgToAckBuffer(channel, message);
                logger.warn("future has been successfully pushed. {}, channel: {}", 
                            message, channel);
            } else {
                logger.error("message write fail, {}, channel: {}", message, channel, 
                             future.cause());
            }
        });
    }
}

这里使用 Redis 的发布 / 订阅,实现一个消息推送的发布订阅器:

  1. 业务层:处理发送消息的逻辑
  2. 业务层:将消息发布到 Redis 里的一个 Topic
  3. 网关层NewMessageListener 监听着 Topic,并会将消息传递给 WebSocketRouterHandler,最终将消息下推给客户端
@Component
public class NewMessageListener implements MessageListener {

    private static final Logger logger = LoggerFactory.getLogger(UserServiceImpl.class);

    @Autowired
    private WebsocketRouterHandler websocketRouterHandler;

    StringRedisSerializer stringRedisSerializer = new StringRedisSerializer();
    private static final RedisSerializer<String> valueSerializer = new GenericToStringSerializer(Object.class);


    @Override
    public void onMessage(Message message, byte[] pattern) {
        String topic = stringRedisSerializer.deserialize(message.getChannel());
        String jsonMsg = valueSerializer.deserialize(message.getBody());
        logger.info("Message Received --> pattern: {},topic:{},message: {}", new String(pattern), topic, jsonMsg);
        JSONObject msgJson = JSONObject.parseObject(jsonMsg);
        long otherUid = msgJson.getLong("otherUid");
        JSONObject pushJson = new JSONObject();
        pushJson.put("type", 4);
        pushJson.put("data", msgJson);

        websocketRouterHandler.pushMsg(otherUid, pushJson);
    }
}

(3)消息推送的 ACK

消息推送 ACK 如何保证消息的可靠投递:

  • 当系统有消息下推后,服务端会依赖客户端响应的 ACK 包,来保证消息推送的可靠性。
  • 如果消息下推后一段时间,服务端没有收到客户端的 ACK 包,那么服务端会认为这条消息没有正常投递下去,就会触发重新下推。
  • 如果一直重试当然不行:得关闭清除客户端连接和待 ACK 列表

**具体实现如下:**服务端会将当前消息加入到一个 “待 ACK Buffer

  • 服务端会同时创建一个定时器,在一定的时间后,会触发 “检查当前消息是否被 ACK”的逻辑
    • 如果客户端有回 ACK,那么服务端就会从这个 “待 ACK Buffer” 中移除这条消息
    • 否则如果这条消息没有被 ACK,那么就会触发消息的重新下推。
public void addMsgToAckBuffer(Channel channel, JSONObject msgJson) {
    channel.attr(NON_ACKED_MAP).get().put(msgJson.getLong("tid"), msgJson);
    // 定时器调度:
    executorService.schedule(() -> {
        if (channel.isActive()) {
            checkAndResend(channel, msgJson);
        }
    }, 5000, TimeUnit.MILLISECONDS);
}

private void checkAndResend(Channel channel, JSONObject msgJson) {
    long tid = msgJson.getLong("tid");
    int tryTimes = 2;// 重推2次
    while (tryTimes > 0) {
        if (channel.attr(NON_ACKED_MAP).get().containsKey(tid) && tryTimes > 0) {
            channel.writeAndFlush(new TextWebSocketFrame(msgJson.toJSONString()));
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        tryTimes--;
    }
}

(4)应用层心跳

应用层心跳的作用:主要是为了解决由于网络的不确定性,而导致的连接不可用的问题。

前端逻辑实现如下:通过定时器每 2 分钟通过长连接给服务端发送一次心跳包

  • 如果在 2 分钟内接收到服务端的消息或者响应,那么客户端的下次 2 分钟定时器的计时,会进行清零重置,重新计算
  • 如果发送的心跳包在 2 分钟后没有收到服务端的响应,客户端会断开当前连接,然后尝试重连
// 每2分钟发送一次心跳包,接收到消息或者服务端的响应又会重置来重新计时
var heartBeat = {
    timeout: 120000,
    timeoutObj: null,
    serverTimeoutObj: null,
    reset: function () {
        clearTimeout(this.timeoutObj);
        clearTimeout(this.serverTimeoutObj);
        this.start();
    },
    start: function () {
        // ... ...
            }, self.timeout)
        }, this.timeout)
    },
}

服务端接收到心跳包的处理逻辑:

@ChannelHandler.Sharable
@Component
public class WebsocketRouterHandler extends SimpleChannelInboundHandler<WebSocketFrame> {
    @Override
    protected void channelRead0(ChannelHandlerContext ctx, WebSocketFrame frame) 
        throws Exception {
        if (frame instanceof TextWebSocketFrame) {
            String msg = ((TextWebSocketFrame) frame).text();
            JSONObject msgJson = JSONObject.parseObject(msg);
            int type = msgJson.getIntValue("type");
            JSONObject data = msgJson.getJSONObject("data");
            switch (type) {
                case 0://心跳
                    long uid = data.getLong("uid");
                    long timeout = data.getLong("timeout");
                    ctx.writeAndFlush(new TextWebSocketFrame("{\"type\":0,\"timeout\":"
                                                             + timeout + "}"));
                    break;
            }
        }
    }
}