當今的Web應用在我們的個人生活與商業應用中的各個方面已經表現出愈發重要的作用。這些應用包括社交媒體網絡、在線購物、商業應用,乃至家用電器的配置程序。雖然它的增長勢頭依然迅猛,但Web應用的用戶體驗與原生應用或桌面應用相比仍然相形見絀,其主要原因是Web應用的設計依賴于單向的HTTP協議。而WebSocket將改變這一現狀,它為瀏覽器與服務端的交互帶來了一種新的基礎元素,為創建一種能夠提供真正的交互性體驗的Web應用提供了必要的基礎。
早期的Web技術都是基于HTTP協議而發展起來的,而HTTP只是一個簡單的基于請求 —— 響應操作的協議,所有的請求都是由客戶端發起的。這套框架原本足以滿足用戶的需求,但在如今開發者所設計的web應用中,由客戶端發起通信這種方式有著很大的制約。雖然人們提出了各種臨時方案,但它們都是基于HTTP協議的,只是應用了輪詢或長輪詢技術(例如Comet)。Comet能夠讓負責處理請求的線程得到釋放,以防止服務器資源耗盡。由于輪詢這種機制并不可靠,因此在2007年時,有人提出了一種名為WebSocket的全雙工(full-duplex)類型的通信方式。這項提議用了整整4年的時間才成為一個標準。但是,盡管它已成為一種標準,但它的使用率卻相當有限。本文將為讀者解釋妨礙WebSocket應用的兩大原因,并且提出了一個設計框架,開發者可以使用這套框架快速地發揮WebSocket的潛能,并且極大地豐富應用的體驗。
導致WebSocket使用率低下的第一個原因在于應用服務器與瀏覽器對其支持不足。但隨著新一代應用服務器與瀏覽器的出現,這種狀況得到了很大的改善。而第二個原因比起前一點來說其影響更大,亦即要充分利用WebSocket的全部潛能,必須對Web應用進行顛覆性的重新設計。而這個重新設計過程需要將基礎的請求 —— 響應這一結構轉變為更復雜的雙向消息傳遞結構。應用程序的重新設計往往是一個開銷很大的過程,而軟件供應商很難從這一過程中看到任何顯著的利益。
我們首先將對WebSocket做個簡單的介紹,隨后展示一種使用WebSocket重新構建應用程序的方法,最后通過一個簡單的示例表現這一方法的各種要點。
WebSocket是在TCP/ip協議之上創建的一種幀協議,客戶端將通過向服務器發送一種特殊的HTTP請求以啟動WebSocket。在最初的握手過程之后,客戶端與服務器就能夠自由地以異步方式互相進行幀的傳送了。幀分為兩種類型:即控制幀與數據幀。最小的控制幀僅有2比特的大小,而在數據幀方面,客戶端的數據幀最小為6比特,服務端的數據幀最小為2比特。數據幀既可以是文本型,也可以是二進制的。文本幀都經過了UTF-8的編碼。幀可以實現分塊,因此一個大數據集可以分解為多個幀。WebSocket不會為幀附加任何標識信息,因此不同類型的信息對應的幀不可混用。只有控制幀能夠在處理一個大消息時的一系列中間幀中出現。在這些基礎的幀之上,還可以定義更復雜的協議。比方說,一個幀能夠帶有校驗和或是它的序列號等相關信息。
WebSocket并不限定于僅在某個特定的編程語言、系統或是操作系統中使用。多數主流的編程語言以及許多瀏覽器都已開始支持WebSocket的編程。雖然在不同的平臺與編程語言中存在著大量的標準,但本文僅關注javaScript HTML5以及Java(J2EE)對WebSocket的支持。在瀏覽器這方面有兩種實現標準,其最新版本分別為Hixie-76和HyBi-17(不久之后發展為IETF RFC 6455)。HyBi的實現相對更高級,并且得到了目前所有主流瀏覽器的支持。而在服務端方面,基于Java的實現則是目前最為流行的。早些時候在Java上曾經出現過幾種WebSocket的實現,它們之后已發展為JSR 356這種實現。JSR代表Java規范請求,對規范請求的說明有幫于讓之后的各種實現保持一致性,并且易于使用。JSR也讓開發者不必依賴于某個特定的實現。JSR 356與servlet規范是相互分離的,但它也允許開發者訪問某些servlet對象。JSR 356的內容涵蓋了WebSocket連接的客戶端與服務端, 我們稍后的討論將集中于配合瀏覽器端的Javascript所實現的服務端。JSR 356目前屬于J2EE 7的一部分,所有流行的開源Java應用服務器都支持它,包括Tomcat、Jetty、Glassfish以及TJWS等等。除此之外,在Java環境中還存在著大約20種各自獨立的WebSocket服務端解決方案,其中有些方案也支持JSR 356。由于WebSocket是J2EE 7的一部分,因而在由Oracle與IBM所推出的商業應用服務器上同樣也得到支持。
正如我之前所說,WebSocket是一種消息傳遞協議。它的API提供了各種在通信雙方進行消息傳遞與接收的方法。這里并不存在經典的訂閱者與發布者的關系。消息只有兩種類型,即文本型與二進制型。不過,在這些類型的消息處理函數中可以對消息進行邏輯上的分離。在Java中能夠以某種方式處理被分解為多個塊的部分消息,而JavaScript尚未支持這種程度的控制能力。如同之前所說,WebSocket是一種非常泛用的協議,它可以在握手時指定所需的邏輯子協議。當不同的系統能夠驗證所連到的系統支持這種邏輯子協議及擴展時,使用WebSocket進行系統集成就變得容易很多。WebSocket幀格式允許在它的基礎上使用可協商的擴展,這與意味著一般來說幀可能會提供更多的信息,并且可能會引入不同的幀類型。
由于WebSocket協議的握手過程是由客戶端發起的,因此需要通過包含了WebSocket接口的JavaScript代碼對所有WebSocket操作進行封裝。
該接口已經實現了標準化1,并通過接口定義語言(IDL)進行定義,如以下代碼所示:
[Constructor(in DOMString url, in optional DOMString PRotocols)][Constructor(in DOMString url, in optional DOMString[] protocols)]interface WebSocket { readonly attribute DOMString url; // ready state const unsigned short CONNECTING = 0; const unsigned short OPEN = 1; const unsigned short CLOSING = 2; const unsigned short CLOSED = 3; readonly attribute unsigned short readyState; readonly attribute unsigned long bufferedAmount; // networking attribute Function onopen; attribute Function onmessage; attribute Function onerror; attribute Function onclose; readonly attribute DOMString protocol; void send(in DOMString data); void close();};WebSocket implements EventTarget;
WebSocket的構建函數包含兩個參數:
WebSocket的URL都是以“ws”為前兩個字符,它代表所使用的是WebSocket協議,而其余部分與HTTP協議中的URL相同,包括主機、端口、路徑以及查詢字符串。如果需要使用安全連接,可以在協議名稱上加一個額外的“s”字符。
可以指定的消息處理函數共有四種:onopen、onmessage、onclose和onerror。在傳遞消息時需要調用send方法,而在關閉連接時則需要調用close方法。由于不存在類似于connect這樣的方法,因此客戶端必須監聽onopen消息,以確認連接已建立,隨后才能夠進行send操作。另一種選擇是對WebSocket對象的readyState屬性進行輪詢,但這種方式并不推薦使用。顯然,在onmessage處理函數中總是能夠調用send操作的。send操作由客戶端異步執行,這也意味著JavaScript在將消息傳遞給接收者的過程中無須等待其結果,而是直接返回。文本消息或二進制消息在接收時不存在任何差別,因此在onmessage處理函數中必須對事件的data參數進行檢查。WebSocket提供了一些屬性,可用于獲取狀態、判斷二進制消息的格式等目的。而其它瀏覽器廠商的特定實現中還可以包含更多的屬性,因此請記得仔細閱讀瀏覽器的文檔,以了解詳細的信息。
Java中的JSR 356定義了常見的(客戶端)與服務端的Java WebSocket通信API。在Java的實現中會指定終結點與服務端終結點對象,這與JavaScript中的WebSocket實現頗為類似??梢酝ㄟ^注解的方式將某個Java類指定為一個終結點對象,而通過OnOpen、OnMessage、OnError和 OnClose等注解信息指定事件處理函數。在每種類型的處理函數中,都可以將重要的session對象作為一個傳入參數。Session對象讓開發者能夠訪問發送消息的功能,并且能夠保持與WebSocket連接相關的狀態特性。消息的發送可以使用同步或異步機制,并且在兩種類型的發送機制中都可以指定超時時間。通過指定相應的解碼器,二進制與文本數據都能夠自動轉換為任意的Java對象,而編碼器則允許WebSocket發送任意類型的Java對象。對于某個特定的WebSocket URL路徑,消息處理函數只能對應文本消息類型或二進制消息類型的其中一種。Java中未提供消息鏈的功能,但也可以通過編程的方式對其進行組織。Java端的API很容易上手,它提供了一種可自定義的配置對象,能夠影響最初的握手過程,決定所支持的子協議、版本,并且提供訪問重要的servlet對象API的功能。終結點不僅能夠通過注解的方式進行部署,也能夠通過編程的方式所生成。
WebSocket對于以下類型的應用程序的開發是一種非常自然的選擇:
其實,WebSocket在傳統的Web應用中也能夠展現其優勢。大多數Web應用都是基于請求 - 響應這一范式進行設計的。雖然Ajax能夠實現異步操作,但在繼續處理下一步操作之間,仍然必須等待響應返回。而由于WebSocket連接只需建立一次,從而避免了為每次數據交換重建連接的過程,并且在后續的通信中也無需發送多余的HTTP頭信息。這種優勢在SSL類型的連接上體現得尤為明顯,因為最初的連接握手是一個開銷很大的操作。瀏覽器端的WebSocket發送操作是完全異步的,而Java的服務端代碼在發送消息后無需進行等待。由于發送消息的這種自由度,在應用中或許需要對某些操作進行手動記錄,以保持應用狀態的一致性。在使用WebSocket時也能夠模擬請求 - 響應這一范式,但如此一來,WebSocket作為一種真正的異步雙向消息傳遞系統的優勢也被大大消減了。由于以上所描述的這些特性,因此應當鼓勵開發者在某些場景中對應用程序的設計方式進行重新思考。
假設某一個應用程序包含了復雜的用戶界面,其中某些區域的功能需要通過服務端的大量計算才能夠生成對應的內容。傳統的基于AJAX的實現方式可以選擇一種延遲調用的機制,通過某個內容請求調用以生成這一區域的內容。而在使用WebSocket的場合下,服務端可以在瀏覽器做好準備的情況下直接發送內容,而無需對某個AJAX請求進行響應。AJAX請求這一方式的缺陷在于,由于瀏覽器所發送的請求是串行的,因此服務端的處理過程無法針對請求的順序進行相應的優化。而WebSocket為服務端提供了一個自行決定最佳的內容生成方式的機會,因而能夠提升Web應用的整體響應性。
要用效地利用WebSocket的功能,還需要仔細考慮幾個額外的要點。由于在WebSocket中隨時可能出現網絡連接的丟失,使數據無法正確地傳遞,因此對于一些至關重要的數據需要進行一些額外的手動記錄操作。一般來說,所收到的每條消息都必須提供足夠的信息,以指示如何對其進行處理。但沒有有效的手段能夠了解信息的請求者是誰,是來自客戶端的請求,還是說服務端想要更新某些內容。在具體使用WebSocket的過程中,可能需要對Web應用的設計進行更深入的重新思考。此外,JavaScript代碼的功能可以遷移至服務端,打個比方,用戶的輸入可以立即發送給服務端進行處理,通過這種方式能夠實現一些復雜的數據校驗操作,而這些校驗功能或許是JavaScript所無法處理的。用戶的輸入還能夠即時地保存在后臺系統中,因此瀏覽器就無需將最終的數據傳遞給服務器進行額外的數據校驗,因為數據在保存在后臺期間已經經過了校驗。如果要使某個應用從富Web客戶端轉為一種輕量級的客戶端,就可以考慮以這種方式增加服務端代碼的職責。
在Web應用開發時使用WebSocket也會面對一些特別的挑戰,WebSocket的Session與HTTP的Session之間并無任何關聯,雖然也可將其用作類似的目標。在Session中可以附加某些通用的數據,因此所有的消息處理過程都可以依賴于Session中所維護的某些狀態和數據。WebSocket的Session也可以根據空閑(不活躍)時間間隔的配置產生超時情況,正如HTTP Session一樣。不過有些系統會自動地持續發送Ping這一控制消息,以防止出現超時。JSR 356建議將HTTP Session與WebSocket Session的超時進行同步。一旦HTTP Session超時,在其范圍內所創建的所有WebSocket連接也都必須關閉。但有些Web應用的設計不會產生任何HTTP Session,而有些應用的Session超時不依賴于HTTP Session,而是由JavaScript所管理的,因此這種機制并不能夠進行可靠的推廣。
另一種需要注意的要點在于,某些瀏覽器會維護一個連接池,以重用連接的方式訪問相同的網站,因此這種流程可以被串行化。而如果瀏覽器為WebSocket連接也創建一個連接池,那么它會受到嚴重的制約。因為如果沒有某種機制保持WebSocket連接的關閉,這個連接就永遠處于活躍狀態,而其它任何創建新連接的嘗試都會產生死鎖。因此,最佳實踐的推薦做法是只使用一個WebSocket連接。
瀏覽器無法對通過WebSocket進行傳遞的數據進行緩存,因此通過WebSocket傳遞可以在瀏覽器中緩存的資源
(例如圖片、CSS等)并非一種有效的途徑。
在網絡上對于RESTful與WebSocket之間的討論從未停歇2。不過,這些比較中的大部分都不是在一個層面上的比較,好比關公戰秦瓊。REST是指表述性狀態轉換,多數情況下它需要依賴底層的HTTP協議實現,也就是說REST是一個基于請求 - 響應的協議。REST這種風格沒有經過標準化,因此任何一種通過HTTP進行通信的方式在某些范圍內都可以稱為REST。REST通常會將新增、讀取、更新和刪除操作(CRUD)與對應的HTTP方法PUT、GET、DELETE之間建立映射關系。而WebSocket所處理的是消息,因此對于單一的RPC來說不存在一個確定的范圍。REST的通信數據格式通常僅限JSON格式以及請求參數,而一個WebSocket消息體可以表現為任何類型,包括純粹的二進制數據3。
當然,WebSocket也能夠用于與REST相似的目的,但在大多數情況下,這種做法有些刻意為之了。正如上文所述,在使用WebSocket過程中需要應用一些不同的設計原則。下表描述了這兩者之間的主要區別4。
以下示例展現了如何通過使用WebSocket將一個文件上傳至服務器,首先最好定義一個服務端的終結點。
@ServerEndpoint("/upload/{file}")public class UploadServer {
其中要定義兩個消息處理函數,一個用于接收上傳文件的二進制數據,而另一個則用于命令接口。由于在WebSocket中允許分離文本與二進制消息,因此在定義兩個處理函數時無需進行額外的操作。用于接收命令的OnMessage處理函數定義如下:
@OnMessage public void processCmd(CMD cmd, Session ses) {
static class CMD { public int cmd; public String data; }
為了將文本消息轉換為CMD對象,需要指定一個解碼器,其定義如下:
public static class CmdDecoder implements Decoder.Text<CMD> {
將文本信息編碼為JSON格式并不是一種強制性的要求,只是在這個示例中需要用到JSON。大文件的上傳是分多個塊進行的,以減少內存的開銷。在瀏覽器中無法利用WebSocket的部分幀,因此需要用到完整的幀來模擬塊傳送。由于瀏覽器以異步的方式發送所有的消息,因此無法得知服務端是否已經接收到了一個完整的文件。命令接口的作用是完成以下工作:
同樣的CMD對象可以進行重用,以滿足各種需求。傳入的命令是按照以下方式進行處理的:
@OnMessage public void processCmd(CMD cmd, Session ses) { switch (cmd.cmd) { case 1: // start fileName = cmd.data; break; case 2: // finish close(ses); cmd.cmd = 3; ses.getAsyncRemote().sendObject(cmd); break; } }
這種實現方式假設瀏覽器端會將所有發送消息的活動進行串行化,即所有消息的到達順序與發送順序是一致的。但是如果某個客戶端使用了某些并行方式進行發送,那么就需要一種更為復雜的實現方式,讓每個所發送的消息都帶有一個ID。另一種方案是為每個收到的文件塊都發送一次確認消息,只是這樣一來WebSocket的優勢也就喪失殆盡了。由于CMD對象的目標是將消息發送至客戶端,因此必須提供一個編碼器:
public static class CmdEncoder implements Encoder.Text<CMD> {
在ServerEndpoint的注解中必須指定解碼器與編碼器信息,如下所示:
@ServerEndpoint(value = "/upload/{file}", decoders = UploadServer.CmdDecoder.class, encoders=UploadServer.CmdEncoder.class)public class UploadServer {
二進制消息的處理函數定義如下:
@OnMessagepublic void savePart(byte[] part, Session ses) { if (uploadFile == null) { if (fileName != null) try { uploadFile = new RandomaccessFile(fileName, "rw"); } catch (FileNotFoundException e) { // TODO Auto-generated catch block e.printStackTrace(); return; } } if (uploadFile != null) try { uploadFile.write(part); System.err.printf("Stored part of %db%n", part.length); } catch (IOException e) { // TODO Auto-generated catch block e.printStackTrace(); }}
此外還可以為OnClose事件加入一個處理函數,萬一出現連接異常關閉的情況,它將負責刪除不完整的文件。
客戶端的實現利用了HTML5中的工作線程(Worker)功能,不幸的是,Firefox沒有采用在Worker中實現文件對象克隆的方式,因此這個示例只能在IE或Chrome中進行測試。如果該解決方案對于瀏覽器的可移植性有很高的要求,那么可以用一個不使用Worker的JavaScript代碼段來代替這個基于Worker的解決方案。但由于未使用獨立的線程(即Worker),因此這種方案的性能會有所下降。Worker的代碼如下所示:
var files = [];var endPoint = "ws" + (self.location.protocol == "https:" ? "s" : "") + "://" + self.location.hostname + (self.location.port ? ":" + self.location.port : "") + "/echoserver/upload/*";var socket;var ready;function upload(blobOrFile) { if (ready) socket.send(blobOrFile);}function openSocket() { socket = new WebSocket(endPoint); socket.onmessage = function(event) { self.postMessage(JSON.parse(event.data)); }; socket.onclose = function(event) { ready = false; }; socket.onopen = function() { ready = true; process(); };}function process() { while (files.length > 0) { var blob = files.shift(); socket.send(JSON.stringify({ "cmd" : 1, "data" : blob.name })); const BYTES_PER_CHUNK = 1024 * 1024 * 2; // 1MB chunk sizes. const SIZE = blob.size; var start = 0; var end = BYTES_PER_CHUNK; while (start < SIZE) { if ('mozSlice' in blob) { var chunk = blob.mozSlice(start, end); } else if ('slice' in blob) { var chunk = blob.slice(start, end); } else { var chunk = blob.webkitSlice(start, end); } upload(chunk); start = end; end = start + BYTES_PER_CHUNK; } socket.send(JSON.stringify({ "cmd" : 2, "data" : blob.name })); //self.postMessage(blob.name + " Uploaded Succesfully"); }}self.onmessage = function(e) { for (var j = 0; j < e.data.files.length; j++) files.push(e.data.files[j]); //self.postMessage("Job size: "+files.length); if (ready) { process(); } else openSocket();}
很方便的一點在于,與Worker進行交互的JavaScript代碼也能夠利用消息傳遞機制。當用戶在瀏覽器中選擇文件進行上傳時,這一操作的信息就會傳遞給Worker。后者會以批量的方式處理第一個準備上傳的文件,它將文件分成多個片段,即多個塊,然后通過WebSocket將這些塊依次上傳。最后發送一個cmd = 2的命令消息。而命令消息的處理函數會將消息重新發送給主JavaScript代碼,通知所上傳的文件已經完成了。如果客戶端選擇上傳許多大文件,那么這段代碼會對瀏覽器端帶來相當大的壓力。為此需要對代碼進行重新調整,讓它在收到上一個文件上傳成功的消息后才繼續上傳下一個文件。這部分內容的修改就留給各位讀者作為一個練習吧。在附錄1中可以找到本示例的完整源代碼。
全能程序員交流QQ群290551701,群內程序員都是來自,百度、阿里、京東、小米、去哪兒、餓了嗎、藍港等高級程序員 ,擁有豐富的經驗。加入我們,直線溝通技術大牛,最佳的學習環境,了解業內的一手的資訊。如果你想結實大牛,那 就加入進來,讓大牛帶你超神!
新聞熱點
疑難解答