SSE之瀏覽器EventSource及服務端event-stream簡單使用及三種方式實現服務端推送(WebAsyncTask和SseEmitter及WebFlux)

瀏覽器EventSource

EventSource基本介紹

EventSource 是服務器推送的一個網絡事件接口。一個EventSource實例會對HTTP服務開啓一個持久化的連接,以text/event-stream 格式發送事件, 會一直保持開啓直到被要求關閉。

一旦連接開啓,來自服務端傳入的消息會以事件的形式分發至你代碼中。如果接收消息中有一個事件字段,觸發的事件與事件字段的值相同。如果沒有事件字段存在,則將觸發通用事件。

與 WebSockets,不同的是,服務端推送是單向的。數據信息被單向從服務端到客戶端分發. 當不需要以消息形式將數據從客戶端發送到服務器時,這使它們成爲絕佳的選擇。例如,對於處理社交媒體狀態更新,新聞提要或將數據傳遞到客戶端存儲機制(如IndexedDB或Web存儲)之類的,EventSource無疑是一個有效方案。

  • EventSource維持一個可以持續接收數據的HTTP長連接
  • EventSource接收文本編碼的流數據,在接收到結束符之前可以一直接收數據
  • EventSource在接收到結束符後會在一定間隔後自動輪詢(或稱重試)
  • EventSource接收文本數據
  • EventSource對象被創建則自動開始輪詢,每次輪詢都在上一次輪詢響應完成後
  • EventSource對象每次輪詢服務器都會觸發onopenonmessage,但一次長連接只觸發一次onopen,在當次長連接期間每次接收數據都會觸發onmessage
  • EventSource需要瀏覽器主動調用EventSource.close()纔會結束輪詢(重試)
  • EventSource重試頻率只能由服務端控制,retry:5000\ndata:數據\n\n表示在5000毫秒後再次輪詢,retry:毫秒數\n是可選的,默認3秒
  • EventSource連接時異常觸發onerror,並立刻重試,不受retry間隔影響

EventSource對象

構造函數

var sse = EventSource(url,configuration);
  • url: 它代表遠程資源的位置的url字符串
  • configuration: (可選)配置JSON=> withCredentials:默認爲false,指示 CORS 是否應包含憑據(credentials)
  • 返回值: 一個新建的 EventSource 對象

返回的EventSource對象包含的屬性:

  • onerror: 錯誤事件函數,入參爲事件對象
  • onmessage: 收到消息事件函數,入參爲事件對象,返回的數據在對象的data屬性中
  • onopen: 連接建立並打開事件函數,入參爲事件對象
  • readyState: (只讀)表連接狀態,可能值是 CONNECTING (0), OPEN (1), 或者 CLOSED (2)
  • url: (只讀)事件源的URL

事件接收器

  • EventSource.onerror: 是一個 EventHandler,當發生錯誤時被調用,並且在此對象上派發 error 事件。
  • EventSource.onmessage: 是一個 EventHandler,當收到一個 message 事件,即消息來自源頭時被調用。
  • EventSource.onopen: 是一個 EventHandler,當收到一個 open 事件,即連接剛打開時被調用。

onmessage入參對象關鍵屬性:

  • data:當次消息數據
  • lastEventId:當次消息ID
  • type:正常情況下都爲message

方法

  • EventSource.close(): 如果存在,則關閉連接,並且設置 readyState 屬性爲 CLOSED。如果連接已經被關閉,此方法不會再進行任何操作。

EventSource簡單使用

//EventSource簡單使用示例(利用重試機制不停輪詢)
window.sseCount = 0;
var sse = new EventSource("/sse");
sse.onmessage = function (e) {
	document.getElementById("s1").innerText=
		new Date(Number(e.data)).toLocaleString();
	++window.sseCount>=20&&sse.close()
}

//EventSource對應的java服務端event-stream
@RequestMapping(value="/sse",produces= MediaType.TEXT_EVENT_STREAM_VALUE)
public String sse(){
	return "data:" + new Date().getTime() + "\n\n";
}

服務端event-stream

服務端event-stream基本介紹

對於服務器而言接收到EventSource的請求和接收到的常規HTTP請求沒有什麼不同,服務端每次收到EventSource都是一個全新的Request

  • 響應時的Content-Typetext/event-stream
  • 響應必須編碼成utf-8等文本格式
  • 響應的體的數據必須以data:開始,且一次輪詢的響應體必須以\n\n結束,否則不能觸發onmessage
  • 如果需要控制瀏覽器輪詢頻率則響應報文前加上retry:毫秒數\n
  • 如果需要給消息唯一的表示則響應報文前加上id:字符串\n
  • 在消息體中\n表示當前類型的內容結束但消息體未結束,而\n\n表示消息體結束

EventSource接收的消息格式:

  • 數據:(必須)data:數據內容\n,對應onmessage入參對象的data屬性
  • 事件id(可選,默認空):id:事件ID字符串\n,對應onmessage入參對象的lastEventId屬性
  • 頻率(可選,默認3秒):retry:毫秒數\n,瀏覽器在接收到響應的制定毫秒數後重新輪詢
  • 結束標識(必須):\n,即消息體最後必須以\n\n結束
  • 完整響應文本格式:retry:毫秒數\nid:ID字符串\ndata:DATA_CONTENT\n\n
  • 樣例: retry:1000\nid:123\ndata:woshishuju\n\n對應的消息爲:重試間隔1秒,當次消息ID爲123,當次消息的數據爲woshishuju

瀏覽器js代碼

window.sseCount = 0;
var sse = new EventSource("/test/sse");
sse.onmessage = function (e) {
	console.log(e)
	var newElement = document.createElement("li");
	newElement.textContent = "id: " + e.lastEventId +
		" , message: " + e.data + " , format: " +
		new Date(Number(e.data)).toLocaleString();
	++window.sseCount>=5&&sse.close();
	var ul = document.querySelector('ul');
	if(ul==null){
		ul = document.createElement("ul");
		document.body.appendChild(ul);
	}
	ul.appendChild(newElement);
}
/*
頁面顯示的消息是:
id: 13053 , message: 1614758013053 , format: 2021/3/3 下午3:53:33
id: 14073 , message: 1614758014073 , format: 2021/3/3 下午3:53:34
id: 15090 , message: 1614758015090 , format: 2021/3/3 下午3:53:35
id: 16114 , message: 1614758016114 , format: 2021/3/3 下午3:53:36
id: 17126 , message: 1614758017126 , format: 2021/3/3 下午3:53:37
*/

服務端java代碼

@RequestMapping(value="/test/sse",produces= MediaType.TEXT_EVENT_STREAM_VALUE)
public String sse(){
	System.out.println(Thread.currentThread().getName());
	long d = new Date().getTime();
	return "retry:1000\nid:"+(d%100000)+"\ndata:" + d + "\n\n";
}
//這個方法被EventSource每秒輪詢一次(retry:1000)
//當這個方法被EventSource輪詢時會打印輸出日誌如下:
/*
http-nio-8080-exec-2
http-nio-8080-exec-3
http-nio-8080-exec-4
http-nio-8080-exec-5
http-nio-8080-exec-7
http-nio-8080-exec-6
http-nio-8080-exec-8
http-nio-8080-exec-9
......
*/
//則證明每次被EventSource輪詢時都是獨立的HTTP請求

對應的每次EventSource輪詢請求報文

GET /test/sse HTTP/1.1
Host: 127.0.0.1:8080
Connection: keep-alive
Accept: text/event-stream
Cache-Control: no-cache
Last-Event-ID: 85413
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.182 Safari/537.36 Edg/88.0.705.81
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: http://127.0.0.1:8080/
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6

對應的每次EventSource輪詢響應標頭

HTTP/1.1 200
Content-Type: text/event-stream;charset=UTF-8
Content-Length: 40
Date: Wed, 03 Mar 2021 07:41:26 GMT
Keep-Alive: timeout=60
Connection: keep-alive

WebAsyncTask實現服務端推送

瀏覽器端 js 代碼

//訂閱
function sub(){
    var sse = new EventSource("/sseStream?id=haha");
    sse.onmessage = function (e) {
        console.log(e)
        var newElement = document.createElement("li");
        newElement.textContent = "id: " + e.lastEventId +
            " , message: " + e.data + " , format: " +
            new Date(Number(e.data)).toLocaleString();
        var ul = document.querySelector('ul');
        if(ul==null){
            ul = document.createElement("ul");
            document.body.appendChild(ul);
        }
        ul.appendChild(newElement);
        //接收到約定的結束標識則不再輪詢,此時可能會因爲延遲而輪詢一次
        if("over"===e.data)sse.close();
    }
}
//發送get請求
function get(url){
    var xhr = new XMLHttpRequest();
    xhr.open("GET",url);
    xhr.send("");
}
//模擬推送
function push(){
    var i;
    for(i=0;i<5;++i){
        setTimeout(function (){
            get("/push?id=haha&content="+new Date().getTime());
        },1000*i);
    }
    setTimeout(function (){
        get("/over?id=haha");
    },1000*(i+1));
}

//測試
sub();
setTimeout(function(){
    push();
},1000);

服務端 java 代碼

import org.springframework.web.bind.annotation.*;
import org.springframework.web.context.request.async.WebAsyncTask;

import javax.servlet.http.HttpServletResponse;
import java.io.PrintWriter;
import java.util.Map;
import java.util.concurrent.Callable;
import java.util.concurrent.ConcurrentHashMap;

@RestController
public class TestController {
    // 新建一個容器,保存連接,用於輸出流
    private Map<String, PrintWriter> responseMap = new ConcurrentHashMap<>();
    // 發送數據給客戶端
    private void writeData(String id, String msg, boolean over) throws Exception {
        PrintWriter writer = responseMap.get(id);
        if (writer == null) {
            return;
        }
        if(msg==null)msg="";
        writer.println("data:"+msg+"\n");
        writer.flush();
        if (over) {
            writer.println("data:"+msg+"\n\n");
            writer.flush();
            writer.close();
            responseMap.remove(id);
        }
    }

    //訂閱消息
    @RequestMapping("/sseStream")
    public WebAsyncTask<Void> sseStream(String id,final HttpServletResponse response){
        response.setHeader("Content-Type", "text/event-stream;charset=UTF-8");
        Callable<Void> callable = () -> {
            responseMap.put(id, response.getWriter());
            writeData(id, "訂閱成功", false);
            while (true) {
                Thread.sleep(1000);
                if (!responseMap.containsKey(id)) {
                    break;
                }
            }
            return null;
        };

        // 採用WebAsyncTask 返回 這樣可以處理超時和錯誤 同時也可以指定使用的Excutor名稱
        WebAsyncTask<Void> webAsyncTask = new WebAsyncTask<>(30000, callable);
        // 注意:onCompletion表示完成,不管你是否超時、是否拋出異常,這個函數都會執行的
        webAsyncTask.onCompletion(() -> System.out.println("程序[正常執行]完成的回調"));

        webAsyncTask.onTimeout(() -> {
            responseMap.remove(id);
            System.out.println("超時了!!!");
            return null;
        });
        
        webAsyncTask.onError(() -> {
            System.out.println("出現異常!!!");
            return null;
        });

        return webAsyncTask;
    }

    //模擬消息推送
    @ResponseBody
    @GetMapping(path = "push")
    public String pushData(String id, String content) throws Exception {
        writeData(id, content, false);
        return "over!";
    }

    //模擬結束當次推送
    @ResponseBody
    @GetMapping(path = "over")
    public String over(String id) throws Exception {
        writeData(id, "over", true);
        return "over!";
    }

}

利用SseEmitter快速實現服務端推送

前端瀏覽器 js 代碼

//向頁面寫接收到的數據
function writeSseLog(e){
    var newElement = document.createElement("li");
    newElement.textContent = "message: " + e.data;
    var ul = document.querySelector('ul');
    if(ul==null){
        ul = document.createElement("ul");
        document.body.appendChild(ul);
    }
    ul.appendChild(newElement);
}
//訂閱消息
function sseSub(id){
    window["sseSubId"+id]=null;
    var sse = new EventSource("/sse/subscribe?id="+id);
    sse.onmessage=function (e){
        if(window["sseSubId"+id]=='over')sse.close();
        writeSseLog(e);
    }
}
//訂閱者1
function sseSub1(){
    sseSub("1");
}
//訂閱者2
function sseSub2(){
    sseSub("2");
}
//發送get請求
function get(url){
    var xhr = new XMLHttpRequest();
    xhr.open("GET",url);
    xhr.send("");
}
//發佈消息
function ssePush(id,txt){
    var url;
    if(id!=null){
        url = "/sse/push?id="+id+"&content="+txt;
    }else{
        url = "/sse/pushAll?content="+txt;
    }
    get(url);
}
//向訂閱者1發佈
function ssePush1() {
    ssePush("1","1-"+new Date().toLocaleString());
}
//向訂閱者2發佈
function ssePush2() {
    ssePush("2","2-"+new Date().toLocaleString());
}
//向全部訂閱者發佈
function ssePushAll() {
    ssePush(null,"A-"+new Date().toLocaleString());
}
//結束訂閱並不再輪詢
function sseOver(id){
    window["sseSubId"+id]='over';
    get("/sse/over?id="+id);
}
//結束全部訂閱
function sseOverAll(){
    sseOver("1");
    sseOver("2");
}

//測試
sseSub1();
sseSub2();
window.pushCount=0;
for(var i=0;i<6;++i){
    setTimeout(function(){
        ssePush1();
        ssePush2();
        ++window.pushCount%2==0&&ssePushAll();
        window.pushCount==6&&sseOverAll();
    },1000*i);
}

後端 java 代碼

import org.springframework.web.bind.annotation.GetMapping;
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;
import java.util.function.BiConsumer;

@RestController
@RequestMapping("/sse")
public class SseController {
    private static final Map<String, SseEmitter> sseCache = new ConcurrentHashMap<>();

    /**訂閱*/
    @GetMapping("/subscribe")
    public SseEmitter subscribe(final String id){
        var sseM = new SseEmitter(1000*60*60L);//超時時間1小時
        sseCache.put(id, sseM);//放入緩存
        sseM.onTimeout(()->{
            sseCache.remove(id);
            System.out.println("over time!");
        });//超時從緩存刪除
        sseM.onCompletion(()-> {
            sseCache.remove(id);
            System.out.println("success over!");
        });//完成從緩存刪除
        sseM.onError(throwable-> {
            System.out.println("error! "+id);
            throwable.printStackTrace();
        });//發生錯誤時打印錯誤
        return sseM;
    }

    /**消息發佈到指定接收者*/
    @GetMapping("/push")
    public String push(final String id, String content) throws IOException {
        var sseM = sseCache.get(id);
        if(sseM!=null)sseM.send(content);
        return "over";
    }

    /**斷開訂閱*/
    @GetMapping("/over")
    public String over(final String id){
        var sseM = sseCache.get(id);
        if(sseM!=null)sseM.complete();
        return "over";
    }

    /**廣播發布*/
    @GetMapping("/pushAll")
    public String pushAll(String content){
        sseCache.keySet().forEach(k->{
            try {
                sseCache.get(k).send(content);
            } catch (IOException e) {
                e.printStackTrace();
            }
        });
        return "over";
    }
}

利用WebFlux更快實現服務端推送

前端瀏覽器 js 代碼

var sse = new EventSource("/sse/subscribe");
sse.onmessage = function (e){
    var ul = document.querySelector("ul");
    if(ul==null){
        ul = document.createElement("ul");
        document.body.appendChild(ul);
    }
    var li = document.createElement("li");
    li.textContent = e.data;
    ul.appendChild(li);
}

服務端 java 代碼

package com.example.wefluxdemo.web;

import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import reactor.util.function.Tuples;

import java.time.Duration;
import java.util.Date;

@RequestMapping("/sse")
@RestController
public class SseController {

    @GetMapping(value = "/subscribe",produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<String>> subscribe(){
        return Flux.interval(Duration.ofSeconds(1))
                .map(seq -> Tuples.of(seq, currTime()))
                .map(data -> ServerSentEvent.<String>builder()
                        .id(Long.toString(data.getT1()))  //爲每次發送設置一個id
                        .data(data.getT2().toString())
                        .build());
    }

    private String currTime(){
        return String.valueOf(new Date().getTime());
    }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章