1. 程式人生 > >WebSocket :用WebSocket實現推送你必須考慮的幾個問題

WebSocket :用WebSocket實現推送你必須考慮的幾個問題

目錄:

1.WebSocket簡介

2.專案背景、硬體環境及客戶端支援

本專案通過WebSocket實現同時線上使用者量幾千的推送伺服器(可內網執行)。且可實時檢視使用者線上狀態。

  • 伺服器:centos 6.5、tomcat 7
  • 客戶端:移動端(安卓、IOS)、網頁端。
  • 服務端第三方庫 :javax.websocket

3.本文研究內容

應用的線上環境後各種異常情況處理:

  • 使用WebSocket時,依賴TCP keepalive還是做業務層心跳
  • 伺服器如何感知客戶端斷開(用以檢視實時使用者線上狀態)
  • 客戶端如何感知服務端異常(用以決定客戶端何時重連)

4.基於javax.websocket服務端程式碼(原始碼後續補充git連線)

WebSocketServer.java

package cn.milo.wsdemo;

import org.apache.log4j.Logger;

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

/*
 * create : 17-07-21
 * auth : milo
 */
@ServerEndpoint("/connect/{userId}"
) public class WebSocketServer { private static Logger log = Logger.getLogger(WebSocketServer.class); /* New Connected */ @OnOpen public void onOpen(@PathParam("userId") String userId , Session session){ log.info("[WebSocketServer] Connected : userId = "
+ userId); WebSocketUtils.add(userId , session); } /* Send Message */ @OnMessage public String onMessage(@PathParam("userId") String userId, String message) { log.info("[WebSocketServer] Received Message : userId = "+ userId + " , message = " + message); if (message.equals("&")){ return "&"; }else{ WebSocketUtils.receive(userId , message); return "Got your message ("+ message +")."; } } /* Errot */ @OnError public void onError(@PathParam("userId") String userId, Throwable throwable, Session session) { log.info("[WebSocketServer] Connection Exception : userId = "+ userId + " , throwable = " + throwable.getMessage()); WebSocketUtils.remove(userId); } /* Close Connection */ @OnClose public void onClose(@PathParam("userId") String userId, Session session) { log.info("[WebSocketServer] Close Connection : userId = " + userId); WebSocketUtils.remove(userId); } }

WebSocketUtils.java

package cn.milo.wsdemo;

import cn.milo.FileUtils.CreateFile;
import org.apache.log4j.Logger;

import javax.websocket.Session;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;


public class WebSocketUtils {

    private static Logger log = Logger.getLogger(WebSocketUtils.class);

    public static Map<String, Session> clients = new ConcurrentHashMap<String, Session>(); 

    /*
    Add Session
     */
    public static void add(String userId, Session session) {
        clients.put(userId,session);
        log.info("當前連線數 = " + clients.size());

    }

    /*
    Receive Message
     */
    public static void receive(String userId, String message) {
        log.info("收到訊息 : UserId = " + userId + " , Message = " + message);
        log.info("當前連線數 = " + clients.size());
    }

    /*
    Remove Session
     */
    public static void remove(String userId) {
        clients.remove(userId);
        log.info("當前連線數 = " + clients.size());

    }

    /*
    Get Session
     */
    public static boolean sendMessage(String userId , String message) {
        log.info("當前連線數 = " + clients.size());
        if(clients.get(userId) == null){
            return false;
        }else{
            clients.get(userId).getAsyncRemote().sendText(message);
            return true;
        }

    }
}

5.客戶端程式碼

<body>
server地址 :  <input id ="serveraddress" type="text" /><br/>
您的使用者id :  <input id ="userId" type="text" /><br/>
<button onclick="initSocket()">連線</button><br/>

=====================================================<br/>
訊息 :  <input id ="message" type="text" /><br/>
<button onclick="send()">傳送</button><br/>
=====================================================<br/>
連線狀態 : <button onclick="clearConnectStatu()">清空</button><br/>
<div id="connectStatu"></div><br/>

=====================================================<br/>
收到訊息 :<br/>
<div id="receivedMessage"></div><br/>
=====================================================<br/>
心跳 :<br/>
<div id="heartdiv"></div><br/>

</body>

<script src="<%=basePath%>/resources/jquery-1.7.2.min.js"></script>
<script type="text/javascript">
    var heartflag = false;
    var webSocket = null;
    var tryTime = 0;
    $(function () {

//        initSocket();
        window.onbeforeunload = function () {

        };
    });

    /**
     * 初始化websocket,建立連線
     */
    function initSocket() {
        var serveraddress = $("#serveraddress").val();
        var userId = $("#userId").val();

        if (!window.WebSocket) {
            $("#connectStatu").append(getNowFormatDate()+"  您的瀏覽器不支援ws<br/>");
            return false;
        }

        webSocket = new WebSocket(serveraddress+"/"+userId);

        // 收到服務端訊息
        webSocket.onmessage = function (msg) {
            if(msg.data == "&"){

            }else{
                $("#receivedMessage").append(getNowFormatDate()+"  收到訊息 : "+msg.data+"<br/>");
            }
        };

        // 異常
        webSocket.onerror = function (event) {
            heartflag = false;
            $("#connectStatu").append(getNowFormatDate()+"  異常<br/>");
        };

        // 建立連線
        webSocket.onopen = function (event) {
            heartflag = true;
            heart();
            $("#connectStatu").append(getNowFormatDate()+"  建立連線成功<br/>");
            tryTime = 0;
        };

        // 斷線重連
        webSocket.onclose = function () {
            heartflag = false;
            // 重試10次,每次之間間隔10秒
            if (tryTime < 10) {
                setTimeout(function () {
                    webSocket = null;
                    tryTime++;
                    initSocket();
                    $("#connectStatu").append( getNowFormatDate()+"  第"+tryTime+"次重連<br/>");
                }, 3*1000);
            } else {
                alert("重連失敗.");
            }
        };

    }

    function send(){
        var message = $("#message").val();
        webSocket.send(message);
    }

    function clearConnectStatu(){
        $("#connectStatu").empty();
    }

    function getNowFormatDate() {
        var date = new Date();
        var seperator1 = "-";
        var seperator2 = ":";
        var month = date.getMonth() + 1;
        var strDate = date.getDate();
        if (month >= 1 && month <= 9) {
            month = "0" + month;
        }
        if (strDate >= 0 && strDate <= 9) {
            strDate = "0" + strDate;
        }
        var currentdate = date.getFullYear() + seperator1 + month + seperator1 + strDate
                + " " + date.getHours() + seperator2 + date.getMinutes()
                + seperator2 + date.getSeconds();
        return currentdate;
    }

    function heart() {
        if (heartflag){
            webSocket.send("&");
            $("#heartdiv").append(getNowFormatDate()+"  心跳 <br/>");
        }
        setTimeout("heart()", 10*60*1000);

    }
</script>

6.問題探索

首先ws是基於TCP的應用層技術,那麼很多同學這裡會有疑問TCP本身是有keepalive機制的為什麼還要做應用層心跳。原因有以下幾個:1.client異常掛死,此時keepalive機制無法反饋真實的client狀態; 2.client 異常斷電斷網出現TCP假死keepalive並不能根本性解決問題,實際上網際網路環境很不穩定;3.ws在應用層,基於傳輸層,在ws中操作TCP也很不方便。封裝就意味著易用性提高靈活性降低。

所以我們在應用層開啟心跳。1次/10mins

接下來我們聊一聊客戶端正常斷開異常斷開如何處理:

客戶端:

client server處理方法 client處理方法 處理思路
關閉瀏覽器 觸發onClose回撥 / 應用層ws主動關掉連線(優雅關閉)
殺掉瀏覽器 觸發onClose和onError回撥 / 在作業系統中,應用程式對應的程序被幹掉的時候會關閉其埠,也就是觸發了TCP四次揮手。對於ws來講直接在外部斷開TCP會觸發ws異常,對於ws來講這樣的關閉方式為非優雅關閉會觸發異常.
斷電斷網 檢測client最後心跳上報時間 觸發onClose(斷網) server角度:如果client最後上報時間已經超過正常週期*3,server認為其離線
client角度:斷電就不說了。斷網的情況client之所以觸發了onClose我認為可能是當斷網時作業系統關閉了所有對外的網路埠或者作業系統通知了瀏覽器斷網(由此看出作業系統的知識真的是太重要了);所以此時三個心跳週期過後當我們認為此session已經斷開時不要忘記通知ws close掉這個session,不然有可能出現大量服務端TCP假死.接下來說重連,大家要注意重連對於server是來講是一個新的連線,大家可以通過斷網重連後server產生的session判斷出斷網重連實際上是產生了一個新的連線。對於server的原session如何處理我做了這樣一個測試,當客戶端斷網後server依然通過原session傳送資料給client當傳送的資料超過一定時間一定數量沒有回覆後server會觸發onError和onClose方法,對於原session server在client斷開後從來不給這個client發訊息的情況也就是重連的情況,我們要在新的session產生時及時清掉舊的session.同TCP假死處理一致.


服務端:

server server處理方法 client處理方法 處理思路
重啟tomcat / 觸發onClose 應用層ws主動關掉連線(優雅關閉)
殺掉tomcat(kill -9 pid / 觸發onClose和onError回撥 (同client被殺死)
斷電斷網 檢測client最後心跳上報時間 心跳異常 (見下表:server斷電斷網時client如何感知),也就是說對於client來講,只要正常傳送心跳給server就可以了。如果server斷開網路超過20分鐘(心跳:次/10mins)所有client均會掉線


server斷電斷網時client如何感知

心跳週期 client現象
次/1s 斷網/斷電後167s(中間經歷了167次心跳)觸發client onClose方法
次/1min 斷網/斷電後6mins 40s(中間經歷了6次心跳)觸發client onClose方法
次/10mins 第一次測試:斷網/斷電後11mins 27s(中間經歷了1次心跳)觸發client onClose方法
第二次測試:斷網/斷電後14mins 28s(中間經歷了1次心跳)觸發client onClose方法
第三次測試:斷網/斷電後15mins 54s(中間經歷了1次心跳)觸發client onClose方法

8月3日補充 中間線路斷網情況

補充一下中間線路斷網情況:
如:中間nat裝置斷網(網際網路環境中間nat裝置是非常多的)或者server網路斷開.這裡大家注意client斷網不算是中間線路斷網,因為client端斷網應用程式馬上可以感知.但是client所在區域網的出口nat斷開的就算是中間網路斷開.

其實上邊已經提到了server網路斷開的情況,分別說明了server和client各自的檢測辦法.但是很多網路不穩定的情況,如:斷開18分鐘後網路又恢復了,這裡涉及到一個重連機制,首先大家要明白當中間網路斷開時實際上是兩段各自維護本端tcp的.最終會觸發tcp強制拆鏈(不傳送四次揮手).分為兩種情況討論:

(1)網路恢復時,client已經將自己連線斷開了,但是server認為網路還在連線中,和tcp假死很像.這種情況在服務端檢測心跳超時之前,服務端推送訊息是沒有辦法到達客戶端的.但是這時服務端的試圖發訊息動作會觸發服務端發現這個連線已經斷開了. 從現象看ws重連時間為: 網路恢復時間——>server發現連線斷開(server發訊息)+超時/server心跳檢測超時 (前提:網路斷開後到網路恢復中間這段時間server沒法過訊息給client,如果傳送過可能網路連線上立即觸發服務端發現連線斷開.)
(2)網路恢復時,client沒有將自己連線斷開,但是server已經斷開.這種情況在client下一次心跳傳送後會觸發tcp重發,重發一定時間沒有回覆client也會進行強制拆鏈.ws重連時間為:網路恢復時間——>client下一次心跳時間+超時. (前提:網路斷開後到網路恢復中間這段時間client沒發過心跳給server,如果傳送過可能網路連線上立即觸發客戶端發現連線斷開.)

上邊兩個前提有點難懂,意思是當網路斷開到網路恢復中間這段時間傳送過訊息,那麼這個訊息第一次傳送肯定是到不了對端,但是這時就已經開始tcp重傳機制了,可能網路恢復時恰好有一次重傳,你的訊息可以發到對端了,但是對端tcp埠已經關閉,tcp發生異常也就立即觸發了本端tcp的關閉.

綜上:tcp重連是需要時間的,這個時間肯定是越短越好,但是又不能太短,這個時間的確定大家可以參考本篇最後的測試.

7.如何做到支援幾千個client同時線上人數

首先tomcat最大執行緒數預設肯定到不了幾千,所以我們需要調tomcat最大執行緒數及執行記憶體。具體引數大家百度一下吧。我這邊最大執行記憶體3個g,最大執行緒調到5k的情況下,3k個client同時線上是沒問題的。再者大家注意下linux作業系統本身有些涉及到tcp連線的配置也可能需要修改。

8.後續

之前本來是想通過udp打洞方式實現內網推送的,但是上週花了一個週末的時間測試結果都不是很理想。有時間我會針對udp打洞原理專門寫篇部落格。

9.8月3日補充(相關測試)

這幾天做了主流瀏覽器的測試工作,測試結果如下:

瀏覽器 心跳間隔:次/10mins 心跳間隔:(無心跳)
360瀏覽器 (不支援ws) (不支援ws)
ie10/ie11 48h穩定(只測了48h) 32mins斷開(錯誤號:1005)
google瀏覽器 18h穩定(只測了18h) 30mins斷開
火狐瀏覽器 5mins斷開 5mins斷開
傲遊瀏覽器 18h穩定(只測了18h) 30mins斷開
UC瀏覽器 18h穩定(只測了18h) 30mins斷開
橘子瀏覽器 3h5mins 異常(且沒有錯誤號e.code) 3h斷開
搜狗瀏覽器 18h穩定(只測了18h) 30mins斷開
QQ瀏覽器 18h穩定(只測了18h) 18h穩定(只測了18h)
獵豹瀏覽器 18h穩定(只測了18h) 18h穩定(只測了18h)

這裡有幾個點說明一下:
1.除特殊說明的橘子瀏覽器,其他瀏覽器斷開時錯誤號均為1006
2.橘子瀏覽器:心跳間隔次/10mins情況下,發生異常且沒有錯誤號,我初步判斷為瀏覽器內部發生異常,可見橘子瀏覽器很不穩定呀.
3.火狐瀏覽器很特殊,心跳次/10min情況下也會斷開,所以我這邊把心跳時間調整為4分半,目前1小時連線正常.
4.ie瀏覽器無心跳情況下32mins斷開,錯誤號1005,1005意思為超時.
結論:由此也證明了[問題探索]中的開啟應用層心跳是非常有必要的.不然連線超過一定時間後自動斷開,且心跳推薦時間為4分半,用以適配所有瀏覽器.