over 2 years ago

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.

首部曲:使用WebSockets建構應用程式

簡易使用的持續連線API

不等客戶端請求,就能推播資料給網頁客戶端,因此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之上的輪詢框架,這不是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: UpgradeUpgrade: 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協定下有兩種偏好:文字或二進制內容。
  • 可能在連線中產生錯誤,在這情況中,假設該錯誤不會導致連線中斷,連線的兩端會被知會,這種非中斷式的錯誤可能發生,例如,交談中的一方傳送一個壞掉的訊息。
  • 連線自發性關閉。這指連線的某方認為交談已經結束,所以關閉連線,在連線關閉前,連線另一方會被知會。

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目錄中,List 1提供一個的例子。

Listing 1. The EchoServer sample
import javax.websocket.OnMessage;
import javax.websocket.server.ServerEndpoint;

@ServerEndpoint("/echo")
public class EchoServer {

    @OnMessage
    public String echo (String incomingMessage){
        return "I got this (" + incomingMessage + ")" + " so I am sending it back !";
    }
}

這個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之一。

Java WebSocket API提供一組Java API類別與Java annotation讓在Java EE網站容器中建立WebSocket端點變簡單。

WebSocket Annotation

Java WebSocket annotation有兩個主要的用途:第一,它們能讓您將任意Java類別變成WebSocket端點;第二,讓您加註該類別的函式以攔截WebSocket端點的生命週期事件。首先,我們先看類別層級的annotation。

@ServerEndpoint這是API中吃苦耐勞的annotation,假如您建立許多WebSocket端點,你常看到它。這類別層級的annotation唯一必填的屬性是value屬性(見Table 1),用來指定URI路徑,指向您希望這個端點在網站應用程式中註冊的URI空間。

屬性 功能 必填
value 定義端點所註冊的URI路徑

Table 1. @ServerEndpoint的屬性

@ClientEndpoint您可以加註@ClientEndpoint在您希望成為客戶端端點的類別上,用來建立連線到伺服器端點,它沒有必填屬性,通常用在連線到Java EE網站容器的rich client應用程式。

@ServerEndpoint@ClientEndpoint的非必須屬性列於Table 2,這些類別層級的annotation有幾個共用屬性,為所修飾的WebSocket端點定義其他組態選項。現在,我們將焦點轉到生命週期的annotation。

@ServerEndpoint
@ClientEndpoint 屬性
功能 必填
configurator 開發者能用來動態設定端點的類別名稱
decoders 條列將進來的Websocket訊息轉成對應Java類別的編碼器
encoders 條列將Java類別轉成作為Websocket送出訊息的編碼器
subprotocols 條列端點能支援的副協定,例如"chat"

Table 2. 類別層級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有SessionRemoteWebSocketContainer介面。

Session Session物件是一個實際連到此端點的WebSocket連線的抽象呈現,它在任何WebSocket生命週期處理函式中都是可存取的,它包含連線如何被建立的資訊,例如,另一方是用哪個URI建立連線,以及連線若保持閒置多久會逾時。它提供以程式關閉連線的方法。它持有一個映對讓應用程式可用來關聯連線與程式資料,例如,可能是端點從另一方收到訊息的完整副本。雖然和HttpSession物件不同,但可比擬成它呈現另一方與存取此Session物件的端點之間一連串的互動。此外,它提供存取此端點的RemoteEndpoint介面的方式。

RemoteEndpointSession物件可以取得RemoteEndpoint介面,用來表達該連線的另一端,實際上,當您想送訊息給連線的另一端時可呼叫此物件,RemoteEndpoint有二種子型別,第一個是RemoteEndpoint.Basic,提供所有以同步方式送WebSocket訊息的函式,另一個是RemoteEndpoint.Async,提供所有以非同步方式送WebSocket訊息的函式。許多應用程式只用同步方式送WebSocket訊息是因為許多應用程式只有小訊息要送,因此同步與非同步的差異不大。大多數應用程式只送簡單的文字與二進制訊息,所以要知道RemoteEndpoint.Basic介面有二個您常會用的函式:

public void sendText(String text) throws IOException;
public void sendBinary(ByteBuffer bb) throws IOException;

WebSocketContainer就像ServletContext與Java servlet的關係,WebSocketContainer與Java WebSocket的關係也是如此,它表達裝載WebSocket端點的Java EE網站容器,有許多關於WebSocket功能性的組態屬性,例如訊息緩衝區的大小及非同步傳送的逾時時間。

開始建造些東西吧:一個WebSocket時鐘

我們已經結束Java WebSocket API的導覽,因此知道足夠的資訊來看我們的第一個WebSocket應用程式。這個時鐘應用程式是一個簡單的網站應用程式,當您執行這應用程式,您會看到index.html如Figure 2所示的網頁。

Figure 2. 未啟動的WebSocket時鐘

當您按下Start按鈕,時鐘從目前時間開始,如Figure 3所示,時間每秒更新。

Figure 3. 已啟動的WebSocket時鐘

當您按下Stop按鈕,時鐘停止直到您再次啟動它,如Figure 4所示。

Figure 4. 已停止的WebSocket時鐘

這應用程式由一個簡單的網頁(index.html)與一個稱作ClockServer的Java WebSocket端點所組成,當Start被按下,index.html用JavaScript程式建立WebSocket連線到ClockServer端點,它每秒傳送時間更新訊息回給瀏覽器客戶端,JavaScript程式處理收到的訊息並顯示在網頁上。按下Stop讓在index.html網頁中的JavaScript程式送一個stop訊息給ClockServer,因此停止傳送時間更新,程式架構如Figure 5所示。

Figure 5. 程式架構

我們來看一下程式,首先是客戶端[編按:完整的程式可以從本期的下載區取得],Listing 2是WebSocket客戶端的程式片段。

Listing 2. WebSocket client code (JavaScript)
...
function start_clock() {
    var wsUri = "ws://localhost:8080/clock-app/clock";
    websocket = new webSocket(wsUri);
    websocket.onmessage = function (evt) {
        last_time = evt.data;
        writeToScreen("<span style='color: blue;'>" + last_time + "</span>");
    };
    websocket.onerror = function (evt) {
        writeToScreen ('<span style="color: red;"> ' + 'ERROR:</span> ' + evt.data);
        websocket.close();
    };
}

function stop_c1ock() {
    websocket.send("stop");
}

這網頁的HTML是相對簡單的,注意到JavaScript的WebSocket API使用完整的URI指向WebSocket端點,其中clock-appws://localhost:8080/clock-app/clock網站應用程式的context路徑[譯註:將context翻成上下文或是情境,恐怕都不合適,就想成整個環境吧]。

start_clock()函式完成建立WebSocket連線的所有工作,並以JavaScript風格加事件處理器,特別是處理收到來自伺服器的訊息。stop_clock()函式單純傳送stop字串給伺服器。

現在將焦點轉向ClockServer端點,如Listing 3所示。 [編按:同樣,完整的程式可以在本期的下載區取得]

Listing 3. The server endpoint
...imports...

@ServerEndpoint ("/clock")
public class ClockServer {

    Thread updateThread;
    boolean running = false;

    @OnOpen
    public void startClock(Session session) {
        final Session mySession = session;
        this.running = true;
        final SimpleDateFormat sdf = new SimpleDateFormat("h:mm:ss a");
        this.updateThread = new Thread() {

            public void run() {
                while (running) {
                    String dateString = sdf.format(new Date());
                    try {
                        mySession.getBasicRemote().sendText(dateString);
                        sleep(1000);
                    } catch (IOException | InterruptedException ie) {
                        running = false;
                    }
                }
            }
        };
        this.updateThread.start();
    }

    @OnMessage
    public String handleMessage(String incomingMessage) {
        if ("stop".equals(incomingMessage)) {
            this.stopClock();
            return "clock stopped";
        } else {
            return "unknown message: " + incomingMessage;
        }
    }

    @OnError
    public void clockError(Throwable t) {
        this.stopClock() ;
    }

    @OnClose
    public void stopClock() {
        this.running = false;
        this.updateThread = null;
    }
}

注意到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低階的訊息功能困住,和想用物件寫程式而不是用字串或位元陣列時,您需要寫程式將字串或位元陣列轉換成您的物件,反之亦然,我將會在本文的第二期討論這個主題[譯註:所以標題才會加上首部曲]。

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,讓我思考許久如何翻成「通順的中文句子」,如果有讀起來不通順的地方,不用客氣,歡迎指教。

← JVM如何取得、載入並執行函示庫 應用Docker在Java應用程式上 →