轮询的轻量替换方案

642 阅读4分钟

假设前端提交了一份生成报告的任务给服务端,生成报告大概需要几分钟,等报告生成完毕后需要告知用户,前端应该怎样处理?

一般我们会想到使用轮询请求来做,可能有同学说使用 websocket,但如果仅仅是为了这个场景引入 websocket 太重了(双向通信),推荐另一种更轻量的方式 Server Sent Events(单向通信)。

其它一些同样适合使用 Server Sent Events 的场景,例如文件上传时的实时进度条、股票实时图表等等。

长连接 or 短连接?

先复习一下常规的网页请求过程。客户端浏览器请求服务端建立一个 HTTP会话,过程中打开一个 TCP 连接,并请求一个资源或服务,然后服务端响应。如果需要更新数据,需要重新请求。

HTTP 1.0 需要使用 Connection:keep-alive 参数来告知服务器端要建立一个长连接,否则每个请求-响应周期后,连接都会关闭。

从HTTP/1.1起,默认使用长连接,当网页打开后,客户端与服务端用于传送资源的 TCP 连接不会被关闭,如果客户端再次发送请求,会继续使用上次建立连接。但 keep-alive 并不意味着永远保持连接,有时效性,可以在请求的服务器设置。

HTTP2.0 不再需要声明 Connection 属性,能对连接多路复用。

需要频繁交互的场景使用长连接,比如开头讲的轮询方案就适合长连接,否则每次建立连接会带来很多额外开销,Server Sent Events 由服务端断断续续发来信息,也应该是一个长连接(服务端不设置 "Connection": "keep-alive",默认也会加上,导致 http 协议是 1.1 ,理论上如果使用 http2.0 会多路复用)。

请求流

HTTP stream是一种推送式数据传输技术,它允许 Web 服务器通过无限期保持打开的单个 HTTP 连接连续向客户端发送数据。下面是一个普通的 http 请求,不过在服务端声明了返回类型是流。

// 与服务端约定好流的分隔符 SPLIT_CODE
dispatch({ type: 'action/start' });
const xhr = new XMLHttpRequest();
xhr.open('GET', `${url}`, true);
let seenChars = 0;
xhr.send();

xhr.onreadystatechange = () => {
  // 处理 stream 分片
  if (xhr.readyState === xhr.LOADING || xhr.readyState === xhr.DONE) {
    const chunk = xhr.response.substr(seenChars);
    const chunkList = chunk.split(SPLIT_CODE);
    forEach(chunkList, (subChunk, index) => {
      if (isJson(subChunk)) {
        dispatch({ type: 'action/process', data: JSON.parse(subChunk) });
        seenChars = seenChars + subChunk.length + (index < chunkList.length - 1 ? SPLIT_CODE.length : 0);
      }
    });
  }
  // 标记结束
  if (xhr.readyState === xhr.DONE) {
    if (xhr.status === 200) {
      dispatch({ type: 'action/end' }); // dispatch end
    } else {
      dispatch({ type: 'action/error' }); // dispatch error
    }
  }
};

初识 Server Sent Events(SSE)

SSE 就是基于 HTTP stream的,服务器向客户端申明发送的内容是 stream(content-type: t
ext/event-stream),不是一次性的数据包,客户端会等待数据流持续不断的发送过来,是单向通信的。

image.png

服务端代码(nodejs实现)

new Koa().
  use(async (ctx, next) => {
    if (ctx.path !== "/sse") {
      return await next();
    }

    ctx.set({
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      "Connection": "keep-alive",
      "Access-Control-Allow-Origin": "*",
    });

    const stream = new PassThrough();
    ctx.status = 200;
    ctx.body = stream;
    let count = 0

    setInterval(() => {
      count += 1;
      stream.write(`data: ${count}\n\n`);
    }, 2000);
  })
  .use(ctx => {
    ctx.status = 200;
    ctx.body = "成功了";
  })
  .listen(5000, () => console.log("Listening"));

客户端通过 EventSource 实例来完成连接初始

function App() {
  const [ data, setData ] = useState('');

  useEffect(() => {
    const source = new EventSource("http://localhost:5000/sse");
    source.onopen = () => console.log("Connected");
    source.onerror = console.error;
    source.onmessage = (event) => {
      console.log(event)
      setData(event.data);
    }
  }, []);

  return (
    <div className="App">
      内容:{ data }
    </div>
  );
}

效果
1.gif

分析一下消息体:
从上面效果动图中也可以看到请求返回的 EventStream 里面主要包括 id、event、data 这几个重要字段。查阅文档进一步分析,每个事件都使用冒号分隔,每对以换行符结尾,事件本身以两个换行符结尾。

  • id: 会成为当前EventSource对象的内部属性"最后一个事件ID"的属性值,如果客户端与流断开连接并再次重新连接,客户端可以跟踪这些并请求服务器在接收到最后一个流事件之后发送流事件。
  • event: 事件类型,如果指定了该字段,则在客户端接收到该条消息时,会在当前的 EventSource 对象上触发一个事件。一个事件流可能具有不同的事件类型,可以指定事件的类型。
  • data: 消息体,如果该条消息包含多个data字段,则客户端会用换行符把它们连接成一个字符串来作为字段值,单个事件消息中可以有一个或多个键值对。
  • retry:一个整数值,指定了重新连接的时间(单位为毫秒),如果该字段值不是整数,则会被忽略。

使用命名事件和未命名事件

const eventSource = new EventSource(`${url}`, { withCredentials: true });

// 监听未指定事件类型的消息
eventSource.onmessage = (event) => {
  // TODO
}

// 监听指定事件类型的消息(只有在服务器发送包含 dongdong 事件类型值时才会触发)
eventSource.addEventListener('dongdong', (event) => {
  // TODO
})

错误处理

eventSource.onerror = (err) => {
  // TODO
}

关闭事件流

eventSource.close();

参考

developer.mozilla.org/en-US/docs/…
datatracker.ietf.org/doc/html/rf…