サーバとのリアルタイム通信 ー ポーリング、ロングポーリング、Server-Sent Events、WebSocket

サーバとのリアルタイム通信 ー ポーリング、ロングポーリング、Server-Sent Events、WebSocket

概要 #

サーバ側で定期的に更新されるデータをクライアントが継続チェックしたい場合、以下4つの代表的な方法があります。

  1. ポーリング(Pooling)
  2. ロングポーリング(Long Pooling)
  3. サーバ送信イベント(Server-Sent Events, SSE)
  4. ウェブソケット(WebSocket, WS)

ポーリングは、定期的な間隔でクライアントからサーバへリクエストを送り、サーバもそれに応じてリクエストを返すだけの最もシンプルな仕組みです。

ロングポーリングは、クライアントからサーバにリクエストを送ったあと、サーバはあえてすぐにレスポンスを返さずに、レスポンスを返す必要が出たタイミング(サーバ側のデータに更新があったなど)で初めてレスポンスを返す仕組みです。

Server-Sent Events は、クライアントとサーバで永続的な接続を確立し、サーバ側から任意のタイミングでクライアントにデータ送信することができます。広く使えるようになったのは比較的最近のことで、様々なブラウザで Server-Sent Event が使えるようになったのは 2020 年あたりです。

WebSocket も、クライアントとサーバで永続的な接続を確立します。WebSocket では、クライアントとサーバ双方がそれぞれ任意のタイミングでデータを送信することができます。双方向通信です。

ポーリング、ロングポーリング、Server-Sent Events ははすべて HTTP プロトコルを用いた通信です。WebSocket では専用の WebSocket プロトコルを使います。そのため、前者3つの方法は HTTP 通信が行えるインフラ環境であれば基本的に採用できますが、WebSocket の場合はインフラがプロトコルに対応していない可能性もあるため、採用を検討する際はインフラ面も事前に確認しましょう。

4つの方法のうち、ポーリングはサーバ側に特別な実装を必要としません。単にクライアントが定期的にリクエストを送るだけだからです。ただし、外部サービスにポーリングする際には、DOS 攻撃のようにならないように注意しましょう。それ以外の方法の場合、サーバ側でもその方法を実現するための実装が行われている必要があります。そのため、例えば Server-Sent Events に対応していない外部サービスに対して Server-Sent Events を使ってアクセスするようなことはできません。

その他ご参考 #

実装サンプル #

それぞれの方法の実装サンプルを載せておきます。

ポーリング #

pooling/
├ index.html
└ server.js

index.html

<!--
    2秒毎にサーバへリクエストし、サーバ時刻を取得する。
-->
<!doctype html>
<html>
  <body>
    <p id="result"></p>

    <script>
      const result = document.getElementById("result");

      async function poll() {
        try {
          const response = await fetch("/my-pooling", {
            cache: "no-store",
          });
          if (!response.ok) {
            throw new Error(`HTTP ${response.status}`);
          }
          const data = await response.json();
          result.textContent = `サーバ時刻: ${data.time}`;
        } catch (error) {
          result.textContent = `取得失敗: ${error.message}`;
        } finally {
          setTimeout(poll, 2000);
        }
      }

      poll();
    </script>
  </body>
</html>

server.js

/**
 * `node server.js` で起動する。
 * 起動後は `http://localhost:3000` でアクセスできる。
 */
const http = require("node:http");
const fs = require("node:fs");
const path = require("node:path");

const server = http.createServer((req, res) => {
  switch (req.url) {
    case "/": {
      res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
      res.end(fs.readFileSync(path.join(__dirname, "index.html")));
      return;
    }

    case "/my-pooling": {
      res.writeHead(200, {
        "Content-Type": "application/json; charset=utf-8",
        "Cache-Control": "no-store",
      });
      res.end(JSON.stringify({ time: new Date().toISOString() }));
      return;
    }

    default: {
      res.writeHead(404);
      res.end("Not Found");
    }
  }
});

server.listen(3000, "127.0.0.1", () => {
  console.log("http://localhost:3000");
});

ロングポーリング #

long-pooling/
├ index.html
└ server.js

index.html

<!--
    サーバのデータ更新まで待ち、レスポンスを受け取ったらすぐに再リクエストする。
-->
<!doctype html>
<html>
  <body>
    <p id="result"></p>

    <script>
      const result = document.getElementById("result");

      async function longPoll() {
        let delay = 0;
        try {
          const response = await fetch("/my-long-pooling", {
            cache: "no-store",
          });
          if (!response.ok) {
            throw new Error(`HTTP ${response.status}`);
          }
          const data = await response.json();
          result.textContent = `サーバ時刻: ${data.time}`;
        } catch (error) {
          result.textContent = `取得失敗: ${error.message}`;
          // 通信に失敗した場合は、2秒待ってから再試行する。
          delay = 2000;
        } finally {
          setTimeout(longPoll, delay);
        }
      }

      longPoll();
    </script>
  </body>
</html>

server.js

/**
 * `node server.js` で起動する。起動後は `http://localhost:3001` でアクセスできる。
 */
const http = require("node:http");
const fs = require("node:fs");
const path = require("node:path");

const waitingResponses = new Set();

// 更新されたデータで、待機中のリクエストに応答する。
function respondToWaitingRequests(data) {
  const body = JSON.stringify(data);
  for (const res of waitingResponses) {
    res.writeHead(200, {
      "Content-Type": "application/json; charset=utf-8",
      "Cache-Control": "no-store",
    });
    res.end(body);
  }
  waitingResponses.clear();
}

const server = http.createServer((req, res) => {
  switch (req.url) {
    case "/": {
      res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
      res.end(fs.readFileSync(path.join(__dirname, "index.html")));
      return;
    }

    case "/my-long-pooling": {
      // すぐにレスポンス返さない(= res.end() しない)で、次のデータ更新まで接続を維持する。
      waitingResponses.add(res);
      res.on("close", () => {
        waitingResponses.delete(res);
      });
      return;
    }

    default: {
      res.writeHead(404);
      res.end("Not Found");
    }
  }
});

server.listen(3001, "127.0.0.1", () => {
  console.log("http://localhost:3001");
});

// デモ用:5秒毎のデータ更新を模擬する。
// 実際のサーバでは、メッセージの受信などトリガーに応答用の関数を呼ぶことになる。
setInterval(() => {
  const data = { time: new Date().toISOString() };
  respondToWaitingRequests(data);
}, 5000);

Server-Sent Events #

server-sent-events/
├ index.html
└ server.js

index.html

<!--
    サーバとの接続を維持し、送られてくるイベントを受け取る。
-->
<!doctype html>
<html>
  <body>
    <p id="result"></p>

    <script>
      const result = document.getElementById("result");
      const events = new EventSource("/my-server-sent-events");

      events.onmessage = (event) => {
        const data = JSON.parse(event.data);
        result.textContent = `サーバ時刻: ${data.time}`;
      };

      // 接続が切れた場合、EventSource が自動で再接続する仕組みを持っている。
      events.onerror = () => {
        result.textContent = "接続エラー: 再接続を待っています...";
      };
    </script>
  </body>
</html>

server.js

/**
 * `node server.js` で起動する。起動後は `http://localhost:3002` でアクセスできる。
 */
const http = require("node:http");
const fs = require("node:fs");
const path = require("node:path");

const connectedResponses = new Set();

// SSE: 接続中のクライアントにデータを送る。送信後も接続を維持する。
function sendToConnectedClients(data) {
  const body = JSON.stringify(data);
  for (const res of connectedResponses) {
    // SSE は「data: 本文」と空行で、1件のイベントを表す。
    res.write(`data: ${body}\n\n`);
  }
}

const server = http.createServer((req, res) => {
  switch (req.url) {
    case "/": {
      res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
      res.end(fs.readFileSync(path.join(__dirname, "index.html")));
      return;
    }

    case "/my-server-sent-events": {
      res.writeHead(200, {
        "Content-Type": "text/event-stream; charset=utf-8",
        "Cache-Control": "no-cache",
      });
      res.flushHeaders();

      // res.end() せず、同じレスポンスに繰り返し書き込める状態にする。
      connectedResponses.add(res);
      res.on("close", () => {
        connectedResponses.delete(res);
      });
      return;
    }

    default: {
      res.writeHead(404);
      res.end("Not Found");
    }
  }
});

server.listen(3002, "127.0.0.1", () => {
  console.log("http://localhost:3002");
});

// デモ用:5秒毎のデータ更新を模擬する。
// 実際のサーバでは、メッセージの受信などをトリガーに送信用の関数を呼ぶことになる。
setInterval(() => {
  const data = { time: new Date().toISOString() };
  sendToConnectedClients(data);
}, 5000);

WebSocket #

web-socket/
├ node_modules
├ package.json
├ index.html
└ server.js

package.json

{
  "dependencies": {
    "ws": "^8"
  }
}

index.html

<!--
    同じ接続でメッセージを送受信する。デモとして、サーバは受信した文字列をそのまま返す。
-->
<!doctype html>
<html>
  <body>
    <form id="form">
      <input id="message" value="こんにちは!" required />
      <button id="send" disabled>送信</button>
    </form>
    <p id="result">接続中...</p>

    <script>
      const result = document.getElementById("result");
      const form = document.getElementById("form");
      const message = document.getElementById("message");
      const send = document.getElementById("send");
      const socket = new WebSocket(`ws://${location.host}/my-web-socket`);

      socket.onopen = () => {
        send.disabled = false;
        result.textContent = "接続完了。メッセージを送信してください。";
      };

      // 送信ボタンを押すと、入力した文字列をサーバへ送る。
      form.onsubmit = (event) => {
        event.preventDefault();
        if (socket.readyState === WebSocket.OPEN) {
          socket.send(message.value);
        }
      };

      socket.onmessage = (event) => {
        result.textContent = `サーバからの返信: ${event.data}`;
      };

      socket.onerror = () => {
        result.textContent = "接続エラーが発生しました。";
      };

      // WebSocket は自動で再接続しない。このサンプルではページをリロードするよう促す。
      socket.onclose = () => {
        send.disabled = true;
        result.textContent = "切断されました。ページをリロードしてください。";
      };
    </script>
  </body>
</html>

server.js

/**
 * `node server.js` で起動する。起動後は `http://localhost:3003` でアクセスできる。
 */
const http = require("node:http");
const fs = require("node:fs");
const path = require("node:path");
const { WebSocketServer } = require("ws");

const server = http.createServer((req, res) => {
  switch (req.url) {
    case "/": {
      res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
      res.end(fs.readFileSync(path.join(__dirname, "index.html")));
      return;
    }

    default: {
      res.writeHead(404);
      res.end("Not Found");
    }
  }
});

// HTTP から WebSocket への接続切り替えは ws ライブラリが処理する。
const webSocketServer = new WebSocketServer({
  server,
  path: "/my-web-socket",
});

webSocketServer.on("connection", (socket) => {
  socket.on("error", console.error);

  // 同じ接続で繰り返し受信・送信できる。
  socket.on("message", (message) => {
    console.log(`クライアントからのメッセージ: ${message.toString()}`);
    socket.send(message.toString());
  });
});

server.listen(3003, "127.0.0.1", () => {
  console.log("http://localhost:3003");
});