about 2 years ago

Translated from "Long Polling with Asynchronous Servlets" by Henry Naftulin, Java Magazine, January/February 2016, page 41-46. Copyright Oracle Corporation.

用非同步Servlets實現Long Polling

當其他方法都無法用時可靠的客戶端/伺服器端通訊方式

在不久之前,桌面應用程式與網頁應用程式之間存在一個很大的差距,如果您回到10年前,很明顯桌面應用程式有比較快的回饋,有比較好的使用者介面,整體上提供較好的使用者體驗,網頁應用程式在使用者體驗上比較落後的一個主要原因是因為無法像桌面應用程式那樣快速反應伺服器上狀態的變化,使用者必須更新整個頁面來取得畫面上的新資料。過去,為了媲美桌面應用程式,不少開發網頁應用程式的公司使用不同的策略,像是使用applets、Adobe Flash應用程式、Comet或其他當時受歡迎的框架,也有使用原始的Ajax,但即使使用這些技術,網頁應用程式還是無法在易用性上與桌面軟體媲美。

現在,網頁應用程式被期待是互動的、有漂亮的UI,且能和相似的桌面軟體做相同的事,使用豐富的前端UI框架(像是Bootstrap)及處理框架(例如jQuery和Angular JS),建立好看且能快速通知伺服器(使用者輸入造成)UI變化的應用程式變得更容易。

但如何傳遞伺服器的變化給網頁客戶端呢?畢竟,在使用現在最新的技術,我們習慣且期待幾乎即時的UI回饋以反應伺服器端資訊的變化,在本文中,我探究一個解決方案:long polling [譯註:我實在不想用長輪詢這個詞],以及簡短地看一下其他替代方案。

現在,網頁應用程式被期待是互動的、有漂亮的UI,且能和相似的桌面軟體做相同的事。

Long Polling與其他替代方案

很長一段時間,網頁應用程式以n-tiered (通常是three-tiered)架構開發,在這架構中,客戶端發起請求向伺服器要資料,若客戶端沒有請求更新,伺服器沒有其他的方法可以推送資料給客戶端,但在許多應用中,伺服器上的變化需要在合理的時間內傳遞給客戶端,使用long polling技術以達成這限制。

Long polling是用來推送資料給網頁用戶端的技術,用戶端請求新資訊後,伺服器會保留這請求直到有新資料為止,當伺服器收到新資料,將送資料給客戶端完成客戶端的回覆,此時,伺服器可以保留此連線用來傳送後需的更新給客戶端,或是立即關閉連線,若是後者,一旦客戶端收到伺服器的回覆,連線就被關閉,客戶端立即傳送另一個更新的請求,然後不斷重複整個流程。

Long polling有幾種變形,最簡單的版本是客戶端以一定的週期輪詢伺服器,當收到請求,伺服器立即回覆,可能是傳送當下最新的資料給客戶端,或是告知客戶端目前沒有新資料,這種簡單的輪詢對更新頻率不高或是顯示失時效的資料不是問題的應用程式來說是可行的。另一種版本是本文我要討論的,伺服器會保留客戶端的請求,直到有客戶端要求的資料才回。

幾個long polling主要的替代方案有WebSocket和Server-Sent Events (SSE)。WebSocket是目前廣泛使用的替代方案,它是在單一TCP連線中提供全雙工通訊通道的標準協定,WebSocket其中一個最大的優點是可以大幅減少伺服器與客戶端之間的網路流量,缺點是並非所有的瀏覽器都支援,針對HTTP協定最佳化的舊網路路由器可能會快取或關閉您的WebSocket連線,這是為什麼某些連線函式庫會在支援時升級到WebSocket協定,但若不支援時降回long polling。[編按:在本期的47頁,有一篇文章介紹使用WebSocket完成相似的專案 (拙譯)]

SSE是另一個標準的技術,瀏覽器從HTTP連線接受來自伺服器的主動更新,它是為能高效地推送資料給客戶端所設計,協定有自動重建連線與其他有用的機制,例如追蹤最後一則已收的訊息,同樣的,不是所有的瀏覽器都支援,在這情況下,程式通常降回long polling方案。

因此,long polling依然是主要且可靠的解決方案,了解它是如何運作及如何有效率地實作是很有用的。

在Servlet 3.0之前的Long Polling

在Servlet 3.0標準出來之前,有二個伺服器執行緒模型:thread per connectionthread per request,在thread-per-connection模型中,每個TCP/IP連線會關聯到一個執行緒,若請求都來自同個客戶端,伺服器每秒可以應付相當大量的請求,但是,這模型有個延展性(scalability)的問題,原因是大多數網站,使用者發起一個動作,然後連線幾乎保持閒置的狀態直到使用者讀完頁面決定接下來要做什麼,因此,關聯到此連線的執行緒也閒置。要改善延展性,網站伺服器可以使用thread-per-request模型,在這模型中,在服務完某個請求後,執行緒可以重複使用去服務不同客戶端的請求,每個請求的服務時間會小幅增加的代價下,這模型允許服務更多的使用者,這代價是因為需要做執行緒排程,目前主流的網站伺服器使用thread-per-request模型。

但是,針對long polling,thread per connection與thread per request在延展性上的差異沒這麼明顯,這是因為每個請求必須等到伺服器有資料後才能回覆,在servlet中等待是很沒效率的,因為本來可以服務其他請求的執行緒被凍結,在Servlet 3.0之前,這導致當更多使用者加入時,延展性很差。

Servlet 3.0改變了Long Polling

Servlet 3.0加入了非同步處理 (asynchronous processing),伺服器可以這方式處理請求,特別是需要較長時間的操作,例如遠端呼叫或是等待某個應用程式事件發生才能產生回覆,在Servlet 3.0標準之前,當等待回覆產生時,servlet要凍結對應的執行緒,且會緊抓著有限資源不放。當有非同步處理,我們可以用不同的執行緒去處理請求並發送回覆給使用者,這改變讓原本servlet的請求執行緒不再被凍結,可以回到servlet容器中去服務其他使用者的請求。

Servlet 3.0加入AsyncContext,一個非同步運算的執行環境,來撰寫非同步處理的程式,AsyncContext將servlet的請求與回覆封裝,讓您可以在原有的servlet處理執行緒外使用。要使用AsyncContext,您首先要跟servlet容器表明意圖,例如在servlet的annoation中加入asyncSupported=true,然後,要將一個請求以非同步模式處理,需要在原先的servlet處理函式中建立一個AsyncContext實體,像是用

AsyncContext asyncContext = request.startAsync(request, response);

此刻,非同步請求的處理可以被委派到另一個執行緒或加到某個佇列中,等晚點再處理,因為servlet的請求與回覆被封裝在AsyncContext中,任何執行緒都可以使用,且沒有與原本的servlet執行緒綁在一起,這允許原先的servlet執行緒可以不用等待非同步的回覆完成就能結束目前的呼叫,然後可以服務其他客戶端的請求。

一個非同步Long Polling簡單例子

我們來看一個簡單的網站聊天應用例子,示範servlet非同步處理的優點,在這應用中,使用者在輸入使用者名稱與一則訊息後,按下Send (見Figure 1),這訊息將出現在所有輪詢此聊天網址的瀏覽器上。

Figure 1. Chat app that broadcasts incoming messages

Listing 1是聊天應用中通訊關鍵的部分程式碼。

Listing 1
@WebServlet(urlPatterns="/chatApi", asyncSupported=true, loadOnStartup = 1)
public class AsyncChatServletApi extends HttpServlet {
    ...
    private static final int NUM_WORKER_THREADS = 10;
    Lock lock = new ReentrantLock();
    LinkedList<AsyncContext> asyncContexts = new LinkedList<>();
    private AsyncListener listener = new ChatAsyncListener();

    @Override
    public void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
        AsyncContext asyncContext = request.startAsync(request, response);
        asyncContext.setTimeout(-1);
        asyncContext.addListener(listener);
        try {
            lock.lock();
            asyncContexts.addFirst(asyncContext);
        } finally {
            lock.unlock();
        }
    }

    @Override
    public void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException {
        final String message = getMessage(request);
        Collection<AsyncContext> localCopy;
        try {
            lock.lock();
            localCopy= asyncContexts;
            asyncContexts = new LinkedList<>();
        } finally {
            lock.unlock();
        }
        for(AsyncContext asyncContext : localCopy) {
            Runnable runnable = new RunnableWriter(asyncContext, message);
            executor.execute(runnable);
        }
        localCopy.clear();
    }

    private String getMessage(...) { ... }

    class RunnableWriter implements Runnable {
        private final AsyncContext asyncContext;
        private final String message;
        public RunnableWriter(..) { .. }

        @Override
        public void run() {
            try(PrintWriter writer = asyncContext.getResponse().getWriter()) {
                writer.println(message);
                writer.flush();
                asyncContext.complete();
            } catch(Exception e) {
                ...
            }
        }
    }
}

這程式例子實作一個非同步的servlet處理來自/chatApi的請求,程式建立一個容器用來儲存多個非同步執行環境,它包含等待聊天訊息的servlet環境,所以不是用servlet處理執行緒去等待某人送一則訊息,而是建立AsyncContext實體,然後儲存該實體,等之後某個新訊息來時再處理。

AsyncChatServletApi類別中,二個REST API會被使用:doGet and doPostdoGet註冊某個使用者要收新訊息,同時,doPost推送新訊息給所有等待的客戶端,明確地說,當使用者開啟URL,呼叫下面程式會建立一個非同步執行環境:

AsyncContext asyncContext = request.startAsync(request, response);

到目前為止,非同步執行環境被儲存到一個容器中,這容器代表等待下則訊息的所有客戶端,此時,servlet執行緒已經完成該請求的處理,然後可以被重新利用來處理其他請求。

當使用者觸發doPost請求發送一則訊息,訊息會被接收並寫到每個非同步執行環境中,然後呼叫下列函式結束整個流程:

asyncContext.complete();

由於servlet可被多個執行緒呼叫,因此,處理執行緒必須確保是執行緒安全的,特別是,要注意以下三個情境:

  • 同時處理二個post請求
  • 一個post請求正在處理中,收到一個get請求
  • 同時處理二個get請求

當二個post請求同時發生或一個post請求與一個get請求同時發生,所有等待訊息的客戶端應該收到訊息(即不能掉訊息),這可以將執行環境容器同步複製一份,並重新建立一個容器,以累積新的請求。同步在doGet函式中將一個新元素加到容器中與在doPost中複製一份容器的動作確保上述二個情境的正確性。

當二個get請求同時處理時,雙方的環境必須被儲存等待後續處理,這可以用一個執行緒安全的容器完成,或是,使用同步的程式碼片段來加入某個元素到容器中,就如同Listing 1所示。[譯註:用lock.lock()與lock.unlock()確保容器內容的正確性]

現在,我們來測試一下逾時。如果在非同步執行環境中設定非無限長的逾時時間,當逾時時,客戶端會收到 “Server returned HTTP response code: 500 for URL: http://localhost:8080/chatApi.” 的回覆,若逾時時間沒有明確被設定,請求會直接使用伺服器預設的逾時時間設定,如果逾時時間被設為0或負數(如isting 1所示),伺服器則永遠不會逾時(雖然網頁客戶端會逾時),要在伺服器中處理逾時,可以實作AsyncListener然後在onTimeout函式中提供客製化的程式,例如,可以用Listing 2的程式改變回覆成為狀態碼408 (HTTP請求逾時的狀態碼),並帶著“Request timeout, no chat messages so far, please try again”的訊息給客戶端。

Listing 2.
public class ChatAsyncListener implements AsyncListener {

    @Override
    public void onComplete(AsyncEvent event) { }

    @Override
    public void onTimeout(AsyncEvent event) {
        AsyncContext asyncContext = event.getAsyncContext();
        HttpServletResponse response = (HttpServletResponse)asyncContext.getResponse();
        response.sendError(HttpServletResponse.SC_REQUEST_TIMEOUT, "Request timeout, no chat messages so far," + " please try again.");
        asyncContext.complete();
    }

    @Override
    public void onError(AsyncEvent event) { }

    @Override
    public void onStartAsync(AsyncEvent event) { }
}

效能

在一般的聊天應用中,所有的客戶端都等待新訊息,同時只有少數客戶端在寫新訊息,因此,針對這應用,專注在當我們的客戶端數量開始增加時會發生什麼事是有意義的,為量測效能,我從64個客戶端開始,然後穩定地增加到2,048個客戶端,量測得到新訊息的時間,Table 1是測試的結果,Figure 1以線圖的方式呈現結果。

Number of Clients
64 128 256 512 1,024 2,048
Run #1 Response
STDEV
483.28
1.39
492.63
3.31
521.00
26.01
485.24
10.55
504.31
15.57
616.75
37.35
Run #2 Response
STDEV
499.80
0.98
490.04
3.15
494.45
3.87
501.02
19.84
546.78
26.85
575.85
35.03
Run #3 Response
STDEV
469.90
0.69
495.57
1.53
489.43
6.79
485.57
7.87
544.37
36.46
659.75
29.13

Table 1. 發送訊息給多個客戶端,三次測試的結果(時間單位是毫秒)

Figure 2. 當客戶端數量增加的反應時間

我們的分析不需要知道完成一個操作所需的確切時間,重要的事當客戶端數量增加時的趨勢,在到達1,024個客戶端之前,平均的反應時間沒有太大的變化,大概維持在500毫秒上下,這是我們的產生訊息的執行緒所花的時間[譯註:沒看到程式,猜測大概是每500毫秒產生一則新訊息],當客戶端數量超過1,024,我們可以看到效能些微下降,主要是因為要花時間處理將訊息推播給客戶端。

另一個有趣的點是這測試說明取回訊息的平均時間略低於訊息產生的頻率,這意味,處理訊息和重新訂閱都需要時間,因此客戶端有機會錯失新訊息。

要加強這方案在不允許錯失訊息的環境中使用,客戶端可以附帶一個標記代表最後一則收到的訊息給伺服器,如果客戶端已經收過最新的訊息,伺服器將讓該執行緒以非同步方式等待新訊息,反之,伺服器將客戶端收到的最後一則訊息之後產生的所有訊息全回給客戶端。

結論

在本文中,我已經解釋什麼是long polling,以及示範如何使用它。有廣泛的支援,當有需要客戶端與伺服器之間的持續連線,隨時可以使用long polling。

譯者的告白
沒想到2016 January/February雙月刊翻譯的第一篇會是關於自己已經很久沒用過的Servlet,本來要翻WebSocket後續的文章,不過那文章有引用此篇文章(其實有交叉引用),加上這文篇文章寫得很流暢好讀,篇幅又短,很快就翻完了,算是一個好的開始吧!

← Jython 2.7:結合Python與Java 語意的抽象化 →