リアルタイムなwebアプリを実現する方法(ポーリング、Comet、Server Sent Events、WebSocket)

リアルタイムなwebアプリを実現する方法について、サンプルコードを作成しながら検証する。

注意点
この記事で記載している実現方法はいずれもHTTPレベルの仕組みで実現されるものであり、サンプルコードはあくまで実装例です。 サンプルコードは動作を確認するのが目的であり、プロダクション適用レベルの考慮は一切していません。

実現方法の一覧

  • ポーリング
    • 画面をフルでレンダリングするパターン
    • AjaxでJSONやXMLでデータを取得するパターン
  • Comet(ロングポーリング)
  • Server Sent Events
  • WebSocket

詳細の理解は、以下の資料を参考にしてください。
参考 サーバPUSHざっくりまとめ

サンプルコード

ポーリング(Ajax)

リアルタイム性はポーリング間隔に依存する。また、リアルタイム性を上げようとすると無駄な通信が多く発生する可能性がある。 例として、サーバ側は1sごとにデータを更新し、クライアントは10sごとにデータをポーリングするコードを作成する。図で表すと以下のようになる。

f:id:kimulla:20191201225822p:plain

Github コード例

クライアント側

window.setTimeoutなどを使って定期的にサーバにリクエストをなげる。(window.setTimeoutは厳密な時間を保証してくれないため、多少ズレる) 結果を受け取ったら画面の一部を更新する。

$(function() {
  var POLLLING_INVERVAL_TIME_IN_MILLIS = 10000;//10s
  (function polling() {
    getCountUp();
    window.setTimeout(polling, POLLLING_INVERVAL_TIME_IN_MILLIS);
  }());
  
  function getCountUp() {
    $.ajax({
    type : "GET",
    url : "countUp",
    content : "application/json",
    dataType : "json",
  }).done(function(data) {
    $("dd").text(data.count);//html要素変更する
  }).fail(function(jqXHR, textStatus) {
    $("dd").text("error occured");//html要素変更する
    });
  }
});
サーバ側

単純にリクエストに対してレスポンスを返す。ポーリングだからといって、特別に何かを実装するわけではない。 更新対象のデータが更新されていれば新しい値を返せるが、更新されてなかったら変更のないデータを返すことになる。

リソースを節約するなら

サンプルコードだと、ポーリングしているタブを開いていないときにも定期的にリクエストが実行される。Page Visibility API を利用すれば、タブが開かれていないときの無駄なリクエストを抑制できる。
参考 MDN web docs Page Visibility API

$(function() {
  var POLLLING_INVERVAL_TIME_IN_MILLIS = 10000;//10s
  (function polling() {
    if(!document.hidden) { // このページが表示されているときだけリクエストする
        getCountUp();
    }
    window.setTimeout(polling, POLLLING_INVERVAL_TIME_IN_MILLIS);
  }());
  
...
});

Comet(ロングポーリング) 同期

前述のポーリングと比べるとリアルタイム性は上がる。が、レスポンスを返した直後はHTTPコネクションを張りなおすため、短期間の変化には弱い。 例として、サーバ側は5sごとにデータを更新し、クライアントはレスポンスを受け取ったらすぐにリクエストを投げる。図で表すと以下のようになる。

f:id:kimulla:20191201225851p:plain

Github コード例

クライアント側

成功しても失敗しても即座にサーバにリクエストを投げる。 結果を受け取ったら画面の一部を更新する。

$(function() {
  (function getCountUp() {
  $.ajax({
    type : "GET",
    url : "countUp",
    content : "application/json",
    dataType : "json",
  }).done(function(data) {
    $("dd").text(data.count); //html要素変更する
    getCountUp();
  }).fail(function(jqXHR, textStatus) {
    $("dd").text("error occured"); //html要素変更する
    getCountUp();
    });
  })();
});
サーバ側

サーバ側を同期処理(データ更新までリクエストスレッドを占有する)にするなら、Servletの処理をブロックさせて待機させる。

@WebServlet(name = "CountUpServlet", urlPatterns = { "/countUp" })
public class CountUpServlet extends HttpServlet {
  // countUpされる値
  AtomicInteger count = new AtomicInteger(0);
  
  @Override
  protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    //インクリメントされるまで待つ
    int prev = count.get();
    while (count.get() <= prev) {
    }
    resp.setContentType("application/json");
    resp.getWriter().write("{\"count\":\"" + count.get() + "\"}");
  }  

  @Override
  public void init() throws ServletException {
    super.init();
    // 5sごとにcountをインクリメントする
    Timer timer = new Timer();
    TimerTask task = new TimerTask() {
      @Override
      public void run() {
        count.incrementAndGet();
      }
    };
    timer.schedule(task, 5000, 5000);
  }  
}

Comet(ロングポーリング) 非同期

処理フローはComet(ロングポーリング) 同期と全く一緒。サーバ側のリクエストスレッドの占有時間だけが異なる。

f:id:kimulla:20191201225955p:plain

Github コード例

クライアント側

Comet(ロングポーリング) 同期 と一緒。

サーバ側

AsyncContext を利用してServletで処理がブロックされるのを防ぐ。非同期にすることによって、リクエストを処理するスレッドの占有を防ぐことができるため、サーバ資源の節約になる。 ※

値が更新され次第、クライアントに値を返す。

@WebServlet(name = "CountUpServlet", urlPatterns = { "/countUp" }, asyncSupported = true)
public class CountUpServlet extends HttpServlet {
  // countUp対象
  int count;
  List<AsyncContext> queue = new ArrayList<>();

  @Override
  protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    final AsyncContext context = req.startAsync();
    queue.add(context);
  }

  @Override
  public void init() throws ServletException {
    super.init();
    // 5sごとにインクリメントする
    Timer timer = new Timer();
    TimerTask task = new TimerTask() {
      @Override
      public void run() {
        count++;
        broadcast();
      }
    };
    timer.schedule(task, 5000, 5000);
  }

  synchronized public void broadcast() {
    CopyOnWriteArrayList<AsyncContext> target = new CopyOnWriteArrayList<>(queue);
    synchronized (queue) {
      queue = new ArrayList<>();
    }

    for (AsyncContext context : target) {
      HttpServletResponse resp = (HttpServletResponse) context.getResponse();
      resp.setContentType("application/json");
      try {
        PrintWriter writer = resp.getWriter();
        writer.write("{\"count\":\"" + count + "\"}");
        writer.close();
        context.complete();
      } catch (IOException e) {
        e.printStackTrace();
      }
    }
  }
}

※ AsyncContextを利用するメリットは、別ブログにまとめました。

www.kimullaa.com

Server Sent Events

1つのHTTPコネクション内で更新されたデータを返すため、ロングポーリングと比較すると低コストでリアルタイム性も高い。(ただし、データを受信するだけ) 例として、サーバ側は5sごとにデータを更新し、HTTPの Content-type:text/event-stream を利用してHTTPレスポンスを閉じずに少しずつデータを返す。図で表すと以下のようになる。

f:id:kimulla:20191201230030p:plain

Github コード例

クライアント側

javascriptのAPIが標準化されているのでそれを利用する。 MDN web docs EventSource

$(function() {
  (function getCountUp() {
    var sse = new EventSource(
        "http://localhost:8080/server-sent-events/countUp");
    sse.addEventListener('message', function(event) {
      $('dd').text(JSON.parse(event.data).count);
    });
  })();
});
サーバ側

1つのコネクション内で複数のリクエスト・レスポンスのやりとりをするため、HTTPレスポンスを閉じずに開きっぱなしにする。そのためコネクションはcloseしない。

@WebServlet(name = "CountUpServlet", urlPatterns = { "/countUp" }, asyncSupported = true)
public class CountUpServlet extends HttpServlet {
  // countUp対象
  int count;
  List<AsyncContext> queue = new ArrayList<>();

  @Override
  protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
    final AsyncContext context = req.startAsync();
    // タイムアウトしないようにmax値入れる
    context.setTimeout(Long.MAX_VALUE);
    queue.add(context);
  }

  @Override
  public void init() throws ServletException {
    super.init();
    // 5sごとにインクリメントする
    Timer timer = new Timer();
    TimerTask task = new TimerTask() {
      @Override
      public void run() {
        count++;
        broadcast();
      }
    };
    timer.schedule(task, 5000, 5000);
  }

  synchronized public void broadcast() {
    CopyOnWriteArrayList<AsyncContext> target = new CopyOnWriteArrayList<>(queue);

    // SSEのためにコネクションはcloseしない
    for (AsyncContext context : target) {
      HttpServletResponse resp = (HttpServletResponse) context.getResponse();
      resp.setContentType("text/event-stream");
      resp.setCharacterEncoding("UTF-8");
      try {
        PrintWriter writer = resp.getWriter();
        writer.write("data: {\"count\":\"" + count + "\"}\n\n");
        writer.flush();
      } catch (IOException e) {
        e.printStackTrace();
      }
    }
  }

}

Jerseyなどは対応するAPIがあるらしいけどServletを直接利用した。
参考 Github ServerSentEventsResource.java

WebSocket

はじめにHTTPのUpgradeを利用してWebSocketのプロトコルにスイッチし、その後はTCPコネクション上でWebSocketプロトコルによる通信を行う。1つのコネクションでサーバからのデータを連続して受信できるため、リアルタイム性が高い。また、クライアントからサーバにもメッセージを送信できるという『双方向』な点が他と違う。 例として、サーバ側は5sごとにデータを更新してクライアントに通知する。 図で表すと以下のようになる。

f:id:kimulla:20191201230059p:plain

Github コード例

クライアント側

javascriptのAPIが標準化されているのでそれを利用する。
参考 MDN web docs WebSocket

onmessageメソッドでデータ受信後の処理を記述する。

<script type="text/javascript">
  $(function() {
    var ws = new WebSocket("ws://localhost:8080/websocket/countUp");
    ws.onmessage = function(e) {
    $('dd').text(JSON.parse(e.data).count);
    };
  });
</script>
サーバ側

ServletにWebsocketの標準APIがあるので利用する。
参考 JSR 356―Java 標準の WebSocket API (ja)

@ServerEndpoint(value = "/countUp")
public class CountUpServer {
  // countUp対象
  private static int count;
  private static final Queue<Session> sessions = new ConcurrentLinkedQueue<>();

  static {
    // 5sごとにインクリメントする
    Timer timer = new Timer();
    TimerTask task = new TimerTask() {
      @Override
      public void run() {
        count++;
        broadcast();
      }
    };
    timer.schedule(task, 5000, 5000);
  }

  @OnOpen
  public void currentCount(Session session) {
    sessions.add(session);
  }
  @OnClose
  public void remove(Session session) {
    sessions.remove(session);
  }
  public static void broadcast() {
    sessions.forEach(session -> {
      session.getAsyncRemote().sendText("{\"count\":" + count + "}");
    });
  }
}

実装してみた感想

  • ポーリング
    • たいしてリアルタイム性が求められない、かつ、リクエストするユーザも少ないなら、選択肢としてありだと思う
    • ただのHTTP通信なので、運用への考慮は少ない
    • 実装がシンプルでコストが低い
  • Comet
    • データ更新後にすぐ通知することができる(リアルタイム性は高い)
    • 短期間の連続したデータ更新には弱い
    • HTTP通信の延長なので、運用への考慮は少ない
    • AsyncContextを直で使うとバグ埋め込みそう
  • Server Sent Events
    • データ更新後にすぐ通知することができる(リアルタイム性は高い)
    • 短期間の連続したデータ更新にも強い
    • HTTP通信の延長なので、運用への考慮は少ない
    • Servlet APIだけで実装するのはツライ
    • Jakarta EE(jsr370)で対応されると実装のハードルは下がる
    • Spring FrameworkはSseEmitterなどの対応あり
    • Jerseyも独自apiの対応あり
  • WebSocket
    • サーバとクライアント間の双方向で高いリアルタイム性を確保できる
    • HTTPとは別のプロトコルに切り替わるので、Proxyサーバなどの運用考慮が必要
    • ネガティブ意見わりと多い印象。実現したい内容に対してオーバースペックになっていないか注意

参考にしたサイト