非同步 Server-Sent Event


HTML5 支援 Server-Sent Event,在請求發送至伺服端後,伺服端的回應會一直持續(始終處於「下載」狀態),例如,可以將〈非同步 Long Polling〉中的 HTML 改寫為使用 Server-Sent Event:

<!DOCTYPE html>
<html>
  <head>
    <title>即時資料</title>
    <meta charset="UTF-8">
    <script type="text/javascript">
        window.addEventListener("load", () => {
            new EventSource("asyncNumber")
                     .addEventListener(
                             "message", 
                             e => document.getElementById('data').innerHTML =  e.data
                      );
        });
    </script>
  </head>
  <body>
      即時資料: <span id="data">0</span>
  </body>
</html>

在〈非同步 Long Polling〉中,只是請求被延遲至伺服端有資料,在回應之後當次連線就關閉,客戶端又再次發送請求,重複此過程,如果使用 Server-Sent Event,客戶端只需要發送一次請求,之後伺服端可以在持續的回應中一直輸出,連線也就不會中斷。

因此伺服端會需要一個迴圈之類的重複結構。而為了能儘快讓容器分配的執行緒釋放,可以在非同步 Servlet 中進行,例如:

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 Queue<AsyncContext>  asyncs;

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

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response)
                       throws ServletException, IOException {
        // 必須是 text/event-stream、UTF-8
        response.setContentType("text/event-stream"); 
        response.setHeader("Cache-Control", "no-cache");
        response.setCharacterEncoding("UTF-8");

        AsyncContext ctx = request.startAsync();
        ctx.setTimeout(30 * 1000);

        ctx.addListener(new AsyncListener() {
          @Override
          public void onComplete(AsyncEvent event) throws IOException {
              asyncs.remove(ctx);
          }

          @Override 
          public void onTimeout(AsyncEvent event) throws IOException {
              asyncs.remove(ctx);
          }

          @Override 
          public void onError(AsyncEvent event) throws IOException {
              asyncs.remove(ctx);
          }

          @Override 
          public void onStartAsync(AsyncEvent event) throws IOException {}
        });


        asyncs.add(ctx);
    } 
}

使用 Server-Sent Event 時,回應時的 Content-Type 標頭必須是 text/event-stream,而編碼必須是 UTF-8,而發送的回應,必須有個 data:,而且最後必須有兩個換行 \n\n,例如:

package cc.openhome;

import java.io.IOException;
import java.io.PrintWriter;
import java.io.UncheckedIOException;
import java.util.*;
import java.util.concurrent.ConcurrentLinkedQueue;

import javax.servlet.*;
import javax.servlet.annotation.WebListener;

@WebListener()
public class WebInitListener implements ServletContextListener {
    // 所有非同步請求的  AsyncContext 將儲存至這個  Queue
    private Queue<AsyncContext> asyncs = new ConcurrentLinkedQueue<>();

    @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) {
        // 逐一完成非同步請求
        asyncs.forEach(ctx -> {
            try {
                PrintWriter out = ctx.getResponse().getWriter();
                out.printf("data: %s\n\n", num);
                out.flush();
            } catch (IOException e) {
                throw new UncheckedIOException(e);
            }
        });
    }
}

接著試著瀏覽方才的 HTML,一樣也能看到伺服端的即時訊息,如果瀏覽器上有開發者工具的話,試著開啟,比較〈非同步 Long Polling〉與這個範例,在請求回應連線上有何不同。