首部曲:使用 WebSockets 建構應用程式
簡易使用的持續連線API
Translated from “Part 1: Building Apps Using WebSockets — The easy-to-use API for long-lived connections” by Danny Coward, Java Magazine, November/December 2015, page 58. Copyright Oracle Corporation.
不等客戶端請求,就能推播資料給網頁客戶端,因此 Java WebSocket 異於 Java EE 其他網站元件。我在本文中探討 WebSocket 協定,WebSocket 如何運作,以及在一個簡單的專案中如何使用它們,您僅需要對網站應用程式有非常基本的理解及它們如何在 Java EE 上運作就能跟上。
Java WebSocket 從以 HTTP 為基礎的互動模型出發,提供一個方法讓 Java EE 應用程式能以非同步方式更新瀏覽器或非瀏覽器客戶端,長久以來,網站互動模型即是 HTTP 請求/回覆的互動模型,這模型豐富且考量到許多複雜的瀏覽器為基礎的應用程式。但是,每個互動都是由瀏覽器以使用者的某些動作發起,例如載入頁面、更新頁面、點擊一個按鈕,或看某個連結等等。
對許多網站應用程式而言,總是讓使用者主控一切不是令人滿意的。從需要即時市場資訊的金融應用,到全世界的人們對物品出價的拍賣應用,或普通的聊天與監控應用,網站應用程式一直在找尋伺服器端可以推播資料給客戶端的方法,這需求產生點對點機制的混用,但不論是保持 HTTP 持久連線或是客戶端輪詢,都無法對這問題提供一個完整的方案,一個想要新方法的需求引領了 WebSocket 協定的開發。
WebSocket協定簡介
WebSocket 協定以 TCP 協定為基礎,在單一連線上提供全雙工的溝通管道,簡單來說,它使用與 HTTP 相同的底層網路協定,在單一個 WebSocket 連線上的雙方可以同時傳送訊息。WebSocket 定義一個簡單的連線生命週期與資料表達的機制,支援二進制與文字為基礎的訊息。與 HTTP 不同,連線是持續的,這意味著因為不需要不斷地為每次訊息傳輸重新建立連線,所以 WebSocket 協定中資料訊息不用在夾帶關於連線的中介資訊 (meta-information),換句話說,相較 HTTP 需要重建連線與夾帶中介資訊,當連線建立後,訊息傳輸比 HTTP 協定要輕量許多。
但是,相較於建構在 HTTP 之上的輪詢框架 [譯註:可參考拙譯《用非同步Servlets 實現 Long Polling》],這不是 WebSocket 更適合用在推播訊息的主要原因,有一個到客戶端的專屬連線,在本質上讓 WebSockets 成為伺服器更新客戶端更有效率的方式,因為只有當需要時,資料才會被送出。
要了解為什麼?想像一下,一個線上拍賣會有 10 個人在 12 小時中為物品出價,假設平均每個競標者都成功為該物品出價兩次,那物品的價格在拍賣過程中變動 20 次。現在假定競標者必須知道最新的出價資訊,因為您無法知道競標者何時會出價,或是目前最新的價格,因此支援拍賣的網站應用程式需要確保每個客戶端每分鐘能更新一次或甚至更多次,這意味每個客戶端需每小時問 60 次,總共 60 × 10 × 12 = 7,200 次更新,換句話說,需產生 7,200 則更新訊息。
但是,如果伺服器能夠透過 WebSocket 在資料實際有變動時推播資料給客戶端,只需要送 20 則訊息給每個客戶端,總共 20 × 10 = 200 則訊息。在整個應用程式的生命週期中,因為客戶端的數量增加,或是伺服器資料可能變動的時間,您很可能看到相關數字更加發散。WebSocket 提供的伺服器推播模型在本質上是比輪詢機制更有效率。
WebSocket 生命週期
在 WebSocket 協定中,客戶端與伺服器端扮演的角色幾乎是相同的,協定中唯一非對稱 [譯註:antisymmetry 是數學裡的專有名詞,但我覺得這裡是用錯字了,應該是下面提到的非對稱 asymmetric] 的地方是連線建立的初始階段,它在意誰創建連線 [譯註:交握中,只有發起者會帶 Sec-WebSocket-Key,回覆者會用 Sec-WebSocket-Key 的值加上一個 UUID 後,以 Base64 編碼作為 Sec-WebSocket-Accept 回覆給發起者,所以才會這麼關心誰是發起者],這很像打電話,要能打電話,某人必須撥號,然後某人必須接聽,但一旦電話接通了,它不在意誰撥號的。
WebSocket 在 Java EE 平台中,一個 WebSocket 客戶端永遠是瀏覽器或在筆電、智慧型手機或桌機上執行的 rich client [譯註:這硬翻很詭異,簡單說就是可以單獨執行且有漂亮使用者介面的應用程式],然後 WebSocket 伺服器端是在 Java EE 應用伺服器中執行的 Java EE 網站應用程式。
現在來看 WebSocket 連線典型的生命週期,首先,客戶端發起連線請求,客戶端傳送一個特殊格式化的 HTTP 請求到網站伺服器,您不需要瞭解每個交握請求 (handshake request) 的細節,識別 WebSocket 交握請求與一般 HTTP 連線是透過 Connection: Upgrade
和 Upgrade: websocket
標頭,以及最重要資訊的是請求的 URI,/mychat
,如下方所示的交握請求:
GET /mychat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: megachat, chat
Sec-WebSocket-Extensions : compress, mux
Sec—WebSocket-Version: 13
Origin: http://example.com
網站伺服器決定是否支援 WebSockets (所有 Java EE 容器都會做),如果支援,在請求 URI 所指的位置是否有個端點滿足請求的需求,如果都沒問題,支援 WebSocket 的網站伺服器回覆一個特殊格式化的 HTTP 回應,稱作 WebSocket 起始的交握回應:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sM1YUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat
Sec-WebSocket-Extensions: compress, mux
這回應證實伺服器將會接受接下來客戶端的 TCP 連線請求,以及加註連線如何被使用的限制,當客戶端處理完回應,且樂於接受限制,則 TCP 連線就建立了,如 Figure 1 所示,連線的兩端都可能繼續相互傳送訊息。
當連線建立完成,幾種事情可能發生:
- 連線任一端可能傳送訊息給另一端。在連線開啟的狀態下,任何時間點都可能發生,訊息在 WebSocket 協定下有兩種偏好:文字或二進制內容。
- 可能在連線中產生錯誤,在這情況中,假設該錯誤不會導致連線中斷,連線的兩端會被知會,這種非中斷式的錯誤可能發生,例如,交談中的一方傳送一個壞掉的訊息。
- 連線自發性關閉。這指連線的某方認為交談已經結束,所以關閉連線,在連線關閉前,連線另一方會被知會。
Java WebSocket API 概要
Java WebSocket API 提供一組 Java API 類別與 Java annotation [譯註:這次嘗試不翻這個單字,希望文章讀起來不會很破碎] 讓在 Java EE 網站容器中建立 WebSocket 端點變簡單,整體的概念是在實作伺服器端邏輯的類別上加註類別層級的 Java WebSocket API annotation @ServerEndpoint
;接下來,在類別中的函式上加註生命週期相關的 annotation,例如:@OnMessage
,讓討論中的函式充滿特殊的能力:每當有 WebSocket 客戶端送訊息到這個端點時都會被呼叫;接下來,將他打包到 WAR 檔中的 WEB-INF/classes 目錄中,Listing 1 提供一個的例子。
這個 WebSocket 端點被映對到 /echo
這個網站應用程式的 URI 空間,每當有一個 WebSocket 客戶端送一則訊息,它會立即將收到的訊息調整後送回。Java WebSocket API 包含方法攔截所有 WebSocket 生命週期事件,且提供方法能以同步及非同步模式傳送訊息,它能讓您使用編碼器與解碼器類別在 WebSocket 訊息與任何 Java 類別之間轉譯。
Java WebSocket API 同樣提供方法建立 WebSocket 客戶端端點,WebSocket 協定唯一非對稱(asymmetric)的是關心誰建立連線,Java WebSocket API 能讓客戶端連線到伺服器端,所以相當合適用來讓 Java 客戶端連到在 Java EE 網站容器中執行的 WebSocket 端點,事實上,可連到任何 WebSocket 伺服器端點。
在我們看 Java WebSocket 真實的例子前,我們先看一遍 Java WebSocket API的 annotations 和主要類別,別擔心等太久才能開始寫程式,Java WebSocket API 是 Java EE 平台上較小的 API 之一 [譯註:幾年前曾寫過,雖然是用非 JavaEE 平台的 Spark framework,但不可否認,API 確實是蠻簡單的]。
WebSocket Annotation
Java WebSocket annotation 有兩個主要的用途:第一,它們能讓您將任意 Java 類別變成 WebSocket 端點;第二,讓您加註該類別的函式以攔截 WebSocket 端點的生命週期事件。首先,我們先看類別層級的 annotation。
@ServerEndpoint 這是 API 中吃苦耐勞的 annotation,假如您建立許多 WebSocket 端點,你常看到它。這類別層級的 annotation 唯一必填的屬性是 value 屬性 (見Table 1),用來指定 URI 路徑,指向您希望這個端點在網站應用程式中註冊的 URI 空間。
@ServerEndpoint
的屬性@ClientEndpoint 您可以加註 @ClientEndpoint
在您希望成為客戶端端點的類別上,用來建立連線到伺服器端點,它沒有必填屬性,通常用在連線到 Java EE 網站容器的 rich client 應用程式。
@ServerEndpoint 與 @ClientEndpoint 的非必須屬性列於 Table 2,這些類別層級的 annotation 有幾個共用屬性,為所修飾的 WebSocket 端點定義其他組態選項。現在,我們將焦點轉到生命週期的 annotation。
@OnOpen 這函式層級的 annotation 告知 Java EE 網站容器:當有人連到 WebSocket 端點時必須呼叫此函式,這個函式可以不帶參數;或是帶一個非必需的 Session
,其型別為 javax.websocket.Session
代表剛建立的 WebSocket 連線;或一個非必須的組態參數,型別為 javax.websocket.EndpointConfig
代表該端點的組態資訊;或一個非必需的 WebSocket 路徑參數,待會很快會提到。
@OnMessage 這個函式層級的 annotation告知 Java EE 網站容器:當有訊息透過該連線送達時必須呼叫此函式,這函式必須有某些類型的參數列,不過幸運的是,有部分是非必須的。參數列必須包含一個變數持有送進來的訊息,可以包含 Session
及路徑參數,訊息的變數類型有許多選項,包含最常使用的 String
作為文字訊息,及 ByteBuffer
作為二進制訊息。函式可以指定回傳的型別或是 void,如果有回傳型別,Java EE 網站容器會解讀成回傳值就是要立即回送給客戶端的訊息。
@OnError 這個函式層級的 annotation 告知 Java EE 網站容器:當連線發生錯誤時必須呼叫此函式,這函式的參數列中必須有一個 Throwable
參數,也可以有非必須的 Session
參數與路徑參數。
@OnClose 針對 WebSocket 生命週期中的最後一個事件,這個函式層級的 annotation 告知 Java EE 網站容器:當連到此端點的 WebSocket 連線將要終止時必須呼叫此函式,這函式的參數列可以有 Session
參數及路徑參數,如果需要的話,一個 javax.websocket.CloseReason
參數,代表連線將結束的原因說明。
Java WebSocket API 類別
Java WebSocket 開發者會用到最重要的 API 有 Session
、Remote
及WebSocketContainer
介面。
Session Session
物件是一個實際連到此端點的 WebSocket 連線的抽象呈現,它在任何 WebSocket 生命週期處理函式中都是可存取的,它包含連線如何被建立的資訊,例如,另一方是用哪個 URI 建立連線,以及連線若保持閒置多久會逾時。它提供以程式關閉連線的方法。它持有一個映對讓應用程式可用來關聯連線與程式資料,例如,可能是端點從另一方收到訊息的完整副本。雖然和 HttpSession
物件不同,但可比擬成它呈現另一方與存取此 Session
物件的端點之間一連串的互動。此外,它提供存取此端點的 RemoteEndpoint
介面的方式。
RemoteEndpoint 從 Session
物件可以取得 RemoteEndpoint
介面,用來表達該連線的另一端,實際上,當您想送訊息給連線的另一端時可呼叫此物件,RemoteEndpoint
有二種子型別,第一個是 RemoteEndpoint.Basic
,提供所有以同步方式送 WebSocket 訊息的函式,另一個是 RemoteEndpoint.Async
,提供所有以非同步方式送 WebSocket 訊息的函式。許多應用程式只用同步方式送 WebSocket 訊息是因為許多應用程式只有小訊息要送,因此同步與非同步的差異不大。大多數應用程式只送簡單的文字與二進制訊息,所以要知道 RemoteEndpoint.Basic
介面有二個您常會用的函式:
WebSocketContainer 就像 ServletContext
與 Java servlet 的關係,WebSocketContainer
與 Java WebSocket 的關係也是如此,它表達裝載 WebSocket 端點的 Java EE 網站容器,有許多關於 WebSocket 功能性的組態屬性,例如訊息緩衝區的大小及非同步傳送的逾時時間。
開始建造些東西吧:一個 WebSocket 時鐘
我們已經結束 Java WebSocket API 的導覽,因此知道足夠的資訊來看我們的第一個 WebSocket 應用程式。這個時鐘應用程式是一個簡單的網站應用程式,當您執行這應用程式,您會看到 index.html 如 Figure 2 所示的網頁。
當您按下Start按鈕,時鐘從目前時間開始,如Figure 3所示,時間每秒更新。
當您按下 Stop 按鈕,時鐘停止直到您再次啟動它,如 Figure 4 所示。
這應用程式由一個簡單的網頁 (index.html) 與一個稱作 ClockServer
的 Java WebSocket 端點所組成,當 Start 被按下,index.html 用 JavaScript 程式建立 WebSocket 連線到 ClockServer1
端點,它每秒傳送時間更新訊息回給瀏覽器客戶端,JavaScript 程式處理收到的訊息並顯示在網頁上。按下 Stop 讓在 index.html 網頁中的 JavaScript 程式送一個 stop 訊息給 ClockServer
,因此停止傳送時間更新,程式架構如 Figure 5 所示。
我們來看一下程式,首先是客戶端[編按:完整的程式可以從本期的下載區取得],Listing 2 是WebSocket客戶端的程式片段。
這網頁的 HTML 是相對簡單的,注意到 JavaScript 的 WebSocket API 使用完整的 URI 指向 WebSocket 端點,其中 clock-app
是ws://localhost:8080/clock-app/clock
網站應用程式的context路徑 [譯註:將context翻成上下文或是情境,恐怕都不合適,就想成整個環境吧]。
start_clock()
函式完成建立 WebSocket 連線的所有工作,並以 JavaScript 風格加事件處理器,特別是處理收到來自伺服器的訊息。stop_clock()
函式單純傳送 stop 字串給伺服器。
現在將焦點轉向 ClockServer
端點,如 Listing 3 所示。 [編按:同樣,完整的程式可以在本期的下載區取得]
注意到 ClockServer
使用 @ServerEndpoint
宣稱自己是一個 WebSocket 端點,對應到所在網站應用程式 context 相對的 URI /clock。由於 @OnOpen
,每當有新的客戶端連線,startClock()
函式就會被呼叫,完成大部分的工作:建立一個執行緒,使用 Session
物件取得代表客戶端的 RemoteEndpoint
實體的 reference,然後將現在的時間格式化後以文字傳送。如果端點收到一則訊息,它會傳遞給 handleMessage()
函式,因為該函式用 @OnMessage
加註,此函式的 String
參數告知您這端點選擇收到簡單的文字訊息(以最簡單的 Java 字串形式)。這函式回傳一個字串,Java EE 容器將轉成 WebSocket 訊息,並立即送回給客戶端。
會有多少 WebSocket 實體?
即使在這簡單的例子中,一個疑問產生:像 ClockServer
這樣的WebSocket端點類別會有多少個實體產生?答案是每個客戶端連線時,就會有一個WebSocket 端點類別的實體產生,每個客戶端有唯一的端點實體,更進一步,Java EE 網站容器保證,不會有二個 WebSockets 同時送到同一個端點實體。所以,相對於 Java servlet 模型,您在撰寫 WebSocket 程式時,可以知道同時只會有一個執行緒呼叫倒它。
結論
WebSocket 協定給予我們二種原生格式可以使用:文字與二進制,這對單純在客戶端與伺服器端簡單交換訊息的應用程式來說是足夠使用的,例如,我們的時鐘應用程式,透過 WebSocket 訊息互動機制交換的資料只有從伺服器廣播出去的時間資訊 (文字格式) 以及客戶端用來結束更新的 stop
字串。但很快地,應用程式有更複雜東西透過 WebSocket 連線傳送與接收,會發現需要一個結構存放這些資訊,作為 Java 開發者,我們習慣以物件的形式處理應用程式資料:不論是使用 Java API 標準的類別,或是我們自己建立的類別,這表示當您被 Java WebSocket API 低階的訊息功能困住,和想用物件寫程式而不是用字串或位元陣列時,您需要寫程式將字串或位元陣列轉換成您的物件,反之亦然,我將會在本文的第二期討論這個主題[譯註:所以標題才會加上首部曲,第二期請參閱拙譯《使用 WebSockets 雙向推播資料》]。
This article was adapted from the book Java EE 7: The Big Picture with kind permission from the publisher, Oracle Press. The book was reviewed on page 10 of the September/October issue.
LEARN MORE
• Oracle’s Java WebSockets tutorial
譯者的告白
中文不太用子句,所以當遇到英文的子句時,翻譯就有點頭痛,偏偏這位作者非常愛用子句,一個句子中常常用超過二個that或which,讓我思考許久如何翻成「通順的中文句子」,如果有讀起來不通順的地方,不用客氣,歡迎指教。