非同步 Long Polling


HTTP 是基於請求、回應模型,HTTP伺服器無法直接對客戶端(瀏覽器)直接傳送訊息,因為沒有請求就不會有回應。在這種請求、回應模型下,如果客戶端想要獲得伺服端應用程式的最新狀態,必須以定期(或不定期)方式發送請求,查詢伺服端的最新狀態。

持續發送請求以查詢伺服端最新狀態,這種方式的問題在於耗用網路流量,如果多次的請求過程,伺服端應用程式狀態並沒有變化,那這多次的請求所耗用的流量就是浪費的。

一個解決的方式是,伺服端將每次請求的回應延後,直到伺服端應用程式狀態有變化時再行回應,當然如此的話,客戶端將會處於等待回應狀態,如果是瀏覽器,可以搭配 Ajax 非同步請求技術,而使用者將不會因此而被迫停止網頁的操作。然而伺服端延後請求的話,若是 Servlet/JSP 技術,等於該請求佔用容器一個可用的執行緒,若客戶端很多,每個請求都佔用容器的執行緒,將會造成容器無法有效率地接受客戶端請求。

如〈簡介 AsyncContext〉中介紹的,Servlet 3.0 提供的非同步處理技術,可以解決每個請求佔用容器執行緒的問題。

以下是實際的例子,模擬應用程式會不定期產生最新資料,這是實作在 ServletContextListener,在應用程式啟動時進行:

package cc.openhome;

import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.*;
import javax.servlet.*;
import javax.servlet.annotation.WebListener;

@WebListener()
public class WebInitListener implements ServletContextListener {
   // 所有非同步請求的 AsyncContext 將儲存至這個 List
    private List<AsyncContext> asyncs = new ArrayList<>();
    @Override
    public void contextInitialized(ServletContextEvent sce) {
        sce.getServletContext().setAttribute("asyncs", asyncs);

        new Thread(() -> {
                while (true) {
                    try {
                        // 模擬不定時
                        Thread.sleep((int) (Math.random() * 5000));
                        // 隨機產生數字
                        response(Math.random() * 10);
                    } catch (Exception e) {
                        throw new RuntimeException(e);
                    }
                }
            }
        ).start();
    }

    private void response(double num) {
        // 逐一完成非同步請求
        synchronized(asyncs) {
            asyncs.forEach(ctx -> {
                try {
                    ctx.getResponse().getWriter().println(num);
                    ctx.complete();
                } catch (IOException e) {
                    throw new UncheckedIOException(e);
                }
            });
            asyncs.clear();
        }
    }
}

在這個 ServletContextListener 中,有個 List 會儲存所有非同步請求的 AsyncContext,並在產生數字後,逐一對客戶端回應,並呼叫 AsyncContextcomplete() 來完成請求。

負責接受請求的 Servlet,一收到請求,就將之加入 List 之中:

package cc.openhome;

import java.io.*;
import java.util.*;
import javax.servlet.*;
import javax.servlet.annotation.*;
import javax.servlet.http.*;

@WebServlet(
    urlPatterns={"/asyncNumber"},
    asyncSupported = true
)
public class AsyncNumber extends HttpServlet {
    private List<AsyncContext>  asyncs;

    @Override
    public void init() throws ServletException {
        asyncs = (List<AsyncContext>) getServletContext().getAttribute("asyncs");
    }

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
                       throws ServletException, IOException {
        synchronized(asyncs) {
            asyncs.add(request.startAsync());
        }
    } 
}

可以使用一個簡單的 HTML,其中使用 Ajax 技術,發送非同步請求至伺服端,這個請求會被延遲,直到伺服端完成回應後,更新網頁上對應的資料,並再度發送非同步請求:

<!DOCTYPE html>
<html>
  <head>
    <title>即時資料</title>
    <meta charset="UTF-8">
    <script type="text/javascript">
        function asyncUpdate() {
            let request = new XMLHttpRequest();
            request.addEventListener("readystatechange", () => {
                if(request.readyState == 4) {
                    if(request.status == 200) {
                        document.getElementById('data').innerHTML = request.responseText;
                        asyncUpdate();
                    }
                }
            });
            request.open('GET', 'asyncNumber?timestamp=' + new Date().getTime());
            request.send(null);
        }

        window.addEventListener("load", asyncUpdate);
    </script>
  </head>
  <body>
      即時資料: <span id="data">0</span>
  </body>
</html>  

可以試著使用多個瀏覽器視窗來請求這個頁面,你會看到每個瀏覽器視窗的資料都是相同的。