使用 WebSockets 雙向推播資料

用 WebSockets 長效性連線見練間單的聊天軟體

Du Spirit
Java Magazine 翻譯系列
20 min readAug 16, 2020

--

Translated from “Pushing Data in Both Directions with WebSockets” by Danny Coward, Java Magazine, January/February 2016, page 47–58. Copyright Oracle Corporation.

在本系列的首部曲中介紹了 WebSockets,基本的 WebSocket 協定給予我們兩種原生的格式可以使用:文字與二進制資料,對於在客戶端與伺服器端交換簡單資訊的應用程式來說相當好用,例如前篇文章的時鐘應用程式,唯一透過 WebSocket 訊息互動機制交換的資料只有從伺服器廣播出去的時間資訊 (文字格式) 以及客戶端用來結束更新的 stop 字串。但很快地,應用程式有更複雜東西透過 WebSocket 連線傳送與接收,會發現需要一個結構存放這些資訊,作為 Java 開發者,我們習慣以物件的形式處理應用程式資料:不論是使用 Java API 標準的類別,或是我們自己建立的類別,這表示當您被 Java WebSocket API 低階的訊息功能困住,和想用物件寫程式而不是用字串或位元陣列時,您需要寫程式將字串或位元陣列轉換成您的物件,反之亦然,我們來看怎麼做吧!

幸運地,Java WebSocket API 支援將物件編碼成 WebSocket 訊息與從 WebSocket 訊息解碼回物件的任務,首先,Java WebSocket API 試圖將訊息轉成您請求的 Java 基礎型別(或等效的類別),這意味您可以宣告一個訊息處理函式像這樣

或這樣

然後Java WebSocket會試圖將傳進來的訊息轉成宣告的參數型別[譯註:intBoolean]。

相同地,RemoteEndpoint.Basic 的傳送函式包含一個通用的函式:

讓您可以傳入任何 Java 基礎型別或等效的類別,Java WebSocket 的實作會為您將值轉換成等效的字串。

這只能讓您到這裡,通常,您想要用更高層次、更結構化的物件來傳遞您應用程式中的訊息,要在訊息處理函式中處理自訂的物件,您須為端點提供一個 WebSocket 的 Decoder 實作,讓執行環境用來將進來的訊息轉換成自訂的物件,要送出自訂的物件,您同樣需要提供 Encoder 的實作,讓執行環境將自訂物件轉換成原生的 WebSocket 訊息,整個流程總結如 Figure 1

Figure 1. Encoders and decoders

Figure 1 的上方呈現端點與客戶端交換字串,下方則是使用編碼器與解碼器將 Foo 物件編成文字訊息與反解的過程。

Java WebSocket API 有一系列的 javax.websocket.Decoderjavax.websocket.Encoder 介面可以選擇,根據您想製作什麼形式的轉換。例如,想實作一個 Decoder 將文字訊息轉換成稱作 Foo 的自訂物件,您可以用 Foo 作為泛型型別實作 Decoder.Text<T> 介面,然後提供這個函式的實作 [譯註:原文 48 頁的程式是 sendObject,但明顯怪怪的,所以查了一下 API 文件然後更正]:

這任勞任怨的解碼器函式會在每次有新的文字訊息送進來時被呼叫,將訊息轉成 Foo 型別的實體,然後執行環境會將這實體送進端點的訊息處理函式中。其他 Decoder 類別可用來轉換二進制 WebSocket 訊息,訊息會以同步式 (blocking) I/O 串流的形式送入。

要實作 Encoder 將自訂的類別 Foo 物件轉成 WebSocket 文字訊息,可以用 Foo 作為泛型型別實作 Encoder.Text<T> 介面,然後提供這個函式的實作:

如果您呼叫 RemoteEndpointsendObject() 函式 (如前所述) 傳送 Foo 實體,Java WebSocket 執行環境會需要這個編碼器這會將 Foo 實體轉成字串。如同 DecoderEncoder 也有不同型態,可以轉換自訂物件成為二進制訊息,將自訂物件以同步的 (blocking) I/O 串流方式傳送。

如我們所見到的 @ClientEndpoint@ServerEndpoint 定義,如果您想用,這機制相當容易與端點連結,您可以單純在端點的 decoders()encoders() 參數中分別列上您想使用的解碼器與編碼器實作。如果,您為 Java 基礎型別設定客製的編碼器與解碼器,他們將會取代執行環境為這些型別提供的預設編碼器與解碼器,如同您所預期的。

訊息處理模式

到目前為止,我們只討論一次只傳送或接受一整個 WebSocket 訊息,即使多數應用程式因為他們的應用協定指定義小量的訊息,保持用這簡單的模式,但有些應用程式需要處理大量的 WeSocket 訊息,像是傳送圖片或大的文件,Java WebSocket API 提供數種處理模式優雅並有效率地處理大量的訊息。

接收大量訊息 Java WebSocket API 有二種額外的模式用來接收訊息,適用在當您知道會是大量訊息的情境。第一種模式讓端點直接處理同步 I/O API,因此可以用 java.io.Reader 接收文字訊息或用 java.io.InputStream 接收二進制訊息。使用這模式,訊息處理函式參數不再是 StringByteBuffer,您將使用 ReaderInputStream ,例如:

第二種模式提供一種基本切割 API,WebSocket 的訊息以小片段搭配一個 boolean 旗標傳給訊息處理函式,用旗標識別後續是否還有其他片段已完成整個訊息,當然,訊息片段會以既定的順序抵達,也不會混入其他訊息的片段。使用這種模式,訊息處理還是多一個 boolean 參數,例如:

在這模式,每個片段的大小取決於幾個傳訊訊息的對象與 Java WebSocket 執行環境設定等相關因素,只需知道您會以多個片段的方式收到完整的訊息。

傳送訊息的模式,如您可能預期的,WebSocket 協定的對稱性,Java WebSocket API 有相同模式適合用來傳送大量的訊息。除了如前所見一次傳送一整個訊息,您也可以用同步式 (blocking) 的I/O串流來傳送訊息,用 java.io.Writerjava.io.OutputStream 傳送文字訊息或二進制內容。當然,可以從 RemoteEndpoint.Basic 介面取得的 Session 物件獲得額外的函式:

第二種模式是切割模式,但相反地,是用來傳送。同樣,一個端點能用 RemoteEndpoint.Basic 的以下函式以此模式傳送訊息:

根據您希望傳送的訊息類型。

非同步式傳送訊息,WebSocket 的訊息送達通知總是非同步的,一個端點通常不會知道訊息甚麼時候送達,訊息總是在另一端選擇時出現。到目前為止,RemoteEndpoint.Basic 介面中我們已經見過的所有用來傳送訊息的函式都是同步式傳送,簡單來說,這意味著 send() 函式的執行會等到訊息確實送達。這對小訊息很合適,但如果訊息量很大,WebSocket 可做更好的事情而不是在等待訊息傳送完畢,像是傳送訊息給其他人、更新使用者介面或專注在處理更多傳送進來的訊息。針對這樣的端點,從 Session 物件取得 RemoteEndpoint.Async,如同 RemoteEndpoint.Basic,有不少種 send() 函式將一整個訊息作為參數(有不同型式),在訊息實際傳送前,它們會立即回傳。例如,當傳送一則大量的文字訊息您可以使用:

這函式立即回傳,作為第二個參數的 SendHandler 會收到通知當訊息實際被傳送。如此,您會知道訊息被送出,但您不需要等待它確實完成。或者,您想周期性地檢查非同步傳送的進度,例如可以選擇此函式:

在這情況,函式在訊息傳送前立即回傳,您可以對回傳的 Future 查詢訊息傳送的狀態,甚至如果你改變主意,可以取消傳送。當然,如您預期,有相同的函式傳送二進制訊息。

在我們結束 Java WebSocket API 的主題前,有一點值得提出來:WebSocket 協定本身並沒有保證送達的概念,換句話說,當您傳送一則訊息,您不知道客戶端是否確實收到,如果,您在錯誤處理函式中收到一個錯誤,它通常是一個訊息沒有被完整送達的明確信號,但如果沒有錯誤,訊息仍然可能被完整送達。您有可能需要用 Java WebSocket 建立互動,對重要的訊息,另一端需要送回一個確認通知,但是,不像其他傳訊協定,像是 JMS,本身沒有任何送達的保證 [譯註:WebSocket 沒有]。

路徑對應

在時鐘的例子中,只有一個端點,對應到整個 Web 應用程式 URI 空間中單一個相對的 URI,客戶端用一個 URL,用應用程式的 URI 加上此端點的 URI 連到此端點,這正是一個 Java WebSocket API 路徑對應的例子。一般來說,一個端點可以從像這樣的 URL 存取

<ws or wss>://<hostname>:<port>/<web-app-context-path>/<websocket-path>?<query-string>

其中,<websocket-path>@ServerEndpoint annotation 的屬性,而 <query-string> 是選擇性的查詢字串。當 <websocket-path> 是一個 URI,如 ClockServer 端點般,只有用此 URI 發出請求才會連到此端點。

Java WebSocket API 能讓伺服器端的端點對應到 URI 範本,URI 範本是一種別緻的方式讓 URI 可以有幾個片段可以用變數替換,例如:

/airlines/{service-class}

是一個 URI 範本,有一個變數稱作 service-class。

Java WebSocket API 允許 URI 的請求對應到一個 URI 範本,如果此請求 URI合乎該 URI 範本,例如:

/airlines/coach
/airlines/first
/airlines/business

都合乎 URI 範本。

/airlines/{service-class}

變數 service-class 分別是coachfirstbusiness

在 WebSocket 應用中,URI 範本相當有用,因為範本中的變數可在端點中使用,在伺服器端中任何生命週期處理函式中,都可以加任意數量的字串參數加註 @PathParam 取得路徑中的變數,延續這個例子,假設我們有 Listing 1的伺服器端點程式:

Listing 1. 一個訂閱通知的端點

根據客戶端請求的 URI,能提供多種不同層級的服務。

在執行環境中取得路徑資訊,一個端點能在執行環境中完整取得自身的路徑資訊。首先,它總是能取得在 WebSocket 容器中發布的路徑,您能在任何能取得 ServerEndpointConfig 實體的地方使用 ServerEndpointConfig.getPath() 取得這資訊,如 Listing 2 所示。

Listing 2. 端點可以取得自己的路徑對應

這方式同樣適用 URI 路徑對應的端點上 [譯註:指非 URI 範本也能用這方式取得路徑]。

第二種資訊您也許想在執行期間知道的是客戶端是以何 URI 連到此端點,這資訊有幾種形式,我們接下來會看到,但此函是能取得所有資訊:

Session.getRequestURI()

此函是給您相對於伺服器跟路徑的 URI,注意的是,這包含此端點的 Web 應用程式的環境路徑,所以,以訂飯店的例子,若布署到環境路徑是 /customer/services 的 Web 應用程式,則客戶端會以此 URI 連到 HotelBookingService

ws://fun.org/customer/services/travel/hotels/3

則呼叫 getRequestURI() 會得到

/customer/services/travel/hotels/3

Session 另有二個函式解析請求的 URI 取得更進一步的資訊當 URI 包含查詢字串,所以我們看一下查詢字串。

查詢字串與請求參數,如我們之前見到,查詢參數在連到一個 WebSocket 端點的 URL 是選擇性的。

<ws or wss>://<host:name>:<port:>/<web-app-context-path>/<websocket-path>?<query-string>

在 URI 中的查詢字串原先是從 common gateway interface (CGI) 應用程式開始大量使用,URI 路徑中的片段指向 CGI 程式(通常是 /cgi-bin),連接在 URI 之後的查詢字串提供一連串的參數讓 CGI 程式確認請求。查詢字串同樣常用在傳送 HTML 表單的資料,例如,一個網站應用程式的 HTML 程式:

點擊 Submit 按鈕會送出一個 HTTP 請求到以下的 URI:

/form-processor?user=Jared

相對於 HTML 程式頁面的位置並將輸入欄位的文字 Jared 送出。根據 /form-processor 路徑所在的資源特性,查詢字串 user=Jared 可以用來決定回傳何種結果。

例如,假設 form-processor 的資源是一個 Java servlet,可以從 HttpServletRequest 呼叫 getQueryString() 取得查詢字串。

相同的精神,查詢字串可以用在連到使用 Java WebSocket API 建立的 WebSocket 端點的 URI 上,Java WebSocket API 不會用 URI 中的查詢字串作為開啟連線時決定應該連到哪個端點,換句話說,不論 URI 中是否有查詢字串,都不會影響對應到哪個伺服器端端點的發佈路徑,此外,查詢字串在發佈時在路徑中是被忽略的。

就如同 CGI 程式或其他網頁元件,WebSocket 端點可以用查詢字串進一步設定客戶端建立的連線。因為 WebSocket 的實作實際上忽略進來的請求中查詢字串的值,如何使用查詢字串的任何邏輯完全是在 WebSocket 元件中,取得查詢字串的函式主要都在 Session 物件中:

public String getQueryString()

此函式回傳完整的查詢字串 (從 ? 字元開始的全部字串),而此函式:

public Map<String,List<String>> getRequestParameterMap()

可以取得從查詢字串解析後的資料結構包含所有請求參數,注意到從 map 取得的值是一個字串的 list,這是因為二個參數可能有相同的名字但不同的值,例如,您可能用此 URI 連到 HotelBookingService 端點:

ws://fun.org/customer/services/travel/hotels/4?showpics=thumbnails&description=short

在這情況,查詢字串是 showpics=thumbnails&description=short,然後取得請求參數,做某些事情如 Listing 3 所示:

Listing 3. Accessing request parameters

其中 pictureTypetextMode 的值分別會是 thumbnailsshort

您同樣可以從請求的 URI 取得查詢字串,在 Java WebSocket API 中, Session.getRequestURI 的結果總是包含 URI 和查詢字串。

伺服器端點的布署

布署 Java WebSocket 端點到 Java EE 容器遵循簡單的事就是簡單的規則,當您將加註 @ServerEndpoint 的 Java 類別打包成 WAR 檔,實作 Java WebSocket 的容器會掃描 WAR 檔,然後找到所有這樣的類別並布署它們。這意思是您除了將它們打包成 WAR 檔外,不需要做什麼特別的事來布署您的伺服器端點。然而,您也許希望緊緊控制布署 WAR 檔中那些伺服器端點,這情況下,您可以提供一個 javax.websocket.ServerApplicationConfig 介面的實作,讓您過濾那些端點要布署。

聊天應用程式

一個測試推送技術的好方式是建立一個有來自許多用戶端頻繁的非同步更新的應用程式,聊天應用程式正是這樣的案例,讓我們開始看一下如何應用所學的 Java WebSocket API 來建立一個簡單的聊天應用程式。

Figure 2 呈現聊天應用程式的主視窗,當您登入時提示輸入使用者名稱。

Figure 2. 登入開始聊天

幾個人可以同時聊天,在底部的文字輸入框中輸入他們的訊息,點擊送出按鈕,您可以在右側看到目前在線的使用者,然後在中間左側看到每個人的訊息紀錄,在 Figure 3 中,三個人有不太愉快的對話 [譯註:要看圖裡的對話才知道]。

Figure 3. 聊天全程

Figure 4 中,我們可以看到其中一人突然離開,而其他人稍微較優雅地離開,只剩下一個人在聊天室。

Figure 4. 離開聊天室

在我們開始詳細檢視程式前,我們先看如何建構這個應用程式的大藍圖,網頁使用 JavaScript WebSocket 客戶端傳送與接收所有聊天訊息,只有一個 Java WebSocket 端點 ChatServer 在網站伺服器處理所有從多個客戶端送來的聊天訊息、追蹤那些用戶端仍在線上、維護對話紀錄、以及對所有連線的客戶端廣播更新不論是誰加入、離開或是任何人隨時送新的訊息到這群組,這應用程式使用自訂的 WebSocket EncodersDecoders 建立聊天訊息的模型。

我們看一下 Listing 4 中的 ChatServer 端點。[因為長度的關係,程式碼片段可從本期的下載區下載]

這程式中有許多要注意的,首先,這端點對應到相對的 URI:/chat-server,並分別使用 ChatEncoderChatDecoder 作為編碼器與解碼器。

第一次了解 Java WebSocket 最好的方法是觀察生命週期函式,如您所知,就是那些加註 @OnOpen@OnMessage@OnError@OnClose 的函式,我們可以用這方式觀察 ChatServer 類別,首先,當有新的客戶端連到 ChatServer 端點時,會準備一個實體變數參考到對話紀錄 (transcript)、session 和 EndpointConfig。記住,每個連線的客戶端都有一個新的實體,所以,每個聊天室中的成員都會有一個獨自的 chat server 實體。每個 WebSocket 邏輯端點總會有一個 EndpointConfig,所以每個 ChatServer 實體的 endpointConfig 變數指向單一共用的 EndpointConfig 實體。這實體是個 singleton,且它持有一個 map 可以放任一應用程式狀態,因此,它是個存放一個應用程式全域狀態的好地方。每個客戶端連線總會有一個獨立的 session物件,所以每個 ChatServer 實體指向自己的 Session 實體代表連線的客戶端,並與 Listing 5 所列的 Transcript 類別建立關聯:

Listing 5. The Transcript class

我們可以看到每個 EndpointConfig 有一個 transcript 實體,換句話說,只有一個 Transcript 實體,與所有 ChatServer 實體分享,這是好的,因為我們需要這實體顯示團體的聊天訊息訊給所有的客戶端。

ChatServer 最重要的函式是訊息處理函式也就是加註 @OnMessage 的函式,您可以看函式的宣告是處理一個 ChatMessage 物件,而不是文字或二進制 WebSocket 訊息,感謝它所使用的 ChatDecoderChatDecoder 將訊息轉成 ChatMessage 的子類別,為了簡潔,不列出所有 ChatMessage 子類別的程式,Table 1 總結 ChatMessage 的子類別和各別的用途。

Table 1. ChatMessage子類別

現在我們可以簡單地看 ChatServer 的訊息處理函式,每當客戶端有新的動作發生都會呼叫 handleChatMessage() 函式,用來處理新使用者登入、發佈一則聊天訊息與使用者登出的情境。

ChatServer 被告知一則新聊天訊息發佈,隨著程式流程,handleChatMessage() 導向 processChatUpdate() 函式,該函式呼叫 addMessage() 將新的聊天訊息加到共用的 transcript,然後呼叫 Listing 6 中的 broadcastTranscriptUpdate() 函式:

這函式使用非常有用的 Session.getOpenSessions(),允許一個端點實體取得連到該邏輯端點的所有開啟中的連線,在這例子中,這函式用這開啟中的所有連線來廣播有新聊天訊息給所有的客戶端以更新他們的畫面顯示該聊天訊息,注意到,聊天訊息是以 ChatMessage 的形式送出,即 ChatUpdateMessageChatEncoder 會處理將 ChatUpdateMessage 轉成實際被送給客戶端的文字訊息,並將聊天訊息包在裡面。

由於在處理送進來的訊息時,我們沒細看 ChatDecoder,我們暫停一下,看一下於 Listing 7 ChatEncoder 類別。

您可以看到 ChatEncoder 類別被要求實作 Encoder 生命週期函式:init()destroy(),雖然這編碼器在容器呼叫時沒做任何事,但別的編碼器可能選擇在這些生命週期函式中初始化與釋放昂貴的資源,encode()函式才是此類別主要的部份,將訊息實體轉換成可以傳送給客戶端的字串。

現在回到 ChatServer 類別,我們可以看到在 handleChatMessage() 函式中,這端點優雅地處理當客戶端的登出:再關閉連線前,送一個 UserSignoffMessage,這同樣優雅地處理客戶端單方面關閉連線,例如關閉瀏覽器或瀏覽別的網頁。加註 @OnCloseendChatChannel() 函式廣播一個訊息給所有連線的客戶端,告知他們有人不告而別離開聊天室。回頭看截圖,我們可以看到 Jess 和 Rob 離開聊天室的不同。

結論

在這兩回的文章中,我們學會如何建立Java WebSocket端點,我們探討了WebSocket協定的基礎概念,與什麼情境伺服器能推送資料的能力,我們研究Java WebSocket端點的生命週期,試驗了Java WebSocket API的幾個主要類別,也研究了編碼與解碼的技巧,包含Java WebSocket API所支援的各式傳訊息模式。我們觀察伺服器端的端點如何被應對到網站應用程式的URI空間及客戶端得請求如何應對到端點。我們用一個聊天應用程式總結,展現Java WebSocket API的許多特點,在這些知識的幫助下,現在您可以輕易地建立有持續連線的應用程式了。

This article was adapted from the book Java EE 7: The Big Picture with kind permission from the publisher, Oracle Press. [譯註:這我就不翻譯了,應該不影響對本文的了解吧!(笑)]

譯者的告白
老實說,翻這篇文章時,Java Magazine的三月/四月雙月刊已經出來了,最近事情超多,之後真的只能挑自己有興趣的文章翻了,無法整期都翻譯,還是有人想加入共筆呢?

--

--