一、应用场景

​早期系统需要支持一个功能,当有申请单提交时,通过页面弹窗提醒审批人。当时的实现方式是,由前端定时轮询调用后台接口,获取待审批的申请单数量。实现简单粗暴,调用量不大时还好,一旦使用人增多,会对后台造成一定的请求压力,并且大部分请求都是无意义的。

近些天又来一个类似的需求,就在想有没有一种其它方式实现,由后台主动通知前端显示弹窗。经过调研,目前有三种解决方式,而 WebSocket 是最佳的解决方案。

二、实现方案

1、前端轮询调用

​ Ajax 轮询也就是定时发送请求,并且无限循环发送,通过这样的方式保证服务端一旦有最新消息,就可以被客户端获取。上述的场景也是使用的这个方案,缺点很明显,定时的时间粒度不好控制,过于频繁会造成后端压力,时间间隔太长又保证不了实时性。

2、Long Polling 长轮询

​ 长轮询是客户端发起一个请求到服务端,如果没有响应则一直等待,当服务端有消息时才返回客户端,连接断开。然后循环往复,重新发起连接。这种方式也避免不了被动,开销也大。

3、WebSocket 实现

WebSocket协议是基于TCP的一种新的网络协议。它实现了浏览器与服务器全双工(full-duplex)通信——允许服务器主动发送信息给客户端。 WebSocket通信协议于2011年被IETF定为标准RFC 6455,并被RFC7936所补充规范。
WebSocket 是 HTML5 开始提供的一种在单个 TCP 连接上进行全双工通讯的协议。
WebSocket 使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。
在 WebSocket API 中,浏览器和服务器只需要做一个握手的动作,然后,浏览器和服务器之间就形成了一条快速通道。两者之间就直接可以数据互相传送。

总结一下就是,WebSocket 可以实现服务端向客户端主动发送消息。下图是 Ajax 轮询 和 WebSocket 的交互对比。

ws.png

三、使用方式

1、客户端代码如下

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title></title>
</head>
<body>
websocket Demo---- 123 <br/>

<input id="text" type="text"/>
<button onclick="send()"> Send</button>
<button onclick="closeWebSocket()"> Close</button>
<div id="message"></div>
<script type="text/javascript">
    //判断当前浏览器是否支持WebSocket
    if ('WebSocket' in window) {
        websocket = new WebSocket("ws://localhost:8080/websocketTest/123");
        console.log("link success")
    } else {
        alert('Not support websocket')
    }

    //连接发生错误的回调方法
    websocket.onerror = function () {
        setMessageInnerHTML("error");
    };

    //连接成功建立的回调方法
    websocket.onopen = function (event) {
        setMessageInnerHTML("open");
    }
    console.log("-----")
    //接收到消息的回调方法
    websocket.onmessage = function (event) {
        setMessageInnerHTML(event.data);
    }

    //连接关闭的回调方法
    websocket.onclose = function () {
        setMessageInnerHTML("close");
    }

    //监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
    window.onbeforeunload = function () {
        websocket.close();
    }

    //将消息显示在网页上
    function setMessageInnerHTML(innerHTML) {
        document.getElementById('message').innerHTML += innerHTML + '<br/>';
    }

    //关闭连接
    function closeWebSocket() {
        websocket.close();
    }

    //发送消息
    function send() {
        var message = document.getElementById('text').value;
        websocket.send(message);
    }
</script>
</body>
</html>

2、服务端主要代码

package com.lyqiang.websocket.server;

import lombok.extern.slf4j.Slf4j;
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.HashSet;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

/**
 * @author lyqiang
 */

@Component
@Slf4j
@ServerEndpoint(value = "/websocketTest/{userId}")
public class TestServer {

    /**
     * 用 list 维护,处理用户打开多个标签页
     */
    private static ConcurrentHashMap<String, Set<TestServer>> webSocketSession = new ConcurrentHashMap<>();

    private Session session;

    private String userId;

    /**
     * 连接
     */
    @OnOpen
    public void onOpen(@PathParam("userId") String userId, Session session) throws IOException {
        this.session = session;
        if (webSocketSession.containsKey(userId)) {
            webSocketSession.get(userId).add(this);
        } else {
            Set<TestServer> set = new HashSet<>();
            set.add(this);
            webSocketSession.put(userId, set);
        }
        this.userId = userId;
        log.info("新连接:{}, this:{}", userId, this);
    }

    /**
     * 关闭时执行
     */
    @OnClose
    public void onClose() {
        webSocketSession.get(userId).remove(this);
        log.info("连接:{} 关闭", this.userId);
    }

    /**
     * 收到消息时执行
     */
    @OnMessage
    public void onMessage(String message, Session session) throws IOException {
        log.info("收到用户 {} 的消息 {} , this: {}", this.userId, message, this);
        session.getBasicRemote().sendText("你好,服务端已经收到 " + this.userId + " 的消息: " + message);
    }

    /**
     * 连接错误时执行
     */
    @OnError
    public void onError(Session session, Throwable error) {
        log.error("用户id为:{}的连接发送错误", this.userId, error);
    }

    /**
     * 发送自定义消息
     */
    public static void sendInfo(String message, String userId) {
        Set<TestServer> serverSet = webSocketSession.get(userId);
        if (serverSet != null && !serverSet.isEmpty()) {
            serverSet.forEach(s -> s.sendMessage(message));
        }
    }

    /**
     * 实现服务器主动推送
     */
    public void sendMessage(String message) {
        try {
            this.session.getBasicRemote().sendText(message);
        } catch (IOException e) {
            log.error("sendMsg error", e);
        }
    }
}

3、完整代码

https://github.com/lyqiangmny/websocket-demo

四、集群模式解决方案

​ 上面的代码是前后端交互的核心思路,针对单机模式,WebSocket 的 session 信息是可以存储在 map 中的,但是对于集群模式,连接 session 放在内存中是有问题的,服务端发送消息时目标用户的 session 可能在其它服务器上。而 session 又不能被序列化到 Redis 中实现 session 共享,只能通过其它方式来解决。

​ 其中一种方式可以使用 Redis 的 发布/订阅 的消息通知来实现,发送消息时使用 redis publish 消息,订阅这个 channel 的多台服务器都能收到消息,然后从内存中检测到目标用户,再与客户端进行通信即可。

redis.png

​ 当然也可以借助其他消息队列实现,比如 RabbitMQ 等,核心思路都是通知到其它服务器检测目标用户是否在当前机器上。

五、总结

​ 目前采用 WebSocket + Redis 发布订阅 实现的前端消息弹窗及菜单气泡数字提醒已经顺利上线。多了解一种解决方案,思路更宽泛,就有更好实现的可能。