前言
最近王子自己搭建了個專案,專案本身很簡單,但是裡面有使用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?是否還能正常接收訊息呢?
生產環境下,這個問題是必須考慮的。
這裡王子已經測試過,消費者是支援自動重連的,所以我們可以放心的使用這套架構來解決此問題。
本文到這裡就結束了,歡迎各位小夥伴留言討論,一起學習,一起進步。
往期文章推薦: