29日 七月 2019

长轮询(long polling)

长轮询是与服务器建立持久连接的最简单方法,它不使用任何特定协议,比如 WebSocket 或者服务端事件(Server Side Events)。

它很容易实现,在很多场景下也很好用。

普通轮询(regular Polling)

最简单的从服务器获取新信息的方式就是轮询。

也就是说,定期向服务器发出请求:“Hello, I’m here, do you have any information for me?”。例如,10 秒发送一次。

作为响应,服务器首先通知自己客户端在线,然后第二次 —— 发送直到那个时刻的消息包。

这很有效,但是也有些缺点:

  1. 消息的传递时间长达 10 秒(每个请求之间)。
  2. 即使没有消息,服务器也会每隔 10 秒被请求轰炸一次。对于后端来说,出于性能的考量,这是一个非常大的负担。

因此,如果我们讨论的是一个小型的服务,这种方法是可行的,但是一般来说,它需要一些改进。

长轮询(long polling)

所谓“长轮询”是一种更好的轮询服务器的方法。

它非常容易实现,并且可以无延迟地传递消息。

其流程为:

  1. 发送请求到服务器。
  2. 服务器在有消息之前不会关闭连接。
  3. 当消息出现 —— 服务器响应请求,并携带相应的数据。
  4. 浏览器马上创建一个新的请求。

当浏览器发送一个请求并与服务器建立挂起(pending)连接的情况是此方法的标准。仅仅在传递消息时,才会重新建立连接。

如果连接丢失,可能是因为网络错误,浏览器立即发送一个新请求。

发出长请求的客户端 subscribe 函数的草图:

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

  if (response.status == 502) {
    // 连接超时错误,
    // 当连接挂起太长可能会发生,远程服务器或者代理会关闭它
    // 重新连接
    await subscribe();
  } else if (response.status != 200) {
    // 显示错误
    showMessage(response.statusText);
    // 1 秒后重连
    await new Promise(resolve => setTimeout(resolve, 1000));
    await subscribe();
  } else {
    // 得到消息
    let message = await response.text();
    showMessage(message);
    await subscribe();
  }
}

subscribe();

你可以看到,subscribe 函数发起 fetch 请求,然后等待请求响应并处理它,然后再调用自己。

对于许多的挂起连接,服务器也应该能够很好的处理

服务器架构必须能够处理许多挂起连接。

某些服务器架构是每个连接对应一个进程。对于许多连接的情况,可能会有许多进程,每个进程占用很多内存。因此连接越多消耗也就越多。

这种情况通常是对于使用 PHP,Ruby 语言的后端,但是从技术上来说,它不是一种语言,而是实现的问题。

使用 Node.js 写的后端通常不会出现这样的问题。

Demo:chat

这是一个 demo:

结果
browser.js
server.js
index.html
// 发送消息,一个简单的 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;
  };
}

// 通过长轮询(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) {
      // 连接超时
      // 当连接等待时间过长时发生
      // 重新连接
      await subscribe();
    } else if (response.status != 200) {
      // 显示错误
      showMessage(response.statusText);
      // 1 秒后重新连接
      await new Promise(resolve => setTimeout(resolve, 1000));
      await subscribe();
    } else {
      // 得到消息
      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);

  // 新客户端想要获取消息
  if (urlParsed.pathname == '/subscribe') {
    onSubscribe(req, res);
    return;
  }

  // 发送消息
  if (urlParsed.pathname == '/publish' && req.method == 'POST') {
    // 接受 POST 请求
    req.setEncoding('utf8');
    let message = '';
    req.on('data', function(chunk) {
      message += chunk;
    }).on('end', function() {
      publish(message); // 广播给所有人
      res.end("ok");
    });

    return;
  }

  // 剩下的是静态的
  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');
  // 通过随机 url 参数避免任何缓存问题
  new SubscribePane(document.getElementById('subscribe'), 'subscribe?random=' + Math.random());
</script>

使用场景

在消息很少的情况下,长轮询很有效。

如果消息比较频繁,那么上面描绘的请求接收(requesting-receiving)消息的图表就会变成锯状(saw-like)。

每条消息都是单独的请求,带有 headers,authentication 等开销。

因此,在这种情况下,首选另一种方法,例如:Websocket 或者 Server Sent Events

教程路线图

评论

在评论之前先阅读本内容…
  • 欢迎你在文章下添加补充内容、提出你的问题或回答提出的问题。
  • 使用 <code> 标签插入几行代码,对于多行代码 — 可以使用 <pre>,对于超过十行的代码 — 建议使用沙箱(plnkrJSBincodepen 等)。
  • 如果你无法理解文章中的内容 — 请详细说明。