2022 年 12 月 12 日

長輪詢

長輪詢是與伺服器建立持續連線最簡單的方式,它不使用任何特定通訊協定,例如 WebSocket 或伺服器發送事件。

它非常容易實作,在許多情況下也夠用了。

定期輪詢

取得伺服器新資訊最簡單的方式是定期輪詢。也就是定期向伺服器發出請求:「嗨,我在這裡,你有任何資訊要給我嗎?」例如,每 10 秒一次。

回應時,伺服器首先會自行記錄客戶端已上線,其次會傳送它到目前為止收到的訊息封包。

這很有效,但有缺點

  1. 訊息傳遞會延遲最多 10 秒(在請求之間)。
  2. 即使沒有訊息,伺服器每 10 秒仍會收到大量請求,即使使用者已切換到其他地方或已睡著。就效能而言,這是一個很大的負擔。

因此,如果我們討論的是一個非常小的服務,這種方法可能是可行的,但一般來說,它需要改進。

長輪詢

所謂的「長輪詢」是輪詢伺服器的更好方法。

它也很容易實作,而且可以立即傳遞訊息。

流程

  1. 向伺服器發送請求。
  2. 伺服器不會關閉連線,直到它有訊息要傳送。
  3. 當訊息出現時,伺服器會對請求做出回應。
  4. 瀏覽器會立即發出新的請求。

這種情況,也就是瀏覽器已發送請求並與伺服器保持連線,是此方法的標準。只有在傳遞訊息時,連線才會關閉並重新建立。

如果連線中斷,例如因為網路錯誤,瀏覽器會立即發送新的請求。

執行長請求的客戶端側 subscribe 函式的草圖

async function subscribe() {
  let response = await fetch("/subscribe");

  if (response.status == 502) {
    // Status 502 is a connection timeout error,
    // may happen when the connection was pending for too long,
    // and the remote server or a proxy closed it
    // let's reconnect
    await subscribe();
  } else if (response.status != 200) {
    // An error - let's show it
    showMessage(response.statusText);
    // Reconnect in one second
    await new Promise(resolve => setTimeout(resolve, 1000));
    await subscribe();
  } else {
    // Get and show the message
    let message = await response.text();
    showMessage(message);
    // Call subscribe() again to get the next message
    await subscribe();
  }
}

subscribe();

如你所見,subscribe 函式會執行提取,然後等待回應,處理它並再次呼叫它自己。

伺服器應該可以處理許多未處理的連線

伺服器架構必須能夠處理許多未處理的連線。

某些伺服器架構會為每個連線執行一個程序,導致程序數與連線數相同,而每個程序都會消耗相當多的記憶體。因此,過多的連線只會消耗所有記憶體。

使用 PHP 和 Ruby 等語言編寫的後端通常就是這種情況。

使用 Node.js 編寫的伺服器通常沒有這種問題。

話雖如此,這不是程式語言的問題。包括 PHP 和 Ruby 在內的大多數現代語言都允許實作適當的後端。請務必確保你的伺服器架構可以順利處理許多同時連線。

示範:聊天

以下是聊天示範,你也可以下載並在本地執行(如果你熟悉 Node.js 且可以安裝模組)

結果
browser.js
server.js
index.html
// Sending messages, a simple POST
function PublishForm(form, url) {

  function sendMessage(message) {
    fetch(url, {
      method: 'POST',
      body: message
    });
  }

  form.onsubmit = function() {
    let message = form.message.value;
    if (message) {
      form.message.value = '';
      sendMessage(message);
    }
    return false;
  };
}

// Receiving messages with long polling
function SubscribePane(elem, url) {

  function showMessage(message) {
    let messageElem = document.createElement('div');
    messageElem.append(message);
    elem.append(messageElem);
  }

  async function subscribe() {
    let response = await fetch(url);

    if (response.status == 502) {
      // Connection timeout
      // happens when the connection was pending for too long
      // let's reconnect
      await subscribe();
    } else if (response.status != 200) {
      // Show Error
      showMessage(response.statusText);
      // Reconnect in one second
      await new Promise(resolve => setTimeout(resolve, 1000));
      await subscribe();
    } else {
      // Got message
      let message = await response.text();
      showMessage(message);
      await subscribe();
    }
  }

  subscribe();

}
let http = require('http');
let url = require('url');
let querystring = require('querystring');
let static = require('node-static');

let fileServer = new static.Server('.');

let subscribers = Object.create(null);

function onSubscribe(req, res) {
  let id = Math.random();

  res.setHeader('Content-Type', 'text/plain;charset=utf-8');
  res.setHeader("Cache-Control", "no-cache, must-revalidate");

  subscribers[id] = res;

  req.on('close', function() {
    delete subscribers[id];
  });

}

function publish(message) {

  for (let id in subscribers) {
    let res = subscribers[id];
    res.end(message);
  }

  subscribers = Object.create(null);
}

function accept(req, res) {
  let urlParsed = url.parse(req.url, true);

  // new client wants messages
  if (urlParsed.pathname == '/subscribe') {
    onSubscribe(req, res);
    return;
  }

  // sending a message
  if (urlParsed.pathname == '/publish' && req.method == 'POST') {
    // accept POST
    req.setEncoding('utf8');
    let message = '';
    req.on('data', function(chunk) {
      message += chunk;
    }).on('end', function() {
      publish(message); // publish it to everyone
      res.end("ok");
    });

    return;
  }

  // the rest is static
  fileServer.serve(req, res);

}

function close() {
  for (let id in subscribers) {
    let res = subscribers[id];
    res.end();
  }
}

// -----------------------------------

if (!module.parent) {
  http.createServer(accept).listen(8080);
  console.log('Server running on port 8080');
} else {
  exports.accept = accept;

  if (process.send) {
     process.on('message', (msg) => {
       if (msg === 'shutdown') {
         close();
       }
     });
  }

  process.on('SIGINT', close);
}
<!DOCTYPE html>
<script src="browser.js"></script>

All visitors of this page will see messages of each other.

<form name="publish">
  <input type="text" name="message" />
  <input type="submit" value="Send" />
</form>

<div id="subscribe">
</div>

<script>
  new PublishForm(document.forms.publish, 'publish');
  // random url parameter to avoid any caching issues
  new SubscribePane(document.getElementById('subscribe'), 'subscribe?random=' + Math.random());
</script>

瀏覽器程式碼在 browser.js

使用範圍

長輪詢在訊息很少時非常有效。

如果訊息很頻繁,那麼上面繪製的請求-接收訊息圖表就會變成鋸齒狀。

每則訊息都是一個單獨的請求,會附帶標頭、驗證負載等。

因此,在這種情況下,建議使用其他方法,例如 WebSocket伺服器傳送事件

教學課程地圖

留言

留言前請閱讀…
  • 如果你有改進建議,請 提交 GitHub 議題 或提交拉取請求,而不是留言。
  • 如果你看不懂文章中的內容,請說明。
  • 要插入幾行程式碼,請使用 <code> 標籤,對於多行程式碼,請將它們包覆在 <pre> 標籤中,對於超過 10 行的程式碼,請使用沙盒(plnkrjsbincodepen…)