聊聊分散式下的WebSocket解決方案

Java全能架構師發表於2020-09-24

 

前言

最近王子自己搭建了個專案,專案本身很簡單,但是裡面有使用WebSocket進行訊息提醒的功能,大體情況是這樣的。

釋出訊息者在系統中傳送訊息,實時的把訊息推送給對應的一個部門下的所有人。

這裡面如果是單機應用的情況時,我們可以通過部門的id和使用者的id組成一個唯一的key,與應用伺服器建立WebSocket長連線,然後就可以接收到釋出訊息者傳送的訊息了。

但是真正把專案應用於生產環境中時,我們是不可能就部署一個單機應用的,而是要部署一個叢集。

所以王子通過Nginx+兩臺Tomcat搭建了一個簡單的負載均衡叢集,作為測試使用,搭建步驟可以看一下這篇文章:Windows下使用Nginx+Tomcat做負載均衡

但是問題出現了,我們的客戶端瀏覽器只會與一臺伺服器建立WebSocket長連線,所以釋出訊息者在傳送訊息時,就沒法保證所有目標部門的人都能接收到訊息(因為這些人連線的可能不是一個伺服器)。

本篇文章就是針對於這麼一個問題展開討論,提出一種解決方案,當然解決方案不止一種,那我們開始吧。

 

WebSocket單體應用介紹

在介紹分散式叢集之前,我們先來看一下王子的WebSocket程式碼實現,先來看java後端程式碼如下:

import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

@ServerEndpoint("/webSocket/{key}")
public class WebSocket {
    private static int onlineCount = 0;
    /**
     * 儲存連線的客戶端
     */
    private static Map<String, WebSocket> clients = new ConcurrentHashMap<String, WebSocket>();
    private Session session;
    /**
     * 傳送的目標科室code
     */
    private String key;

    @OnOpen
    public void onOpen(@PathParam("key") String key, Session session) throws IOException {
        this.key = key;
        this.session = session;
        if (!clients.containsKey(key)) {
            addOnlineCount();
        }
        clients.put(key, this);
        Log.info(key+"已連線訊息服務!");
    }

    @OnClose
    public void onClose() throws IOException {
        clients.remove(key);
        subOnlineCount();
    }

    @OnMessage
    public void onMessage(String message) throws IOException {
        if(message.equals("ping")){
            return ;
        }
        JSONObject jsonTo = JSON.parseObject(message);
        String mes = (String) jsonTo.get("message");
        if (!jsonTo.get("to").equals("All")){
            sendMessageTo(mes, jsonTo.get("to").toString());
        }else{
            sendMessageAll(mes);
        }
    }

    @OnError
    public void onError(Session session, Throwable error) {
        error.printStackTrace();
    }

    private void sendMessageTo(String message, String To) throws IOException {
        for (WebSocket item : clients.values()) {
            if (item.key.contains(To) )
                item.session.getAsyncRemote().sendText(message);
        }
    }

    private void sendMessageAll(String message) throws IOException {
        for (WebSocket item : clients.values()) {
            item.session.getAsyncRemote().sendText(message);
        }
    }

    public static synchronized int getOnlineCount() {
        return onlineCount;
    }

    public static synchronized void addOnlineCount() {
        WebSocket.onlineCount++;
    }

    public static synchronized void subOnlineCount() {
        WebSocket.onlineCount--;
    }

    public static synchronized Map<String, WebSocket> getClients() {
        return clients;
    }
}

示例程式碼中並沒有使用Spring,用的是原生的java web編寫的,簡單和大家介紹一下里面的方法。

onOpen:在客戶端與WebSocket服務連線時觸發方法執行

onClose:在客戶端與WebSocket連線斷開的時候觸發執行

onMessage:在接收到客戶端傳送的訊息時觸發執行

onError:在發生錯誤時觸發執行

可以看到,在onMessage方法中,我們直接根據客戶端傳送的訊息,進行訊息的轉發功能,這樣在單體訊息服務中是沒有問題的。

 

再來看一下js程式碼

var host = document.location.host;

    // 獲得當前登入科室
    var deptCodes='${sessionScope.$UserContext.departmentID}';
    deptCodes=deptCodes.replace(/[\[|\]|\s]+/g, "");
    var key = '${sessionScope.$UserContext.userID}'+deptCodes;
    var lockReconnect = false;  //避免ws重複連線
    var ws = null;          // 判斷當前瀏覽器是否支援WebSocket
    var wsUrl = 'ws://' + host + '/webSocket/'+ key;
    createWebSocket(wsUrl);   //連線ws

    function createWebSocket(url) {
        try{
            if('WebSocket' in window){
                ws = new WebSocket(url);
            }else if('MozWebSocket' in window){  
                ws = new MozWebSocket(url);
            }else{
                  layer.alert("您的瀏覽器不支援websocket協議,建議使用新版谷歌、火狐等瀏覽器,請勿使用IE10以下瀏覽器,360瀏覽器請使用極速模式,不要使用相容模式!"); 
            }
            initEventHandle();
        }catch(e){
            reconnect(url);
            console.log(e);
        }     
    }

    function initEventHandle() {
        ws.onclose = function () {
            reconnect(wsUrl);
            console.log("llws連線關閉!"+new Date().toUTCString());
        };
        ws.onerror = function () {
            reconnect(wsUrl);
            console.log("llws連線錯誤!");
        };
        ws.onopen = function () {
            heartCheck.reset().start();      //心跳檢測重置
            console.log("llws連線成功!"+new Date().toUTCString());
        };
        ws.onmessage = function (event) {    //如果獲取到訊息,心跳檢測重置
            heartCheck.reset().start();      //拿到任何訊息都說明當前連線是正常的//接收到訊息實際業務處理
        ...
        };
    }
    // 監聽視窗關閉事件,當視窗關閉時,主動去關閉websocket連線,防止連線還沒斷開就關閉視窗,server端會拋異常。
    window.onbeforeunload = function() {
        ws.close();
    }  

    function reconnect(url) {
        if(lockReconnect) return;
        lockReconnect = true;
        setTimeout(function () {     //沒連線上會一直重連,設定延遲避免請求過多
            createWebSocket(url);
            lockReconnect = false;
        }, 2000);
    }

    //心跳檢測
    var heartCheck = {
        timeout: 300000,        //5分鐘發一次心跳
        timeoutObj: null,
        serverTimeoutObj: null,
        reset: function(){
            clearTimeout(this.timeoutObj);
            clearTimeout(this.serverTimeoutObj);
            return this;
        },
        start: function(){
            var self = this;
            this.timeoutObj = setTimeout(function(){
                //這裡傳送一個心跳,後端收到後,返回一個心跳訊息,
                //onmessage拿到返回的心跳就說明連線正常
                ws.send("ping");
                console.log("ping!")
                self.serverTimeoutObj = setTimeout(function(){//如果超過一定時間還沒重置,說明後端主動斷開了
                    ws.close();     //如果onclose會執行reconnect,我們執行ws.close()就行了.如果直接執行reconnect 會觸發onclose導致重連兩次
                }, self.timeout)
            }, this.timeout)
        }
  }

js部分使用的是原生H5編寫的,如果為了更好的相容瀏覽器,也可以使用SockJS,有興趣小夥伴們可以自行百度。

接下來我們就手動的優化程式碼,實現WebSocket對分散式架構的支援。

 

解決方案的思考

現在我們已經瞭解單體應用下的程式碼結構,也清楚了WebSocket在分散式環境下面臨的問題,那麼是時候思考一下如何能夠解決這個問題了。

我們先來看一看發生這個問題的根本原因是什麼。

簡單思考一下就能明白,單體應用下只有一臺伺服器,所有的客戶端連線的都是這一臺訊息伺服器,所以當釋出訊息者傳送訊息時,所有的客戶端其實已經全部與這臺伺服器建立了連線,直接群發訊息就可以了。

換成分散式系統後,假如我們有兩臺訊息伺服器,那麼客戶端通過Nginx負載均衡後,就會有一部分連線到其中一臺伺服器,另一部分連線到另一臺伺服器,所以釋出訊息者傳送訊息時,只會傳送到其中的一臺伺服器上,而這臺訊息伺服器就可以執行群發操作,但問題是,另一臺伺服器並不知道這件事,也就無法傳送訊息了。

現在我們知道了根本原因是生產訊息時,只有一臺訊息伺服器能夠感知到,所以我們只要讓另一臺訊息伺服器也能感知到就可以了,這樣感知到之後,它就可以群發訊息給連線到它上邊的客戶端了。

那麼什麼方法可以實現這種功能呢,王子很快想到了引入訊息中介軟體,並使用它的釋出訂閱模式來通知所有訊息伺服器就可以了。

 

引入RabbitMQ解決分散式下的WebSocket問題

在訊息中介軟體的選擇上,王子選擇了RabbitMQ,原因是它的搭建比較簡單,功能也很強大,而且我們只是用到它群發訊息的功能。

RabbitMQ有一個廣播模式(fanout),我們使用的就是這種模式。

首先我們寫一個RabbitMQ的連線類:

import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;

import java.io.IOException;
import java.util.concurrent.TimeoutException;


public class RabbitMQUtil {
    private static Connection connection;

    /**
     * 與rabbitmq建立連線
     * @return
     */
    public static Connection getConnection() {
        if (connection != null&&connection.isOpen()) {
            return connection;
        }

        ConnectionFactory factory = new ConnectionFactory();
        factory.setVirtualHost("/");
        factory.setHost("192.168.220.110"); // 用的是虛擬IP地址
        factory.setPort(5672);
        factory.setUsername("guest");
        factory.setPassword("guest");

        try {
            connection = factory.newConnection();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (TimeoutException e) {
            e.printStackTrace();
        }

        return connection;
    }
}

這個類沒什麼說的,就是獲取MQ連線的一個工廠類。

然後按照我們的思路,就是每次伺服器啟動的時候,都會建立一個MQ的消費者監聽MQ的訊息,王子這裡測試使用的是Servlet的監聽器,如下:

import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;


public class InitListener implements ServletContextListener {
    @Override
    public void contextInitialized(ServletContextEvent servletContextEvent) {
        WebSocket.init();
    }

    @Override
    public void contextDestroyed(ServletContextEvent servletContextEvent) {

    }
}

記得要在Web.xml中配置監聽器資訊

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
         version="4.0">
    <listener>
        <listener-class>InitListener</listener-class>
    </listener>
</web-app>

WebSocket中增加init方法,作為MQ消費者部分

public  static void init() {
        try {
            Connection connection = RabbitMQUtil.getConnection();
            Channel channel = connection.createChannel();
            //交換機宣告(引數為:交換機名稱;交換機型別)
            channel.exchangeDeclare("fanoutLogs",BuiltinExchangeType.FANOUT);
            //獲取一個臨時佇列
            String queueName = channel.queueDeclare().getQueue();
            //佇列與交換機繫結(引數為:佇列名稱;交換機名稱;routingKey忽略)
            channel.queueBind(queueName,"fanoutLogs","");


            //這裡重寫了DefaultConsumer的handleDelivery方法,因為傳送的時候對訊息進行了getByte(),在這裡要重新組裝成String
            Consumer consumer = new DefaultConsumer(channel) {
                @Override
                public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
                    super.handleDelivery(consumerTag, envelope, properties, body);
                    String message = new String(body,"UTF-8");
                    System.out.println(message);
            //這裡可以使用WebSocket通過訊息內容傳送訊息給對應的客戶端 } };
//宣告佇列中被消費掉的訊息(引數為:佇列名稱;訊息是否自動確認;consumer主體) channel.basicConsume(queueName,true,consumer); //這裡不能關閉連線,呼叫了消費方法後,消費者會一直連線著rabbitMQ等待消費 } catch (IOException e) { e.printStackTrace(); } }

同時在接收到訊息時,不是直接通過WebSocket傳送訊息給對應客戶端,而是傳送訊息給MQ,這樣如果訊息伺服器有多個,就都會從MQ中獲得訊息,之後通過獲取的訊息內容再使用WebSocket推送給對應的客戶端就可以了。

WebSocket的onMessage方法增加內容如下:

try {
            //嘗試獲取一個連線
            Connection connection = RabbitMQUtil.getConnection();
            //嘗試建立一個channel
            Channel channel = connection.createChannel();
            //宣告交換機(引數為:交換機名稱; 交換機型別,廣播模式)
            channel.exchangeDeclare("fanoutLogs", BuiltinExchangeType.FANOUT);
            //訊息釋出(引數為:交換機名稱; routingKey,忽略。在廣播模式中,生產者宣告交換機的名稱和型別即可)
            channel.basicPublish("fanoutLogs","", null,msg.getBytes("UTF-8"));
            System.out.println("釋出訊息");
            channel.close();
        } catch (IOException |TimeoutException e) {
            e.printStackTrace();
        }

增加後刪除掉原來的Websocket推送部分程式碼。

這樣一整套的解決方案就完成了。

 

總結

到這裡,我們就解決了分散式下WebSocket的推送訊息問題。

我們主要是引入了RabbitMQ,通過RabbitMQ的釋出訂閱模式,讓每個訊息伺服器啟動的時候都去訂閱訊息,而無論哪臺訊息伺服器在傳送訊息的時候都會傳送給MQ,這樣每臺訊息伺服器就都會感知到傳送訊息的事件,從而再通過Websocket傳送給客戶端。

大體流程就是這樣,那麼小夥伴們有沒有想過,如果RabbitMQ掛掉了幾分鐘,之後重啟了,消費者是否可以重新連線到RabbitMQ?是否還能正常接收訊息呢?

生產環境下,這個問題是必須考慮的。

這裡王子已經測試過,消費者是支援自動重連的,所以我們可以放心的使用這套架構來解決此問題。

本文到這裡就結束了,歡迎各位小夥伴留言討論,一起學習,一起進步。

 

 

往期文章推薦:

什麼是訊息中介軟體?主要作用是什麼?

常見的訊息中介軟體有哪些?你們是怎麼進行技術選型的?

你懂RocketMQ 的架構原理嗎?

聊一聊RocketMQ的註冊中心NameServer

Broker的主從架構是怎麼實現的?

RocketMQ生產部署架構如何設計

RabbitMQ和Kafka的高可用叢集原理

RocketMQ的傳送模式和消費模式

討論一下秒殺系統的技術難點與解決方案

相關文章