瀏覽器EventSource
EventSource基本介紹
EventSource 是服務器推送的一個網絡事件接口。一個EventSource實例會對HTTP服務開啓一個持久化的連接,以text/event-stream 格式發送事件, 會一直保持開啓直到被要求關閉。
一旦連接開啓,來自服務端傳入的消息會以事件的形式分發至你代碼中。如果接收消息中有一個事件字段,觸發的事件與事件字段的值相同。如果沒有事件字段存在,則將觸發通用事件。
與 WebSockets,不同的是,服務端推送是單向的。數據信息被單向從服務端到客戶端分發. 當不需要以消息形式將數據從客戶端發送到服務器時,這使它們成爲絕佳的選擇。例如,對於處理社交媒體狀態更新,新聞提要或將數據傳遞到客戶端存儲機制(如IndexedDB或Web存儲)之類的,EventSource無疑是一個有效方案。
- EventSource維持一個可以持續接收數據的HTTP長連接
- EventSource接收文本編碼的流數據,在接收到結束符之前可以一直接收數據
- EventSource在接收到結束符後會在一定間隔後自動輪詢(或稱重試)
- EventSource接收文本數據
- EventSource對象被創建則自動開始輪詢,每次輪詢都在上一次輪詢響應完成後
- EventSource對象每次輪詢服務器都會觸發
onopen
和onmessage
,但一次長連接只觸發一次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
:當次消息IDtype
:正常情況下都爲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-Type
爲text/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());
}
}