對於一般的Web 應用開發,大多數開發人員並不陌生。在Web 應用中,瀏覽器和服務器之間使用的是請求/ 響應的交互模式。瀏覽器發出請求,服務器根據收到的請求來生成相應的響應。瀏覽器再對收到的響應進行處理,展現給用戶。響應的格式可能是HTML、XML 或JSON 等。隨著REST 架構風格和AJAX 的流行,服務器更多地使用JSON 作為響應的數據格式。 Web 應用使用XMLHttpRequest 對象來發送請求,並根據服務器端返回的數據,對頁面的內容進行動態更新。通常來說,用戶在頁面上的操作,比如點擊或移動鼠標,會觸發相應的事件。由XMLHttpRequest 對象來發出請求,得到服務器響應之後進行頁面的局部更新。這種方式的不足之處在於:服務器端產生的數據變化不能及時地通知瀏覽器,而是需要等到下次請求發出時才能被瀏覽器獲取。對於某些對數據實時性要求很高的應用來說,這種延遲是不能接受的。為了滿足這類應用的需求,就需要有某種方式能夠從服務器端推送數據給瀏覽器,以保證服務器端的數據變化可以在第一時間通知給用戶。目前常見的解決辦法有不少,主要可以分成兩類。這兩類方法的區別在於是否基於HTTP 協議來實現。不使用HTTP 協議的做法是使用HTML 5 新增的WebSocket 規範,而使用HTTP 協議的做法則包括簡易輪詢、COMET 技術和本文中要介紹的HTML 5 服務器推送事件。下面會對這幾種技術進行介紹。
簡介在介紹HTML 5 服務器推送事件之前,首先介紹一些上面提到的幾種服務器端數據推送技術。第一種是WebSocket。 WebSocket 規範是HTML 5 中的一個重要組成部分,已經被很多主流瀏覽器所支持,也有不少基於WebSocket 開發的應用。正如名稱所表示的一樣,WebSocket 使用的是套接字連接,基於TCP 協議。使用WebSocket 之後,實際上在服務器端和瀏覽器之間建立一個套接字連接,可以進行雙向的數據傳輸。 WebSocket 的功能是很強大的,使用起來也靈活,可以適用於不同的場景。不過WebSocket 技術也比較複雜,包括服務器端和瀏覽器端的實現都不同於一般的Web 應用。
除了WebSocket 之外,其他的實現方式是基於HTTP 協議來達到實時推送的效果。第一種做法是簡易輪詢,即瀏覽器端定時向服務器端發出請求,來查詢是否有數據更新。這種做法比較簡單,可以在一定程度上解決問題。不過對於輪詢的時間間隔需要進行仔細考慮。輪詢的間隔過長,會導致用戶不能及時接收到更新的數據;輪詢的間隔過短,會導致查詢請求過多,增加服務器端的負擔。
COMET 技術改進了簡易輪詢的缺點,使用的是長輪詢。長輪詢的方式在每次請求時,服務器端會保持該連接在一段時間內處於打開狀態,而不是在響應完成之後就立即關閉。這樣做的好處是在連接處於打開狀態的時間段內,服務器端產生的數據更新可以被及時地返回給瀏覽器。當上一個長連接關閉之後,瀏覽器會立即打開一個新的長連接來繼續請求。不過COMET 技術的實現在服務器端和瀏覽器端都需要第三方庫的支持。綜合比較上面提到的4 種不同的技術,簡易輪詢由於其本身的缺陷,並不推薦使用。 COMET 技術並不是HTML 5 標準的一部分,從兼容標準的角度出發,也不推薦使用。 WebSocket 規範和服務器推送技術都是HTML 5 標準的組成部分,在主流瀏覽器上都提供了原生的支持,是推薦使用的。不過WebSocket 規範更加複雜一些,適用於需要進行複雜雙向數據通訊的場景。對於簡單的服務器數據推送的場景,使用服務器推送事件就足夠了。
在瀏覽器支持方面,服務器推送事件已經在除IE 外的大部分桌面和移動瀏覽器上得到了支持。支持服務器推送事件的瀏覽器及其版本包括:Firefox 6.0+、Chrome 6.0+、Safari 5.0+、Opera 11.0+、iOS Safari 4.0+、Opera Mobile 11.1+、Chrome for Android 25.0+、Firefox for Android 19.0+ 以及Blackberry Browser 7.0+ 等。關於IE 的支持,在下面的章節中有詳細的介紹。
下面對服務器推送事件的規范進行具體的說明。
規範Server-sent Events 規範是HTML 5 規範的一個組成部分,具體的規範文檔見參考資源。該規範比較簡單,主要由兩個部分組成:第一個部分是服務器端與瀏覽器端之間的通訊協議,第二部分則是在瀏覽器端可供JavaScript 使用的EventSource 對象。通訊協議是基於純文本的簡單協議。服務器端的響應的內容類型是text/event-stream。響應文本的內容可以看成是一個事件流,由不同的事件所組成。每個事件由類型和數據兩部分組成,同時每個事件可以有一個可選的標識符。不同事件的內容之間通過僅包含回車符和換行符的空行(/r/n)來分隔。每個事件的數據可能由多行組成。代碼清單1 給出了服務器端響應的示例。
服務器端響應的示例data: first eventdata: second eventid: 100event: myeventdata: third eventid: 101: this is a commentdata: fourth eventdata: fourth event continue
如代碼清單1 所示,每個事件之間通過空行來分隔。對於每一行來說,冒號(:)前面表示的是該行的類型,冒號後面則是對應的值。可能的類型包括:
在上面代碼中,第一個事件只包含數據first event,會產生默認的事件;第二個事件的標識符是100,數據為second event;第三個事件會產生類型為myevent的事件;最後一個事件的數據為fourth event/nfourth event continue。當有多行數據時,實際的數據由每行數據以換行符連接而成。
如果服務器端返回的數據中包含了事件的標識符,瀏覽器會記錄最近一次接收到的事件的標識符。如果與服務器端的連接中斷,當瀏覽器端再次進行連接時,會通過HTTP 頭Last-Event-ID來聲明最後一次接收到的事件的標識符。服務器端可以通過瀏覽器端發送的事件標識符來確定從哪個事件開始來繼續連接。
對於服務器端返回的響應,瀏覽器端需要在JavaScript 中使用EventSource 對象來進行處理。 EventSource 使用的是標準的事件監聽器方式,只需要在對像上添加相應的事件處理方法即可。 EventSource 提供了三個標準事件,如表1 所示。
表1. EventSource 對象提供的標準事件| 名稱 | 說明 | 事件處理方法 |
| open | 當成功與服務器建立連接時產生 | onopen |
| message | 當收到服務器發送的事件時產生 | onmessage |
| error | 當出現錯誤時產生 | onerror |
如之前所述,服務器端可以返回自定義類型的事件。對於這些事件,可以使用addEventListener 方法來添加相應的事件處理方法。代碼清單2 給出了EventSource 對象的使用示例。
EventSource 對象的使用示例var es = new EventSource('events');es.onmessage = function(e) { console.log(e.data);};es.addEventListener('myevent', function(e) { console.log(e. data);});如上所示,在指定URL 創建出EventSource 對象之後,可以通過onmessage 和addEventListener 方法來添加事件處理方法。當服務器端有新的事件產生,相應的事件處理方法會被調用。 EventSource 對象的onmessage 屬性的作用類似於addEventListener( ' message ' ),不過onmessage 屬性只支持一個事件處理方法。在介紹完服務器推送事件的規範內容之後,下面介紹服務器端的實現。
服務器端和瀏覽器端實現從上一節中對通訊協議的描述可以看出,服務器端推送事件是一個比較簡單的協議。服務器端的實現也相對比較簡單,只需要按照協議規定的格式,返迴響應內容即可。在開源社區可以找到各種不同的服務器端技術相對應的實現。自己開發的難度也不大。本文使用Java 作為服務器端的實現語言。相應的實現基於開源的jetty-eventsource-servlet 項目,見參考資源。下面通過一個具體的示例來說明如何使用jetty-eventsource-servlet 項目。示例用來模擬一個物體在某個限定空間中的隨機移動。該物體從一個隨機位置開始,然後從上、下、左和右四個方向中隨機選擇一個方向,並在該方向上移動隨機的距離。服務器端不斷改變該物體的位置,並把位置信息推送給瀏覽器,由瀏覽器來顯示。
服務器端實現服務器端的實現由兩部分組成:一部分是用來產生數據的org.eclipse.jetty.servlets.EventSource 接口的實現,另一部分是作為瀏覽器訪問端點的繼承自org.eclipse.jetty.servlets.EventSourceServlet 類的servlet 實現。下面代碼給出了EventSource 接口的實現類。
EventSource 接口的實現類MovementEventSource
public class MovementEventSource implements EventSource { private int width = 800; private int height = 600; private int stepMax = 5; private int x = 0; private int y = 0; private Random random = new Random(); private Logger logger = Logger .getLogger(getClass().getName()); public MovementEventSource(int width, int height, int stepMax) { this.width = width; this.height = height; this.stepMax = stepMax; this.x = random.nextInt (width); this.y = random.nextInt(height); } @Override public void onOpen(Emitter emitter) throws IOException { query(emitter); //開始生成位置信息} @Override public void onResume(Emitter emitter, String lastEventId) throws IOException { updatePosition(lastEventId); //更新起始位置query(emitter); //開始生成位置信息} //根據Last-Event-Id來更新起始位置private void updatePosition(String id) { if (id != null) { String[] pos = id.split(,); if (pos.length > 1) { int xPos = -1, yPos = -1; try { xPos = Integer.parseInt(pos[0 ], 10); yPos = Integer.parseInt(pos[1], 10); } catch (NumberFormatException e) { } if (isValidMove(xPos, yPos)) { x = xPos; y = yPos; } } } } private void query(Emitter emitter) throws IOException { emitter.comment(Start sending movement information.); while(true) { emitter.comment(); move(); //移動位置String id = String.format(%s,% s, x, y); emitter.id(id); //根據位置生成事件標識符emitter.data(id); //發送位置信息數據try { Thread.sleep(2000); } catch (InterruptedException e) { logger.log(Level.WARNING, / Movement query thread interrupted. Close the connection., e); break; } } emitter.close(); //當循環終止時,關閉連接} @Override public void onClose() { } //獲取下一個合法的移動位置private void move() { while (true) { int[] move = getMove(); int xNext = x + move[0]; int yNext = y + move[1] ; if (isValidMove(xNext, yNext)) { x = xNext; y = yNext; break; } } } //判斷當前的移動位置是否合法private boolean isValidMove(int x, int y) { return x >= 0 && x <= width && y >=0 && y <= height; } //隨機生成下一個移動位置private int[] getMove() { int[] xDir = new int[] {-1, 0, 1, 0 }; int[] yDir = new int[] {0, -1, 0, 1}; int dir = random.nextInt(4); return new int[] {xDir[dir] * random.nextInt(stepMax), / yDir[dir] * random.nextInt(stepMax)}; }}類MovementEventSource 需要實現EventSource 接口的onOpen、onResume 和onClose 方法,其中onOpen 方法在瀏覽器端的連接打開的時候被調用,onResume 方法在瀏覽器端重新建立連接時被調用,onClose 方法則在瀏覽器關閉連接的時候被調用。 onOpen 和onResume 方法都有一個EventSource.Emitter 接口類型的參數,可以用來發送數據。 EventSource.Emitter 接口中包含的方法包括data、event、comment、id 和close 等,分別對應於通訊協議中各種不同類型的事件。而onResume 方法還額外包含一個參數lastEventId,表示通過Last-Event-ID 頭髮送過來的最近一次事件的標識符。
MovementEventSource 類中事件生成的主要邏輯在query 方法中。該方法中包含一個無限循環,每隔2 秒鐘改變一次位置,同時把更新之後的位置通過EventSource.Emitter 接口的data 方法發送給瀏覽器端。每個事件都有對應的標識符,而標識符的值就是位置本身。如果連接斷開之後,瀏覽器重新進行連接,可以從上一次的位置開始繼續移動該物體。
與MovementEventSource 類對應的servlet 實現比較簡單,只需要繼承自EventSourceServlet 類並覆寫newEventSource 方法即可。在newEventSource 方法的實現中,需要返回一個MovementEventSource 類的對象,如下所示。每當瀏覽器端建立連接時,該servlet 會創建一個新的MovementEventSource 類的對象來處理該請求。
servlet 實現類MovementServlet public class MovementServlet extends EventSourceServlet { @Override protected EventSource newEventSource(HttpServletRequest request, String clientId) { return new MovementEventSource(800, 600, 20); } }在服務器端實現中,需要注意的是要添加相應的servlet 過濾器支持。這是jetty-eventsource-servlet 項目所依賴的Jetty Continuations 框架的要求,否則的話會出現錯誤。添加過濾器的方式是在web.xml 文件中添加代碼如下所示的配置內容。
Jetty Continuations 所需servlet 過濾器的配置<filter> <filter-name>continuation</filter-name> <filter-class>org.eclipse.jetty.continuation.ContinuationFilter</filter-class> </filter> <filter-mapping> <filter-name>continuation </filter-name> <url-pattern>/sse/*</url-pattern> </filter-mapping>瀏覽器端實現
瀏覽器端的實現也比較簡單,只需要創建出EventSource 對象,並添加相應的事件處理方法即可。下面代碼給出了相應的實現。在頁面中使用一個方塊表示物體。當接收到新的事件時,根據事件數據中給出的坐標信息,更新方塊在頁面上的位置。
瀏覽器端的實現代碼var es = new EventSource('sse/movement'); es.addEventListener('message', function(e) { var pos = e.data.split(','), x = pos[0], y = pos [1]; $('#box').css({ left : x + 'px', top : y + 'px' }); });在介紹完基本的服務器端和瀏覽器端實現之後,下面介紹比較重要的IE 的支持。
IE 支持使用瀏覽器原生的EventSource 對象的一個比較大的問題是IE 並不提供支持。為了在IE 上提供同樣的支持,一般有兩種辦法。第一種辦法是在其他瀏覽器上使用原生EventSource 對象,而在IE 上則使用簡易輪詢或COMET 技術來實現;另外一種做法是使用polyfill 技術,即使用第三方提供的JavaScript 庫來屏蔽瀏覽器的不同。本文使用的是polyfill 技術,只需要在頁面中加載第三方JavaScript 庫即可。應用本身的瀏覽器端代碼並不需要進行改動。一般推薦使用第二種做法,因為這樣的話,在服務器端只需要使用一種實現技術即可。
在IE 上提供類似原生EventSource 對象的實現並不簡單。理論上來說,只需要通過XMLHttpRequest 對象來獲取服務器端的響應內容,並通過文本解析,就可以提取出相應的事件,並觸發對應的事件處理方法。不過問題在於IE 上的XMLHttpRequest 對象並不支持獲取部分的響應內容。只有在響應完成之後,才能獲取其內容。由於服務器端推送事件使用的是一個長連接。當連接一直處於打開狀態時,通過XMLHttpRequest 對象並不能獲取響應的內容,也就無法觸發對應的事件。更具體的來說,當XMLHttpRequest 對象的readyState 為3(READYSTATE_INTERACTIVE)時,其responseText 屬性是無法獲取的。
為了解決IE 上XMLHttpRequest 對象的問題,就需要使用IE 8 中引入的XDomainRequest 對象。 XDomainRequest 對象的作用是發出跨域的AJAX 請求。 XDomainRequest 對象提供了onprogress 事件。當onprogress 事件發生時,可以通過responseText 屬性來獲取到響應的部分內容。這是XDomainRequest 對象和XMLHttpRequest 對象的最大不同,也是使用XDomainRequest 對象來實現類似原生EventSource 對象的基礎。在使用XDomainRequest 對像打開與服務器端的連接之後,當服務器端有新的數據產生時,可以通過XDomainRequest 對象的onprogress 事件的處理方法來進行處理,對接收到的數據進行解析,根據數據的內容觸發相應的事件。
不過由於XDomainRequest 對象本來的目的是發出跨域AJAX 請求,考慮到跨域訪問的安全性問題,XDomainRequest 對像在使用時的限制也比較嚴格。這些限制會影響到其作為EventSource 對象的實現方式。具體的限制和解決辦法如下所示:
由於XDomainRequest 對象的這些限制,服務器端的實現也需要作出相應的改動。這些改動包括返回Access-Control-Allow-Origin 頭;對於瀏覽器端發送的text/plain類型的參數進行解析;處理請求中包含的用戶認證相關的信息。
本文的示例使用的polyfill 庫是GitHub 上的Yaffle 開發的EventSource 項目,具體的地址見參考資源。在使用該polyfill 庫,並對服務器端的實現進行修改之後,就可以在IE 8 及以上的瀏覽器中使用服務器推送事件。如果需要支持IE 7,則只能使用簡易輪詢或COMET 技術。本文的示例代碼見參考資源。
小結如果需要從服務器端推送數據給瀏覽器,可以使用的基於HTML 5 規範標準的技術包括WebSocket 和服務器推送事件。開發人員可以根據應用的具體需求來選擇合適的技術。如果只是需要從服務器端推送數據,服務器推送事件的規範更加簡單,實現起來更容易。本文對服務器推送事件的規範內容、服務器端和瀏覽器端的實現都進行了詳細的介紹,對如何支持IE 瀏覽器也進行了具體的分析。