服務器端實時推送技術之SSE

前言

在講Server-Sent Events (SSE) 之前,我們先來看看 HTTP 請求- 響應。一個標準的 HTTP 請求- 響應,需要客戶端打開一個連接,將一個 HTTP 請求(如 HTTP GET 請求)發送到服務端,然後接收到 HTTP 回來的響應,如果該響應被完全發送或者接收,服務端就會把連接關閉。通常是由某個客戶發起,客戶端纔會需要請求所有數據。

然而, Server-Sent Events (SSE) 與 HTTP 請求- 響應背道而馳,它是一種機制,客戶端一旦建立起客戶機-服務器的連接,就能讓服務端將數據以異步的方式從服務器推到客戶端。當連接由客戶端建立完成,服務端就提供數據,並決定新數據“塊"可用時將其發送到客戶端。當一個新的數據事件發生在服務端時,這個事件被服務端發送到客戶端。因此,名稱被稱爲 Server-Sent Events(服務器推送事件)。下面是支持服務端到客戶端交互的技術總覽:

  • 插件提供 socket 方式:比如利用 Flash XMLSocket,Java Applet 套接口,Activex 包裝的 socket。

    • 優點:原生 socket 的支持,與 PC 端的實現方式相似;
    • 缺點:瀏覽器端需要裝相應的插件;與 js 進行交互時複雜
  • Polling:輪詢,重複發送新的請求到服務端。如果服務端沒有新的數據,就發送適當的指示並關閉連接。然後客戶端等待一段時間後,發送另一個請求(例如,一秒後)

    • 優點:實現簡單,無需做過多的更改
    • 缺點:輪詢的間隔過長,會導致用戶不能及時接收到更新的數據;輪詢的間隔過短,會導致查詢請求過多,增加服務器端的負擔。

  • Long-polling:長輪詢,客戶端發送一個請求到服務端,如果服務端沒有新的數據,就保持住這個連接直到有數據。一旦服務端有了數據(消息)給客戶端,它就使用這個連接發送數據給客戶端。接着連接關閉。
    •  優點:比 Polling 做了優化,有較好的時效性
    • 缺點:需第三方庫支持,實現較爲複雜;每次連接只能發送一個數據,多個數據發送時耗費服務器性能

  • 基於 iframe 及 htmlfile 的流(streaming)方式:iframe 流方式是在頁面中插入一個隱藏的 iframe,利用其src屬性在服務器和客戶端之間創建一條長鏈接,服務器向 iframe 傳輸數據(通常是 HTML,內有負責插入信息的 javascript),來實時更新頁面。
    • 優點:消息能夠實時到達;
    • 缺點:服務器維持着長連接期會消耗資源;iframe 不規範的用法;數據推送過程會有加載進度條顯示,界面體驗不好

  • Server-Sent events:SSE 與 長輪詢機制類似,區別是每個連接不只發送一個消息。客戶端發送一個請求,服務端就保持這個連接直到有一個新的消息已經準備好了,那麼它將消息發送回客戶端,同時仍然保持這個連接是打開,這樣這個連接就可以用於另一個可用消息的發送。一旦準備好了一個新消息,通過同一初始連接發送回客戶端。客戶端單獨處理來自服務端傳回的消息後不關閉連接。所以,SSE 通常重用一個連接處理多個消息(稱爲事件)。SSE 還定義了一個專門的媒體類型 text/event-stream,描述一個從服務端發送到客戶端的簡單格式。SSE 還提供在大多數現代瀏覽器裏的標準 javascript 客戶端 API 實現。關於 SSE 的更多信息,請參見 SSE API 規範
    • 優點:HTML5 標準;實現較爲簡單;一個連接可以發送多個數據
    • 缺點:IE 不支持 EventSource(可以使用第三方的 js 庫來解決,具體可以本章中的源碼) ;服務器只能單向推送數據到客戶端

  • WebSocket: WebSocket 與上述技術都不同,因爲它提供了一個真正的全雙工連接。發起者是一個客戶端,發送一個帶特殊 HTTP 頭的請求到服務端,通知服務器, HTTP 連接可能“升級”到一個全雙工的 TCP/IP WebSocket 連接。如果服務端支持 WebSocket,它可能會選擇升級到 WebSocket。一旦建立 WebSocket 連接,它可用於客戶機和服務器之間的雙向通信。客戶端和服務器可以隨意向對方發送數據。此時,新的 WebSocket 連接上的交互不再是基於 HTTP 協議了。 WebSocket 可以用於需要快速在兩個方向上交換小塊數據的在線遊戲或任何其他應用程序。(示例可以參考http://www.waylau.com/netty-websocket-chat/)
    • 優點:HTML5 標準;大多數瀏覽器支持;真正全雙工;性能強
    • 缺點:實現相對複雜;ws 協議

SSE vs. WebSocket

用比較籠統的一個說法,就是WebSocket能做的,SSE也能做,反之亦然,但是它們還是有差別的,特別是在完成某些任務方面。WebSocket 是一種更爲複雜的服務端實現技術,但它是真正的雙向傳輸技術,既能從服務端向客戶端推送數據,也能從客戶端向服務端推送數據。WebSocket 和 SSE 的瀏覽器支持率差不多,除了IE。IE是個例外,即便IE11都還不支持原生 SSE,IE10 添加了WebSocket 支持,可見上圖。與 WebSocket 相比,SSE 有一些顯著的優勢。我認爲它最大的優勢就是便利:不需要添加任何新組件,用任何你習慣的後端語言和框架就能繼續使用。你不用爲新建虛擬機、弄一個新的IP或新的端口號而勞神,就像在現有網站中新增一個頁面那樣簡單。我喜歡把這稱爲既存基礎設施優勢。

SSE 的第二個優勢是服務端的簡潔。我們將在下節中看到,服務端代碼只需幾行。相對而言,WebSocket 則很複雜,不借助輔助類庫基本搞不定。因爲 SSE 能在現有的 HTTP/HTTPS 協議上運作,所以它能直接運行於現有的代理服務器和認證技術。而對 WebSocket 而言,代理服務器需要做一些開發(或其他工作)才能支持,在寫這本書時,很多服務器還沒有(雖然這種狀況會改善)。SSE還有一個優勢:它是一種文本協議,腳本調試非常容易。事實上,在本書中,我們會在開發和測試時用 curl,甚至直接在命令行中運行後端腳本。不過,這就引出了 WebSocket 相較 SSE 的一個潛在優勢:WebSocket 是二進制協議,而 SSE 是文本協議(通常使用UTF-8編碼)。當然,我們可以通過SSE連接傳輸二進制數據:在 SSE 中,只有兩個具有特殊意義的字符,它們是 CR 和LF,而對它們進行轉碼並不難。但用 SSE 傳輸二進制數據時數據會變大,如果需要從服務端到客戶端傳輸大量的二進制數據,最好還是用 WebSocket。

WebSocket 相較 SSE 最大的優勢在於它是雙向交流的,這意味向服務端發送數據就像從服務端接收數據一樣簡單。用 SSE時,一般通過一個獨立的 Ajax 請求從客戶端向服務端傳送數據。相對於 WebSocket,這樣使用 Ajax 會增加開銷,但也就多一點點而已。如此一來,問題就變成了“什麼時候需要關心這個差異?”如果需要以1次/秒或者更快的頻率向服務端傳輸數據,那應該用 WebSocket。0.2次/秒到1次/秒的頻率是一個灰色地帶,用 WebSocket 和用 SSE 差別不大;但如果你期望重負載,那就有必要確定基準點。頻率低於0.2次/秒左右時,兩者差別不大。

從服務端向客戶端傳輸數據的性能如何?如果是文本數據而非二進制數據(如前文所提到的),SSE和WebSocket沒什麼區別。它們都用TCP/IP套接字,都是輕量級協議。延遲、帶寬、服務器負載等都沒有區別。在舊版本瀏覽器上的兼容,WebSocket 難兼容,SSE 易兼容。

服務器端實時推送技術之SSE用法

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;

import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 服務器端實時推送技術之 SseEmitter 的用法測試
 * <p>
 * 測試步驟:
 * 1.請求http://localhost:8888/sse/start?clientId=111接口,瀏覽器會阻塞,等待服務器返回結果;
 * 2.請求http://localhost:8888/sse/send?clientId=111接口,可以請求多次,並觀察第1步的瀏覽器返回結果;
 * 3.請求http://localhost:8888/sse/end?clientId=111接口結束某個請求,第1步的瀏覽器將結束阻塞;
 * 其中clientId代表請求的唯一標誌;
 *
 * @author syj
 */
@RestController
@RequestMapping("/sse")
public class SseEmitterController {
    private static final Logger logger = LoggerFactory.getLogger(SseEmitterController.class);

    // 用於保存每個請求對應的 SseEmitter
    private Map<String, Result> sseEmitterMap = new ConcurrentHashMap<>();

    /**
     * 返回SseEmitter對象
     *
     * @param clientId
     * @return
     */
    @RequestMapping("/start")
    public SseEmitter testSseEmitter(String clientId) {
        // 默認30秒超時,設置爲0L則永不超時
        SseEmitter sseEmitter = new SseEmitter(0L);
        sseEmitterMap.put(clientId, new Result(clientId, System.currentTimeMillis(), sseEmitter));
        return sseEmitter;
    }

    /**
     * 向SseEmitter對象發送數據
     *
     * @param clientId
     * @return
     */
    @RequestMapping("/send")
    public String setSseEmitter(String clientId) {
        try {
            Result result = sseEmitterMap.get(clientId);
            if (result != null && result.sseEmitter != null) {
                long timestamp = System.currentTimeMillis();
                result.sseEmitter.send(timestamp);
            }
        } catch (IOException e) {
            logger.error("IOException!", e);
            return "error";
        }

        return "Succeed!";
    }

    /**
     * 將SseEmitter對象設置成完成
     *
     * @param clientId
     * @return
     */
    @RequestMapping("/end")
    public String completeSseEmitter(String clientId) {
        Result result = sseEmitterMap.get(clientId);
        if (result != null) {
            sseEmitterMap.remove(clientId);
            result.sseEmitter.complete();
        }
        return "Succeed!";
    }

    private class Result {
        public String clientId;
        public long timestamp;
        public SseEmitter sseEmitter;

        public Result(String clientId, long timestamp, SseEmitter sseEmitter) {
            this.clientId = clientId;
            this.timestamp = timestamp;
            this.sseEmitter = sseEmitter;
        }
    }
}

前端JS接受

<script>
	//記錄加載次數
	var time=1;
	if (typeof (EventSource) !== "undefined") {
		var source = new EventSource("/sse/start?clientId=111");
		console.log(source);
		source.addEventListener("事件名", function(e) {
			document.getElementById("result").innerHTML = e.data;
		}, false);//使用false表示在冒泡階段處理事件,而不是捕獲階段。
	} else {
		document.getElementById("result").innerHTML = "抱歉,你的瀏覽器不支持 server-sent 事件...";
	}
</script>

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章