Spring Boot 使用 WebSocket 實現消息推送 及 WebSocket原理

WebSocket 是什麼原理,爲什麼可以實現持久連接?

一、WebSocket是HTML5出的東西(協議),也就是說HTTP協議沒有變化,或者說沒關係,但HTTP是不支持持久連接的(長連接,循環連接的不算)

首先HTTP有1.1和1.0之說,也就是所謂的keep-alive,把多個HTTP請求合併爲一個,但是Websocket其實是一個新協議,跟HTTP協議基本沒有關係,只是爲了兼容現有瀏覽器的握手規範而已,也就是說它是HTTP協議上的一種補充可以通過這樣一張圖理解。

有交集,但是並不是全部。
另外Html5是指的一系列新的API,或者說新規範,新技術。Http協議本身只有1.0和1.1,而且跟Html本身沒有直接關係。
通俗來說,你可以用HTTP協議傳輸非Html數據,就是這樣。再簡單來說,層級不一樣。

二、Websocket是什麼樣的協議,具體有什麼優點

首先,Websocket是一個持久化的協議,相對於HTTP這種非持久的協議來說。簡單的舉個例子吧,用目前應用比較廣泛的PHP生命週期來解釋。
1) HTTP的生命週期通過Request來界定,也就是一個Request 一個Response,那麼在HTTP1.0中,這次HTTP請求就結束了。
在HTTP1.1中進行了改進,使得有一個keep-alive,也就是說,在一個HTTP連接中,可以發送多個Request,接收多個Response。
但是請記住 Request = Response , 在HTTP中永遠是這樣,也就是說一個request只能有一個response。而且這個response也是被動的,不能主動發起。
2)首先Websocket是基於HTTP協議的,或者說借用了HTTP的協議來完成一部分握手。在握手階段是一樣的。
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Origin: http://example.com
熟悉HTTP的童鞋可能發現了,這段類似HTTP協議的握手請求中,多了幾個東西。
Upgrade: websocket
Connection: Upgrade
這個就是Websocket的核心了,告訴Apache、Nginx等服務器:注意啦,窩發起的是Websocket協議,快點幫我找到對應的助理處理~不是那個老土的HTTP。
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
首先,Sec-WebSocket-Key 是一個Base64 encode的值,這個是瀏覽器隨機生成的,告訴服務器:你妹,不要忽悠窩,我要驗證你是不是真的是Websocket助理。
然後,Sec_WebSocket-Protocol 是一個用戶定義的字符串,用來區分同URL下,不同的服務所需要的協議。簡單理解:今晚我要服務A,別搞錯啦~
最後,Sec-WebSocket-Version 是告訴服務器所使用的Websocket Draft(協議版本),在最初的時候,Websocket協議還在 Draft 階段,各種奇奇怪怪的協議都有,而且還有很多期奇奇怪怪不同的東西,什麼Firefox和Chrome用的不是一個版本之類的,當初Websocket協議太多可是一個大難題。不過現在還好,已經定下來啦~大家都使用的一個東西。
然後服務器會返回下列東西,表示已經接受到請求,成功建立Websocket啦!
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat
這裏開始就是HTTP最後負責的區域了,告訴客戶,我已經成功切換協議啦~
Upgrade: websocket
Connection: Upgrade
依然是固定的,告訴客戶端即將升級的是Websocket協議,而不是mozillasocket,lurnarsocket或者shitsocket。
然後,Sec-WebSocket-Accept 這個則是經過服務器確認,並且加密過後的 Sec-WebSocket-Key。服務器:好啦好啦,知道啦,給你看我的ID CARD來證明行了吧。
後面的,Sec-WebSocket-Protocol 則是表示最終使用的協議。
至此,HTTP已經完成它所有工作了,接下來就是完全按照Websocket協議進行了。具體的協議就不在這闡述了。

三、Websocket的作用

在講Websocket之前,我就順帶着講下 long poll 和 ajax輪詢 的原理。
首先是 ajax輪詢 ,ajax輪詢 的原理非常簡單,讓瀏覽器隔個幾秒就發送一次請求,詢問服務器是否有新信息。
場景再現:
客戶端:啦啦啦,有沒有新信息(Request)
服務端:沒有(Response)
客戶端:啦啦啦,有沒有新信息(Request)
服務端:沒有。。(Response)
客戶端:啦啦啦,有沒有新信息(Request)
服務端:你好煩啊,沒有啊。。(Response)
客戶端:啦啦啦,有沒有新消息(Request)
服務端:好啦好啦,有啦給你。(Response)
客戶端:啦啦啦,有沒有新消息(Request)
服務端:。。。。。沒。。。。沒。。。沒有(Response) ---- loop

long poll 
long poll 其實原理跟 ajax輪詢 差不多,都是採用輪詢的方式,不過採取的是阻塞模型(一直打電話,沒收到就不掛電話),也就是說,客戶端發起連接後,如果沒消息,就一直不返回Response給客戶端。直到有消息才返回,返回完之後,客戶端再次建立連接,周而復始。
場景再現
客戶端:啦啦啦,有沒有新信息,沒有的話就等有了才返回給我吧(Request)
服務端:額。。 等待到有消息的時候。。來 給你(Response)
客戶端:啦啦啦,有沒有新信息,沒有的話就等有了才返回給我吧(Request) -loop

從上面可以看出其實這兩種方式,都是在不斷地建立HTTP連接,然後等待服務端處理,可以體現HTTP協議的另外一個特點,被動性。
何爲被動性呢,其實就是,服務端不能主動聯繫客戶端,只能有客戶端發起。
簡單地說就是,服務器不會、不能主動發起連接,但是上司有命令,如果有客戶來,不管多麼累都要好好接待。
說完這個,我們再來說一說上面的缺陷,從上面很容易看出來,不管怎麼樣,上面這兩種都是非常消耗資源的。
ajax輪詢 需要服務器有很快的處理速度和資源。(速度)
long poll 需要有很高的併發,也就是說同時接待客戶的能力。(場地大小)
所以ajax輪詢 和long poll 都有可能發生這種情況。
客戶端:啦啦啦啦,有新信息麼?
服務端:線路正忙,請稍後再試(503 Server Unavailable)
客戶端:。。。。好吧,啦啦啦,有新信息麼?
服務端:線路正忙,請稍後再試(503 Server Unavailable)

通過上面這個例子,我們可以看出,這兩種方式都不是最好的方式,需要很多資源。
一種需要更快的速度,一種需要更多的'電話'。這兩種都會導致'電話'的需求越來越高。
HTTP還是一個無狀態協議。通俗的說就是,服務器因爲每天要接待太多客戶了,是個健忘鬼,你一掛電話,他就把你的東西全忘光了,把你的東西全丟掉了。你第二次還得再告訴服務器一遍。
所以在這種情況下出現了,Websocket出現了。他解決了HTTP的這幾個難題。
首先,被動性,當服務器完成協議升級後(HTTP->Websocket),服務端就可以主動推送信息給客戶端啦。所以上面的情景可以做如下修改。
客戶端:啦啦啦,我要建立Websocket協議,需要的服務:chat,Websocket協議版本:17(HTTP Request)
服務端:ok,確認,已升級爲Websocket協議(HTTP Protocols Switched)
客戶端:麻煩你有信息的時候推送給我噢。。
服務端:ok,有的時候會告訴你的。
服務端:balabalabalabala
服務端:balabalabalabala
服務端:哈哈哈哈哈啊哈哈哈哈
服務端:笑死我了哈哈哈哈哈哈哈
就變成了這樣,只需要經過一次HTTP請求,就可以做到源源不斷的信息傳送了。(在程序設計中,這種設計叫做回調,即:你有信息了再來通知我,而不是我傻乎乎的每次跑來問你)
這樣的協議解決了上面同步有延遲,而且還非常消耗資源的這種情況。
那麼爲什麼他會解決服務器上消耗資源的問題呢?
其實我們所用的程序是要經過兩層代理的,即HTTP協議在Nginx等服務器的解析下,然後再傳送給相應的Handler(JAVA等)來處理。
簡單地說,我們有一個非常快速的接線員(Nginx),他負責把問題轉交給相應的客服(Handler)。
本身接線員基本上速度是足夠的,但是每次都卡在客服(Handler)了,老有客服處理速度太慢。,導致客服不夠。
Websocket就解決了這樣一個難題,建立後,可以直接跟接線員建立持久連接,有信息的時候客服想辦法通知接線員,然後接線員在統一轉交給客戶。
這樣就可以解決客服處理速度過慢的問題了。
同時,在傳統的方式上,要不斷的建立,關閉HTTP協議,由於HTTP是非狀態性的,每次都要重新傳輸identity info(鑑別信息),來告訴服務端你是誰。
雖然接線員很快速,但是每次都要聽這麼一堆,效率也會有所下降的,同時還得不斷把這些信息轉交給客服,不但浪費客服的處理時間,而且還會在網路傳輸中消耗過多的流量/時間。
但是Websocket只需要一次HTTP握手,所以說整個通訊過程是建立在一次連接/狀態中,也就避免了HTTP的非狀態性,服務端會一直知道你的信息,直到你關閉請求,這樣就解決了接線員要反覆解析HTTP協議,還要查看identity info的信息。
同時由客戶主動詢問,轉換爲服務器(推送)有信息的時候就發送(當然客戶端還是等主動發送信息過來的。。),沒有信息的時候就交給接線員(Nginx),不需要佔用本身速度就慢的客服(Handler)了。

至於怎麼在不支持Websocket的客戶端上使用Websocket。。答案是:不能。
但是可以通過上面說的 long poll 和 ajax 輪詢來模擬出類似的效果。

---------------------------------------------------------------------------------------------
下面是具體代碼實現:
1、pom
核心是@ServerEndpoint這個註解。這個註解是Javaee標準裏的註解,tomcat7以上已經對其進行了實現,如果是用傳統方法使用tomcat發佈項目,只要在pom文件中引入javaee標準即可使用。
    <dependency>
      <groupId>javax</groupId>
      <artifactId>javaee-api</artifactId>
      <version>7.0</version>
      <scope>provided</scope>
    </dependency>
但使用springboot的內置tomcat時,就不需要引入javaee-api了,spring-boot已經包含了。使用springboot的websocket功能首先引入springboot組件。
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
            <version>1.3.5.RELEASE</version>
        </dependency>
springboot的高級組件會自動引用基礎的組件,像spring-boot-starter-websocket就引入了spring-boot-starter-web和spring-boot-starter,所以不要重複引入。

2、使用@ServerEndpoint創立websocket endpoint
首先要注入ServerEndpointExporter,這個bean會自動註冊使用了@ServerEndpoint註解聲明的Websocket endpoint。要注意,如果使用獨立的servlet容器,而不是直接使用springboot的內置容器,就不要注入ServerEndpointExporter,因爲它將由容器自己提供和管理。
@Configuration
public class WebSocketConfig {
    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}
3、接下來就是寫websocket的具體實現類,很簡單,直接上代碼:
package com.bocom.fmp.common.websocket;
import java.io.IOException;
import java.util.concurrent.CopyOnWriteArraySet;
import javax.websocket.OnClose;
import javax.websocket.OnError;
import javax.websocket.OnMessage;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.springframework.stereotype.Component;

@ServerEndpoint(value = "/client/{userId}")
@Component
public class WarningPushSocket {
	
    private static Logger logger = LogManager.getLogger(WarningPushSocket.class.getName());
	
    //靜態變量,用來記錄當前在線連接數。應該把它設計成線程安全的。
    private static int onlineCount = 0;
	  
    //concurrent包的線程安全Set,用來存放每個客戶端對應的MyWebSocket對象。
    private static CopyOnWriteArraySet<WarningPushSocket> wsClientMap = new CopyOnWriteArraySet<>();  
  
    //與某個客戶端的連接會話,需要通過它來給客戶端發送數據
    private Session session;
    
    /**
     * 連接建立成功調用的方法
     * @param session 當前會話session
     */
    @OnOpen
    public void onOpen (@PathParam("userId")String userId,Session session){
        this.session = session;
        wsClientMap.add(this);
        addOnlineCount();
        logger.info(session.getId()+"有新鏈接加入,當前鏈接數爲:" + wsClientMap.size());
    }
  
    /**
     * 連接關閉
     */
    @OnClose
    public void onClose (){
    	wsClientMap.remove(this);  
        subOnlineCount();  
        logger.info("有一鏈接關閉,當前鏈接數爲:" + wsClientMap.size());
    }
  
    /**
     * 收到客戶端消息
     * @param message 客戶端發送過來的消息
     * @param session 當前會話session
     * @throws IOException
     */
    @OnMessage
    public void onMessage (String message, Session session) throws IOException {
        logger.info("來終端的警情消息:" + message);
        sendMsgToAll(message); 
    }
    
    /**
     * 發生錯誤
     */
    @OnError
    public void onError(Session session, Throwable error) {
    	logger.info("wsClientMap發生錯誤!");
        error.printStackTrace();
    }
    
    /**
     * 給所有客戶端羣發消息
     * @param message 消息內容
     * @throws IOException
     */
    public void sendMsgToAll(String message) throws IOException {
    	for ( WarningPushSocket item : wsClientMap ){  
            item.session.getBasicRemote().sendText(message);
    	}
    	logger.info("成功羣送一條消息:" + wsClientMap.size());
    }
  
    public void sendMessage (String message) throws IOException {
        this.session.getBasicRemote().sendText(message);  
        logger.info("成功發送一條消息:" + message);
    }
  
    public static synchronized  int getOnlineCount (){
        return WarningPushSocket.onlineCount;  
    }
  
    public static synchronized void addOnlineCount (){
    	WarningPushSocket.onlineCount++;  
    }
  
    public static synchronized void subOnlineCount (){
    	WarningPushSocket.onlineCount--;  
    }
}

使用springboot的唯一區別是要@Component聲明下,而使用獨立容器是由容器自己管理websocket的,但在springboot中連容器都是spring管理的。
雖然@Component默認是單例模式的,但springboot還是會爲每個websocket連接初始化一個bean,所以可以用一個靜態set保存起來。
<!DOCTYPE HTML>
<html>
<head>
    <title>My WebSocket</title>
</head>


<body>
Welcome<br/>
<input id="text" type="text" /><button οnclick="send()">Send</button>    <button οnclick="closeWebSocket()">Close</button>
<div id="message">
</div>
</body>


<script type="text/javascript">
    var websocket = null;


    //判斷當前瀏覽器是否支持WebSocket
    if('WebSocket' in window){
        websocket = new WebSocket("ws://localhost:9090/client");
    }
    else{
        alert('Not support websocket')
    }


    //連接發生錯誤的回調方法
    websocket.onerror = function(){
        setMessageInnerHTML("error");
    };


    //連接成功建立的回調方法
    websocket.onopen = function(event){
        setMessageInnerHTML("open");
    }


    //接收到消息的回調方法
    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>
</html>

4、總結
springboot已經做了深度的集成和優化,要注意是否添加了不需要的依賴、配置或聲明。由於很多講解組件使用的文章是和spring集成的,會有一些配置,在使用springboot時,由於springboot已經有了自己的配置,再這些配置有可能導致各種各樣的異常。

發佈了83 篇原創文章 · 獲贊 333 · 訪問量 68萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章