在講Server-Sent Events (SSE) 之前,我們先來(lái)看看 HTTP 請(qǐng)求- 響應(yīng)。一個(gè)標(biāo)準(zhǔn)的 HTTP 請(qǐng)求- 響應(yīng),需要客戶端打開(kāi)一個(gè)連接,將一個(gè) HTTP 請(qǐng)求(如 HTTP GET 請(qǐng)求)發(fā)送到服務(wù)端,然后接收到 HTTP 回來(lái)的響應(yīng),如果該響應(yīng)被完全發(fā)送或者接收,服務(wù)端就會(huì)把連接關(guān)閉。通常是由某個(gè)客戶發(fā)起,客戶端才會(huì)需要請(qǐng)求所有數(shù)據(jù)。
然而, Server-Sent Events (SSE) 與 HTTP 請(qǐng)求- 響應(yīng)背道而馳,它是一種機(jī)制,客戶端一旦建立起客戶機(jī)-服務(wù)器的連接,就能讓服務(wù)端將數(shù)據(jù)以異步的方式從服務(wù)器推到客戶端。當(dāng)連接由客戶端建立完成,服務(wù)端就提供數(shù)據(jù),并決定新數(shù)據(jù)“塊"可用時(shí)將其發(fā)送到客戶端。當(dāng)一個(gè)新的數(shù)據(jù)事件發(fā)生在服務(wù)端時(shí),這個(gè)事件被服務(wù)端發(fā)送到客戶端。因此,名稱被稱為 Server-Sent Events(服務(wù)器推送事件)。下面是支持服務(wù)端到客戶端交互的技術(shù)總覽:
插件提供 socket 方式:比如利用 Flash XMLSocket,Java Applet 套接口,Activex 包裝的 socket。
Polling:輪詢,重復(fù)發(fā)送新的請(qǐng)求到服務(wù)端。如果服務(wù)端沒(méi)有新的數(shù)據(jù),就發(fā)送適當(dāng)?shù)闹甘静㈥P(guān)閉連接。然后客戶端等待一段時(shí)間后,發(fā)送另一個(gè)請(qǐng)求(例如,一秒后)
用比較籠統(tǒng)的一個(gè)說(shuō)法,就是WebSocket能做的,SSE也能做,反之亦然,但是它們還是有差別的,特別是在完成某些任務(wù)方面。
WebSocket 是一種更為復(fù)雜的服務(wù)端實(shí)現(xiàn)技術(shù),但它是真正的雙向傳輸技術(shù),既能從服務(wù)端向客戶端推送數(shù)據(jù),也能從客戶端向服務(wù)端推送數(shù)據(jù)。
WebSocket 和 SSE 的瀏覽器支持率差不多,除了IE。IE是個(gè)例外,即便IE11都還不支持原生 SSE,IE10 添加了WebSocket 支持,可見(jiàn)上圖。
與 WebSocket 相比,SSE 有一些顯著的優(yōu)勢(shì)。我認(rèn)為它最大的優(yōu)勢(shì)就是便利:不需要添加任何新組件,用任何你習(xí)慣的后端語(yǔ)言和框架就能繼續(xù)使用。你不用為新建虛擬機(jī)、弄一個(gè)新的IP或新的端口號(hào)而勞神,就像在現(xiàn)有網(wǎng)站中新增一個(gè)頁(yè)面那樣簡(jiǎn)單。我喜歡把這稱為既存基礎(chǔ)設(shè)施優(yōu)勢(shì)。
SSE 的第二個(gè)優(yōu)勢(shì)是服務(wù)端的簡(jiǎn)潔。我們將在下節(jié)中看到,服務(wù)端代碼只需幾行。相對(duì)而言,WebSocket 則很復(fù)雜,不借助輔助類庫(kù)基本搞不定。
因?yàn)?SSE 能在現(xiàn)有的 HTTP/HTTPS 協(xié)議上運(yùn)作,所以它能直接運(yùn)行于現(xiàn)有的代理服務(wù)器和認(rèn)證技術(shù)。而對(duì) WebSocket 而言,代理服務(wù)器需要做一些開(kāi)發(fā)(或其他工作)才能支持,在寫(xiě)這本書(shū)時(shí),很多服務(wù)器還沒(méi)有(雖然這種狀況會(huì)改善)。SSE還有一個(gè)優(yōu)勢(shì):它是一種文本協(xié)議,腳本調(diào)試非常容易。事實(shí)上,在本書(shū)中,我們會(huì)在開(kāi)發(fā)和測(cè)試時(shí)用 curl,甚至直接在命令行中運(yùn)行后端腳本。
不過(guò),這就引出了 WebSocket 相較 SSE 的一個(gè)潛在優(yōu)勢(shì):WebSocket 是二進(jìn)制協(xié)議,而 SSE 是文本協(xié)議(通常使用UTF-8編碼)。當(dāng)然,我們可以通過(guò)SSE連接傳輸二進(jìn)制數(shù)據(jù):在 SSE 中,只有兩個(gè)具有特殊意義的字符,它們是 CR 和LF,而對(duì)它們進(jìn)行轉(zhuǎn)碼并不難。但用 SSE 傳輸二進(jìn)制數(shù)據(jù)時(shí)數(shù)據(jù)會(huì)變大,如果需要從服務(wù)端到客戶端傳輸大量的二進(jìn)制數(shù)據(jù),最好還是用 WebSocket。
WebSocket 相較 SSE 最大的優(yōu)勢(shì)在于它是雙向交流的,這意味向服務(wù)端發(fā)送數(shù)據(jù)就像從服務(wù)端接收數(shù)據(jù)一樣簡(jiǎn)單。用 SSE時(shí),一般通過(guò)一個(gè)獨(dú)立的 Ajax 請(qǐng)求從客戶端向服務(wù)端傳送數(shù)據(jù)。相對(duì)于 WebSocket,這樣使用 Ajax 會(huì)增加開(kāi)銷,但也就多一點(diǎn)點(diǎn)而已。如此一來(lái),問(wèn)題就變成了“什么時(shí)候需要關(guān)心這個(gè)差異?”如果需要以1次/秒或者更快的頻率向服務(wù)端傳輸數(shù)據(jù),那應(yīng)該用 WebSocket。0.2次/秒到1次/秒的頻率是一個(gè)灰色地帶,用 WebSocket 和用 SSE 差別不大;但如果你期望重負(fù)載,那就有必要確定基準(zhǔn)點(diǎn)。頻率低于0.2次/秒左右時(shí),兩者差別不大。
從服務(wù)端向客戶端傳輸數(shù)據(jù)的性能如何?如果是文本數(shù)據(jù)而非二進(jìn)制數(shù)據(jù)(如前文所提到的),SSE和WebSocket沒(méi)什么區(qū)別。它們都用TCP/IP套接字,都是輕量級(jí)協(xié)議。延遲、帶寬、服務(wù)器負(fù)載等都沒(méi)有區(qū)別。
在舊版本瀏覽器上的兼容,WebSocket 難兼容,SSE 易兼容。
看了上述的定義,可以知道 SSE 適合應(yīng)用于服務(wù)端單向推送信息到客戶端的場(chǎng)景。 Jersey 的 SSE 大致可以分為發(fā)布-訂閱模式和廣播模式。
為使用 Jersey SSE, 添加如下依賴:
<dependency>
<groupId>org.glassfish.jersey.media</groupId>
<artifactId>jersey-media-sse</artifactId>
</dependency>
@Path("see-events")
public class SseResource {
private EventOutput eventOutput = new EventOutput();
private OutboundEvent.Builder eventBuilder;
private OutboundEvent event ;
/**
* 提供 SSE 事件輸出通道的資源方法
* @return eventOutput
*/
@GET
@Produces(SseFeature.SERVER_SENT_EVENTS)
public EventOutput getServerSentEvents() {
// 不斷循環(huán)執(zhí)行
while (true) {
SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");//設(shè)置日期格式
String now = df.format(new Date()); //獲取當(dāng)前系統(tǒng)時(shí)間
String message = "Server Time:" + now;
System.out.println( message );
eventBuilder = new OutboundEvent.Builder();
eventBuilder.id(now);
eventBuilder.name("message");
eventBuilder.data(String.class,
message ); // 推送服務(wù)器時(shí)間的信息給客戶端
event = eventBuilder.build();
try {
eventOutput.write(event);
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
eventOutput.close();
return eventOutput;
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
上面的代碼定義了資源部署在 URI "see-events"。這個(gè)資源有一個(gè) @GET 資源方法返回作為一個(gè)實(shí)體 EventOutput ——通用 Jersey ChunkedOutput API 的擴(kuò)展用于輸出分塊消息處理。
//判斷瀏覽器是否支持 EventSource
if (typeof (EventSource) !== "undefined") {
var source = new EventSource("webapi/see-events");
// 當(dāng)通往服務(wù)器的連接被打開(kāi)
source.onopen = function(event) {
console.log("連接開(kāi)啟!");
};
// 當(dāng)接收到消息。只能是事件名稱是 message
source.onmessage = function(event) {
console.log(event.data);
var data = event.data;
var lastEventId = event.lastEventId;
document.getElementById("x").innerHTML += "\n" + 'lastEventId:'+lastEventId+';data:'+data;
};
//可以是任意命名的事件名稱
/*
source.addEventListener('message', function(event) {
console.log(event.data);
var data = event.data;
var lastEventId = event.lastEventId;
document.getElementById("x").innerHTML += "\n" + 'lastEventId:'+lastEventId+';data:'+data;
});
*/
// 當(dāng)錯(cuò)誤發(fā)生
source.onerror = function(event) {
console.log("連接錯(cuò)誤!");
};
} else {
document.getElementById("result").innerHTML = "Sorry, your browser does not support server-sent events..."
}
首先要判斷瀏覽器是否支持 EventSource,而后,EventSource 對(duì)象分別監(jiān)聽(tīng) onopen、onmessage、onerror 事件。其中, source.onmessage = function(event) {}
和 source.addEventListener('message', function(event) {}
是一樣的,區(qū)別是,后者可以支持監(jiān)聽(tīng)不同名稱的事件,而 onmessage 屬性只支持一個(gè)事件處理方法。。
運(yùn)行項(xiàng)目
mvn jetty:run
瀏覽器訪問(wèn) http://localhost:8080
@Singleton
@Path("sse-chat")
public class SseChatResource {
private SseBroadcaster broadcaster = new SseBroadcaster();
/**
* 提供 SSE 事件輸出通道的資源方法
* @return eventOutput
*/
@GET
@Produces(SseFeature.SERVER_SENT_EVENTS)
public EventOutput listenToBroadcast() {
EventOutput eventOutput = new EventOutput();
this.broadcaster.add(eventOutput);
return eventOutput;
}
/**
* 提供 寫(xiě)入 SSE 事件通道的資源方法
* @param message
* @param name
*/
@POST
@Produces(MediaType.TEXT_PLAIN)
public void broadcastMessage(@DefaultValue("waylau.com") @QueryParam("message") String message,
@DefaultValue("waylau") @QueryParam("name") String name) {
SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");//設(shè)置日期格式
String now = df.format(new Date()); //獲取當(dāng)前系統(tǒng)時(shí)間
message = now +":"+ name +":"+ message; // 發(fā)送的消息帶上當(dāng)前的時(shí)間
OutboundEvent.Builder eventBuilder = new OutboundEvent.Builder();
OutboundEvent event = eventBuilder.name("message")
.mediaType(MediaType.TEXT_PLAIN_TYPE)
.data(String.class, message)
.build();
// 發(fā)送廣播
broadcaster.broadcast(event);
}
}
其中,SseChatResource 資源類用 @Singleton 注解,告訴 Jersey 運(yùn)行時(shí),資源類只有一個(gè)實(shí)例,用于所有傳入/sse-chat
路徑的請(qǐng)求。應(yīng)用程序引用私有的 broadcaster 字段,這樣我們?yōu)樗姓?qǐng)求可以使用相同的實(shí)例。客戶端想監(jiān)聽(tīng) SSE 事件,先發(fā)送 GET 請(qǐng)求到sse-chat
的 listenToBroadcast() 資源方法處理。方法創(chuàng)建一個(gè)新的 EventOutput 用于展示請(qǐng)求的客戶端的連接,并通過(guò) add(EventOutput) 注冊(cè) eventOutput 實(shí)例到單例 broadcaster。方法返回 eventOutput 導(dǎo)致 Jersey 使請(qǐng)求的客戶端事件與 eventOutput 實(shí)例綁定,向客戶機(jī)發(fā)送響應(yīng) HTTP 頭??蛻舳诉B接保持開(kāi)放,客戶端等待準(zhǔn)備接收新的 SSE 事件。所有的事件通過(guò) broadcaster 寫(xiě)入 eventOutput。這樣開(kāi)發(fā)人員可以方便地處理發(fā)送新的事件到所有訂閱的客戶端。
當(dāng)客戶端想要廣播新消息給所有的已經(jīng)監(jiān)聽(tīng) SSE 連接的客戶端時(shí),它先發(fā)送一個(gè) POST 請(qǐng)求將消息內(nèi)容發(fā)到 SseChatResource 資源。 SseChatResource 資源調(diào)用方法 broadcastMessage,消息內(nèi)容作為輸入?yún)?shù)。一個(gè)新的 SSE 出站事件是建立在標(biāo)準(zhǔn)方法上并傳遞給 broadcaster。broadcaster 內(nèi)部在所有注冊(cè)了的 EventOutput 上調(diào)用 write(OutboundEvent) 。當(dāng)該方法只返回一個(gè)標(biāo)準(zhǔn)文本響應(yīng)給客戶端,來(lái)通知客戶端已經(jīng)成功廣播了消息。正如您可以看到的, broadcastMessage 資源方法只是一個(gè)簡(jiǎn)單的 JAX-RS 資源的方法。
您可能已經(jīng)注意到,Jersey SseBroadcaster 完成該用例不是強(qiáng)制性的。每個(gè) EventOutput 可以只是存儲(chǔ)在收集器里,在 broadcastMessage 方法里面迭代。然而,SseBroadcaster 內(nèi)部會(huì)識(shí)別和處理客戶端斷開(kāi)連接。當(dāng)客戶端關(guān)閉了連接,broadcaster 可檢測(cè)并刪除過(guò)期的在內(nèi)部收集器里面注冊(cè)了 EventOutput 的連接,以及釋放所有服務(wù)器端關(guān)聯(lián)了陳舊連接的資源。此外,SseBroadcaster 的實(shí)現(xiàn)是線程安全的,這樣客戶端可以在任何時(shí)間連接和斷開(kāi), SseBroadcaster 總是廣播消息給最近收集的注冊(cè)和活躍的客戶端。
//判斷瀏覽器是否支持 EventSource
if (typeof (EventSource) !== "undefined") {
var source = new EventSource("webapi/sse-chat");
// 當(dāng)通往服務(wù)器的連接被打開(kāi)
source.onopen = function(event) {
var ta = document.getElementById('response_text');
ta.value = '連接開(kāi)啟!';
};
// 當(dāng)接收到消息。只能是事件名稱是 message
source.onmessage = function(event) {
var ta = document.getElementById('response_text');
ta.value = ta.value + '\n' + event.data;
};
//可以是任意命名的事件名稱
/*
source.addEventListener('message', function(event) {
var ta = document.getElementById('response_text');
ta.value = ta.value + '\n' + event.data;
});
*/
// 當(dāng)錯(cuò)誤發(fā)生
source.onerror = function(event) {
var ta = document.getElementById('response_text');
ta.value = ta.value + '\n' + "連接出錯(cuò)!";
};
} else {
alert("Sorry, your browser does not support server-sent events");
}
function send(message) {
var xmlhttp;
var name = document.getElementById('name_id').value;
if (window.XMLHttpRequest)
{// code for IE7+, Firefox, Chrome, Opera, Safari
xmlhttp=new XMLHttpRequest();
}
else
{// code for IE6, IE5
xmlhttp=new ActiveXObject("Microsoft.XMLHTTP");
}
xmlhttp.open("POST","webapi/sse-chat?message=" + message +'&name=' + name ,true);
xmlhttp.send();
}
EventSource 的用法與發(fā)布-訂閱模式類似。而 send(message) 方法是將消息以 POST 請(qǐng)求發(fā)送給服務(wù)端,而后將該消息進(jìn)行廣播,從而達(dá)到了聊天室的效果。
報(bào)如下錯(cuò)誤:
八月 18, 2015 7:48:28 下午 org.glassfish.jersey.servlet.internal.ResponseWriter suspend
WARNING: Attempt to put servlet request into asynchronous mode has failed. Please check your servlet configuration - all Servlet instances and Servlet filters involved in the request processing must explicitly declare support for asynchronous request processing.
java.lang.IllegalStateException: !asyncSupported
at org.eclipse.jetty.server.Request.startAsync(Request.java:2072)
at org.glassfish.jersey.servlet.async.AsyncContextDelegateProviderImpl$ExtensionImpl.getAsyncContext(AsyncContextDelegateProviderImpl.java:112)
at org.glassfish.jersey.servlet.async.AsyncContextDelegateProviderImpl$ExtensionImpl.suspend(AsyncContextDelegateProviderImpl.java:96)
at org.glassfish.jersey.servlet.internal.ResponseWriter.suspend(ResponseWriter.java:121)
at org.glassfish.jersey.server.ServerRuntime$Responder.writeResponse(ServerRuntime.java:747)
at org.glassfish.jersey.server.ServerRuntime$Responder.processResponse(ServerRuntime.java:424)
at org.glassfish.jersey.server.ServerRuntime$Responder.process(ServerRuntime.java:414)
at org.glassfish.jersey.server.ServerRuntime$2.run(ServerRuntime.java:312)
at org.glassfish.jersey.internal.Errors$1.call(Errors.java:271)
at org.glassfish.jersey.internal.Errors$1.call(Errors.java:267)
at org.glassfish.jersey.internal.Errors.process(Errors.java:315)
at org.glassfish.jersey.internal.Errors.process(Errors.java:297)
at org.glassfish.jersey.internal.Errors.process(Errors.java:267)
at org.glassfish.jersey.process.internal.RequestScope.runInScope(RequestScope.java:317)
at org.glassfish.jersey.server.ServerRuntime.process(ServerRuntime.java:292)
at org.glassfish.jersey.server.ApplicationHandler.handle(ApplicationHandler.java:1139)
at org.glassfish.jersey.servlet.WebComponent.service(WebComponent.java:460)
at org.glassfish.jersey.servlet.ServletContainer.service(ServletContainer.java:386)
at org.glassfish.jersey.servlet.ServletContainer.service(ServletContainer.java:334)
at org.glassfish.jersey.servlet.ServletContainer.service(ServletContainer.java:221)
at org.eclipse.jetty.servlet.ServletHolder.handle(ServletHolder.java:808)
at org.eclipse.jetty.servlet.ServletHandler.doHandle(ServletHandler.java:587)
at org.eclipse.jetty.server.handler.ScopedHandler.handle(ScopedHandler.java:143)
at org.eclipse.jetty.security.SecurityHandler.handle(SecurityHandler.java:577)
at org.eclipse.jetty.server.session.SessionHandler.doHandle(SessionHandler.java:223)
at org.eclipse.jetty.server.handler.ContextHandler.doHandle(ContextHandler.java:1127)
at org.eclipse.jetty.servlet.ServletHandler.doScope(ServletHandler.java:515)
at org.eclipse.jetty.server.session.SessionHandler.doScope(SessionHandler.java:185)
at org.eclipse.jetty.server.handler.ContextHandler.doScope(ContextHandler.java:1061)
at org.eclipse.jetty.server.handler.ScopedHandler.handle(ScopedHandler.java:141)
at org.eclipse.jetty.server.handler.ContextHandlerCollection.handle(ContextHandlerCollection.java:215)
at org.eclipse.jetty.server.handler.HandlerCollection.handle(HandlerCollection.java:110)
at org.eclipse.jetty.server.handler.HandlerWrapper.handle(HandlerWrapper.java:97)
at org.eclipse.jetty.server.Server.handle(Server.java:497)
at org.eclipse.jetty.server.HttpChannel.handle(HttpChannel.java:310)
at org.eclipse.jetty.server.HttpConnection.onFillable(HttpConnection.java:257)
at org.eclipse.jetty.io.AbstractConnection$2.run(AbstractConnection.java:540)
at org.eclipse.jetty.util.thread.QueuedThreadPool.runJob(QueuedThreadPool.java:635)
at org.eclipse.jetty.util.thread.QueuedThreadPool$3.run(QueuedThreadPool.java:555)
at java.lang.Thread.run(Thread.java:722)
是指服務(wù)器不支持異步請(qǐng)求。解決方法是在 web.xml 中添加
<async-supported>true</async-supported>
最后的 web.xml 為:
<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd"
id="WebApp_ID" version="3.1">
<servlet>
<servlet-name>Jersey Web Application</servlet-name>
<servlet-class>org.glassfish.jersey.servlet.ServletContainer</servlet-class>
<init-param>
<param-name>javax.ws.rs.Application</param-name>
<param-value>com.waylau.rest.RestApplication</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
<async-supported>true</async-supported>
</servlet>
<servlet-mapping>
<servlet-name>Jersey Web Application</servlet-name>
<url-pattern>/webapi/*</url-pattern>
</servlet-mapping>
</web-app>
由于瀏覽器同源策略,凡是發(fā)送請(qǐng)求url的協(xié)議、域名、端口三者之間任意一與當(dāng)前頁(yè)面地址不同即為跨域。
URL | 說(shuō)明 | 是否允許通信 |
---|---|---|
http://www.a.com/a.js http://www.a.com/b.js | 同一域名下 | 允許 |
http://www.a.com/lab/a.js http://www.a.com/script/b.js | 同一域名下不同文件夾 | 允許 |
http://www.a.com:8000/a.js http://www.a.com/b.js | 同一域名,不同端口 | 不允許 |
http://www.a.com/a.js https://www.a.com/b.js | 同一域名,不同協(xié)議 | 不允許 |
http://www.a.com/a.js http://70.32.92.74/b.js | 域名和域名對(duì)應(yīng)ip | 不允許 |
http://www.a.com/a.js http://script.a.com/b.js | 主域相同,子域不同 | 不允許 |
http://www.a.com/a.js http://a.com/b.js | 同一域名,不同二級(jí)域名(同上) | 不允許(cookie這種情況下也不允許訪問(wèn)) |
http://www.cnblogs.com/a.js http://www.a.com/b.js | 不同域名 | 不允許 |
出于安全考慮,默認(rèn)是不允許跨域訪問(wèn)的,會(huì)報(bào)如下異常:
解決是服務(wù)器啟動(dòng) CORS。
先是做一個(gè)過(guò)濾器 CrossDomainFilter.java,將響應(yīng)頭“Access-Control-Allow-Origin”設(shè)置為“*”
@Override
public void filter(ContainerRequestContext requestContext,
ContainerResponseContext responseContext) throws IOException {
// 響應(yīng)頭添加了對(duì)允許訪問(wèn)的域,* 代表是全部域
responseContext.getHeaders().add("Access-Control-Allow-Origin", "*");
}
在 RestApplication 里,注冊(cè)該過(guò)濾器即可。
public class RestApplication extends ResourceConfig {
public RestApplication() {
// 資源類所在的包路徑
packages("com.waylau.rest.resource");
// 注冊(cè) MultiPart
register(MultiPartFeature.class);
// 注冊(cè)CORS過(guò)濾器
register(CrossDomainFilter.class);
}
}
這樣,就能跨域訪問(wèn)了,如下,192.168.11.103 可以訪問(wèn) 192.168.11.125 站下的資源
見(jiàn) sse-real-time-web
項(xiàng)目
更多建議: