在講Server-Sent Events (SSE) 之前,我們先來看看 HTTP 請求- 響應(yīng)。一個(gè)標(biāo)準(zhǔn)的 HTTP 請求- 響應(yīng),需要客戶端打開一個(gè)連接,將一個(gè) HTTP 請求(如 HTTP GET 請求)發(fā)送到服務(wù)端,然后接收到 HTTP 回來的響應(yīng),如果該響應(yīng)被完全發(fā)送或者接收,服務(wù)端就會(huì)把連接關(guān)閉。通常是由某個(gè)客戶發(fā)起,客戶端才會(huì)需要請求所有數(shù)據(jù)。
然而, Server-Sent Events (SSE) 與 HTTP 請求- 響應(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ā)送新的請求到服務(wù)端。如果服務(wù)端沒有新的數(shù)據(jù),就發(fā)送適當(dāng)?shù)闹甘静㈥P(guān)閉連接。然后客戶端等待一段時(shí)間后,發(fā)送另一個(gè)請求(例如,一秒后)
用比較籠統(tǒng)的一個(gè)說法,就是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 支持,可見上圖。
與 WebSocket 相比,SSE 有一些顯著的優(yōu)勢。我認(rèn)為它最大的優(yōu)勢就是便利:不需要添加任何新組件,用任何你習(xí)慣的后端語言和框架就能繼續(xù)使用。你不用為新建虛擬機(jī)、弄一個(gè)新的IP或新的端口號(hào)而勞神,就像在現(xiàn)有網(wǎng)站中新增一個(gè)頁面那樣簡單。我喜歡把這稱為既存基礎(chǔ)設(shè)施優(yōu)勢。
SSE 的第二個(gè)優(yōu)勢是服務(wù)端的簡潔。我們將在下節(jié)中看到,服務(wù)端代碼只需幾行。相對而言,WebSocket 則很復(fù)雜,不借助輔助類庫基本搞不定。
因?yàn)?SSE 能在現(xiàn)有的 HTTP/HTTPS 協(xié)議上運(yùn)作,所以它能直接運(yùn)行于現(xiàn)有的代理服務(wù)器和認(rèn)證技術(shù)。而對 WebSocket 而言,代理服務(wù)器需要做一些開發(fā)(或其他工作)才能支持,在寫這本書時(shí),很多服務(wù)器還沒有(雖然這種狀況會(huì)改善)。SSE還有一個(gè)優(yōu)勢:它是一種文本協(xié)議,腳本調(diào)試非常容易。事實(shí)上,在本書中,我們會(huì)在開發(fā)和測試時(shí)用 curl,甚至直接在命令行中運(yùn)行后端腳本。
不過,這就引出了 WebSocket 相較 SSE 的一個(gè)潛在優(yōu)勢:WebSocket 是二進(jìn)制協(xié)議,而 SSE 是文本協(xié)議(通常使用UTF-8編碼)。當(dāng)然,我們可以通過SSE連接傳輸二進(jìn)制數(shù)據(jù):在 SSE 中,只有兩個(gè)具有特殊意義的字符,它們是 CR 和LF,而對它們進(jìn)行轉(zhuǎn)碼并不難。但用 SSE 傳輸二進(jìn)制數(shù)據(jù)時(shí)數(shù)據(jù)會(huì)變大,如果需要從服務(wù)端到客戶端傳輸大量的二進(jìn)制數(shù)據(jù),最好還是用 WebSocket。
WebSocket 相較 SSE 最大的優(yōu)勢在于它是雙向交流的,這意味向服務(wù)端發(fā)送數(shù)據(jù)就像從服務(wù)端接收數(shù)據(jù)一樣簡單。用 SSE時(shí),一般通過一個(gè)獨(dú)立的 Ajax 請求從客戶端向服務(wù)端傳送數(shù)據(jù)。相對于 WebSocket,這樣使用 Ajax 會(huì)增加開銷,但也就多一點(diǎn)點(diǎ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沒什么區(qū)別。它們都用TCP/IP套接字,都是輕量級(jí)協(xié)議。延遲、帶寬、服務(wù)器負(fù)載等都沒有區(qū)別。
在舊版本瀏覽器上的兼容,WebSocket 難兼容,SSE 易兼容。
看了上述的定義,可以知道 SSE 適合應(yīng)用于服務(wù)端單向推送信息到客戶端的場景。 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ù)器的連接被打開
source.onopen = function(event) {
console.log("連接開啟!");
};
// 當(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 對象分別監(jiān)聽 onopen、onmessage、onerror 事件。其中, source.onmessage = function(event) {}
和 source.addEventListener('message', function(event) {}
是一樣的,區(qū)別是,后者可以支持監(jiān)聽不同名稱的事件,而 onmessage 屬性只支持一個(gè)事件處理方法。。
運(yùn)行項(xiàng)目
mvn jetty:run
瀏覽器訪問 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;
}
/**
* 提供 寫入 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
路徑的請求。應(yīng)用程序引用私有的 broadcaster 字段,這樣我們?yōu)樗姓埱罂梢允褂孟嗤膶?shí)例。客戶端想監(jiān)聽 SSE 事件,先發(fā)送 GET 請求到sse-chat
的 listenToBroadcast() 資源方法處理。方法創(chuàng)建一個(gè)新的 EventOutput 用于展示請求的客戶端的連接,并通過 add(EventOutput) 注冊 eventOutput 實(shí)例到單例 broadcaster。方法返回 eventOutput 導(dǎo)致 Jersey 使請求的客戶端事件與 eventOutput 實(shí)例綁定,向客戶機(jī)發(fā)送響應(yīng) HTTP 頭??蛻舳诉B接保持開放,客戶端等待準(zhǔn)備接收新的 SSE 事件。所有的事件通過 broadcaster 寫入 eventOutput。這樣開發(fā)人員可以方便地處理發(fā)送新的事件到所有訂閱的客戶端。
當(dāng)客戶端想要廣播新消息給所有的已經(jīng)監(jiān)聽 SSE 連接的客戶端時(shí),它先發(fā)送一個(gè) POST 請求將消息內(nèi)容發(fā)到 SseChatResource 資源。 SseChatResource 資源調(diào)用方法 broadcastMessage,消息內(nèi)容作為輸入?yún)?shù)。一個(gè)新的 SSE 出站事件是建立在標(biāo)準(zhǔn)方法上并傳遞給 broadcaster。broadcaster 內(nèi)部在所有注冊了的 EventOutput 上調(diào)用 write(OutboundEvent) 。當(dāng)該方法只返回一個(gè)標(biāo)準(zhǔn)文本響應(yīng)給客戶端,來通知客戶端已經(jīng)成功廣播了消息。正如您可以看到的, broadcastMessage 資源方法只是一個(gè)簡單的 JAX-RS 資源的方法。
您可能已經(jīng)注意到,Jersey SseBroadcaster 完成該用例不是強(qiáng)制性的。每個(gè) EventOutput 可以只是存儲(chǔ)在收集器里,在 broadcastMessage 方法里面迭代。然而,SseBroadcaster 內(nèi)部會(huì)識(shí)別和處理客戶端斷開連接。當(dāng)客戶端關(guān)閉了連接,broadcaster 可檢測并刪除過期的在內(nèi)部收集器里面注冊了 EventOutput 的連接,以及釋放所有服務(wù)器端關(guān)聯(lián)了陳舊連接的資源。此外,SseBroadcaster 的實(shí)現(xiàn)是線程安全的,這樣客戶端可以在任何時(shí)間連接和斷開, SseBroadcaster 總是廣播消息給最近收集的注冊和活躍的客戶端。
//判斷瀏覽器是否支持 EventSource
if (typeof (EventSource) !== "undefined") {
var source = new EventSource("webapi/sse-chat");
// 當(dāng)通往服務(wù)器的連接被打開
source.onopen = function(event) {
var ta = document.getElementById('response_text');
ta.value = '連接開啟!';
};
// 當(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 請求發(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ù)器不支持異步請求。解決方法是在 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ā)送請求url的協(xié)議、域名、端口三者之間任意一與當(dāng)前頁面地址不同即為跨域。
URL | 說明 | 是否允許通信 |
---|---|---|
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 | 域名和域名對應(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這種情況下也不允許訪問) |
http://www.cnblogs.com/a.js http://www.a.com/b.js | 不同域名 | 不允許 |
出于安全考慮,默認(rèn)是不允許跨域訪問的,會(huì)報(bào)如下異常:
解決是服務(wù)器啟動(dòng) CORS。
先是做一個(gè)過濾器 CrossDomainFilter.java,將響應(yīng)頭“Access-Control-Allow-Origin”設(shè)置為“*”
@Override
public void filter(ContainerRequestContext requestContext,
ContainerResponseContext responseContext) throws IOException {
// 響應(yīng)頭添加了對允許訪問的域,* 代表是全部域
responseContext.getHeaders().add("Access-Control-Allow-Origin", "*");
}
在 RestApplication 里,注冊該過濾器即可。
public class RestApplication extends ResourceConfig {
public RestApplication() {
// 資源類所在的包路徑
packages("com.waylau.rest.resource");
// 注冊 MultiPart
register(MultiPartFeature.class);
// 注冊CORS過濾器
register(CrossDomainFilter.class);
}
}
這樣,就能跨域訪問了,如下,192.168.11.103 可以訪問 192.168.11.125 站下的資源
見 sse-real-time-web
項(xiàng)目
更多建議: