over 2 years ago

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

使用WebSockets雙向推播資料

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

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

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

@OnMessage
public void handleCounter(int newvalue) {...}

或這樣

@OnMessage
public void handleBoolean(Boolean b) {...}

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

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

public void sendObject(Object message) throws IOException, EncodeException

讓您可以傳入任何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>介面,然後提供這個函式的實作:

public void sendObject(Object message) throwsIOException, EncodeException

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

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

public String encode(Foo foo) throws EncodeException

如果您呼叫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,例如:

@OnMessage
public void handleMessageAsStream(InputStream messageStream, Session session) {
    // read from the messageStream

    // until you have consumed the

    // whole binary message

}

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

@OnMessage
public void handleMessageInChunks(String chunk, boolean isLast) {
    // reconstitute the message

    // from the chunks as they arrive

}

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

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

public Writer getSendWriter() throws IOException

public OutputStream getSendStream() throws IOException

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

public void sendText(String partialTextMessage, boolean isLast) throws IOException

public void sentBinary(ByteBuffer partialBinaryMessage, boolean isLast) throws IOException

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

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

public void sendText(String textMessage, SendHandler handler)

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

public Future<Void> sendText(String textMessage)

在這情況,函式在訊息傳送前立即回傳,您可以對回傳的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. 一個訂閱通知的端點
@ServerEndpoint("/air1ines/{service-class}")
public class MyBookingNotifier {

    @OnOpen
    public void initializeUpdates(Session session, @PathParam("service-class") String sClass) {
        if ("first".equals(sClass)) {
            // open champagne

        } else if ("business".equals(sC1ass)) {
            // heated nuts

        } else {
            // don't bang your head on our aircraft

        }
    }
    ...
}

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

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

Listing 2. 端點可以取得自己的路徑對應
@ServerEndpoint("/travel/hotels/{stars}")
public class HotelBookingService {

    public void handleConnection(Session s, EndpointConfig config) {
        String myPath = ((ServerEndpointConfig) config).getPath();
        // myPath is "/travel/hotels/{stars}"

        ...
    }
}

這方式同樣適用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程式:

<form
  name="input"
  action="form-processor" method="get">
  Your Username: <input type="text" name="user">
                 <input type="submit" value="Submit">
</form>

點擊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
@ServerEndpoint("/travel/hotels/{stars}")
public class HotelBookingService2 {

    public void handleConnection(Session session, EndpointConfig config) {
        String pictureType = session.getRequestParameterMap().get("showpics").get(0);
        String textMode = session.getRequestParameterMap().get("description").get(0);
        ...
    }
    ...
}

其中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
import java.util.ArrayList;
import java.util.List;
import javax.websocket.*;

public class Transcript {

    private List<String> messages = new ArrayList<>();
    private List<String> usernames = new ArrayList<>();
    private int maxLines;
    private static String TRANSCRIPT_ATTRIBUTE_NAME = "CHAT_TRANSCRIPT_AN";

    public static Transcript getTranscript(EndpointConfig ec) {
        if (!ec.getUserProperties().containsKey(TRANSCRIPT_ATTRIBUTE_NAME)) {
            ec.getUserProperties().put(TRANSCRIPT_ATTRIBUTE_NAME, new Transcript(20));
            return (Transcript) c.getUserProperties().get(TRANSCRIPT_ATTRIBUTE_NAME);
        }
    }

    Transcript(int maxLines) {
        this.maxLines = maxLines;
    }

    public String getLastUsername() {
        return usernames.get(usernames.size() -1);
    }

    public String getLastMessage() {
        return messages.get(messages.size() -1);
    }

    public void addEntry(string username, String message) {
        if (usernames.size() > maxLines) {
            usernames.remove(0);
            messages.remove(0);
        }
        usernames.add(username);
        messages.add(message);
    }
}

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

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

ChatMessage子類別 用途
ChatUpdateMessage 帶有使用者名稱與該使用者送出的聊天訊息
NewUserMessage 帶有新登入的使用者名稱
UserListUpdateMessage 帶有現在在線上的使用者名稱列表
UserSignoffMessage 帶有離開的使用者名稱

Table 1. ChatMessage子類別

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

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

Listing 6. Broadcasting a new chat message
private void broadcastTranscriptUpdate() {
    for (Session nextsession : session.getOpenSessions()) {
        ChatUpdateMessage cdm = new ChatUpdateMessage(this.transcript.getLastUsername(), this.transcript.getLastMessage());
        try{
            nextsession.getBasicRemote(}.sendObject(cdm};
        } catch (IOException | EncodeException ex) {
            System.out.println("Error updating a client : " + ex.getMessage());
        }
    }
}

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

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

Listing 7. The ChatEncoder class
import java.util.Iterator;
import javax.websocket.EncodeException;
import javax.websocket.Encoder;
import javax.websocket.EndpointConfig;

public class ChatEncoder implements Encoder.Text<ChatMessage> {

    public static final String SEPARATOR = ":";

    @Override
    public void init(EndpointConfig config) {}

    @Override
    public void destroy() {}

    @Override
    public String encode(ChatMessage cm) throws EncodeException {
        if (cm instanceof StructuredMessage) {
            String dataString = "";
            for (Iterator itr = ((StructuredMessage) cm).getList().iterator(); itr.hasNext(); ) {
                dataString = dataString + SEPARATDR +
                itr.next();
            }
            return cm.getType() + dataString;
        } else if (cm instanceof BasicMessage) {
            return cm.getType() + ((BasicMessage) cm).getData();
        } else {
            throw new EncodeException(cm, "Cannot encode messages of type: " + cm.getC1ass());
        }
    }
}

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

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. [譯註:這我就不翻譯了,應該不影響對本文的了解吧!(笑)]

LEARN MORE
Oracle’s Java WebSockets tutorial
Long polling, a WebSocket alternative (拙譯)

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

← 語意的抽象化 Agile is Dead? →