用非同步Servlets實現Long Polling
當其他方法都無法用時可靠的客戶端/伺服器端通訊方式
Translated from “Long Polling with Asynchronous Servlets” by Henry Naftulin, Java Magazine, January/February 2016, page 41–46. Copyright Oracle Corporation.
在不久之前,桌面應用程式與網頁應用程式之間存在一個很大的差距,如果您回到 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 connection 與 thread 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),這訊息將出現在所有輪詢此聊天網址的瀏覽器上。
Listing 1是聊天應用中通訊關鍵的部分程式碼。
這程式例子實作一個非同步的 servlet 處理來自/chatApi
的請求,程式建立一個容器用來儲存多個非同步執行環境,它包含等待聊天訊息的 servlet 環境,所以不是用 servlet 處理執行緒去等待某人送一則訊息,而是建立AsyncContext
實體,然後儲存該實體,等之後某個新訊息來時再處理。
在AsyncChatServletApi
類別中,二個 REST API 會被使用:doGet
及doPost
,doGet
註冊某個使用者要收新訊息,同時,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
或負數(如 Listing 1 所示),伺服器則永遠不會逾時(雖然網頁客戶端會逾時),要在伺服器中處理逾時,可以實作AsyncListener
然後在onTimeout
函式中提供客製化的程式,例如,可以用 Listing 2 的程式改變回覆成為狀態碼 408 (HTTP請求逾時的狀態碼),並帶著 “Request timeout, no chat messages so far, please try again”的訊息給客戶端。
效能
在一般的聊天應用中,所有的客戶端都等待新訊息,同時只有少數客戶端在寫新訊息,因此,針對這應用,專注在當我們的客戶端數量開始增加時會發生什麼事是有意義的,為量測效能,我從 64 個客戶端開始,然後穩定地增加到 2,048 個客戶端,量測得到新訊息的時間,Table 1是測試的結果,Figure 1以線圖的方式呈現結果。
我們的分析不需要知道完成一個操作所需的確切時間,重要的事當客戶端數量增加時的趨勢,在到達 1,024 個客戶端之前,平均的反應時間沒有太大的變化,大概維持在 500 毫秒上下,這是我們的產生訊息的執行緒所花的時間[譯註:沒看到程式,猜測大概是每 500 毫秒產生一則新訊息],當客戶端數量超過 1,024,我們可以看到效能些微下降,主要是因為要花時間處理將訊息推播給客戶端。
另一個有趣的點是這測試說明取回訊息的平均時間略低於訊息產生的頻率,這意味,處理訊息和重新訂閱都需要時間,因此客戶端有機會錯失新訊息。
要加強這方案在不允許錯失訊息的環境中使用,客戶端可以附帶一個標記代表最後一則收到的訊息給伺服器,如果客戶端已經收過最新的訊息,伺服器將讓該執行緒以非同步方式等待新訊息,反之,伺服器將客戶端收到的最後一則訊息之後產生的所有訊息全回給客戶端。
結論
在本文中,我已經解釋什麼是 long polling,以及示範如何使用它。有廣泛的支援,當有需要客戶端與伺服器之間的持續連線,隨時可以使用 long polling。
2020/07/22 補充,即便到了 2020,我覺得 long polling 有時還是不錯的選擇,特別是在 horizontal scale 時,能有更好的平均擴散效果。
譯者的告白
沒想到 2016 January/February雙月刊翻譯的第一篇會是關於自己已經很久沒用過的 Servlet,本來要翻 WebSocket後續的文章,不過那文章有引用此篇文章 (其實有交叉引用),加上這文篇文章寫得很流暢好讀,篇幅又短,很快就翻完了,算是一個好的開始吧!