乐趣区

你可能不知道的浏览器实时通信方案

本文主要探讨现阶段浏览器端可行的实时通信方案,以及它们的发展历史。

这里以 sockjs 作为切入点,这是一个流行的浏览器实时通信库,提供了 ’ 类 Websocket’、一致性、跨平台的 API,旨在浏览器和服务器之间创建一个低延迟、全双工、支持跨域的实时通信信道. 主要特点就是仿生 Websocket,它会优先使用 Websocket 作为传输层,在不支持 WebSocket 的环境回退使用其他解决方案,例如 XHR-Stream、轮询.

所以 sockjs 本身就是浏览器实时通信方案的编年史, 本文也是按照由新到老这样的顺序来介绍这些解决方案.

类似 sockjs 的解决方案还有 socket.io

如果你觉得文章不错,请不要吝惜你的点赞????,鼓励笔者写出更精彩的文章

目录

  • WebSocket
  • XHR-streaming
  • EventSource
  • HtmlFile
  • Polling
  • Long polling
  • 扩展

WebSocket

WebSocket 其实不是本文的主角,而且网上已经有很多教程,本文的目的是介绍 WebSocket 之外的一些回退方案,在浏览器不支持 Websocket 的情况下, 可以选择回退到这些方案.

在此介绍 Websocket 之前,先来了解一些 HTTP 的基础知识,毕竟 WebSocket 本身是借用 HTTP 协议实现的。

HTTP 协议是基于 TCP/IP 之上的应用层协议,也就是说 HTTP 在 TCP 连接中进行请求和响应的,浏览器会为每个请求建立一个 TCP 连接,请求等待服务端响应,在服务端响应后关闭连接:

后来人们发现为每个 HTTP 请求都建立一个 TCP 连接,太浪费资源了,能不能不要着急关闭 TCP 连接,而是将它复用起来, 在一个 TCP 连接中进行多次请求。

这就有了 HTTP 持久连接(HTTP persistent connection, 也称为 HTTP keep-alive), 它利用同一个 TCP 连接来发送和接收多个 HTTP 请求 / 响应。持久连接的方式可以大大减少等待时间, 双方不需要重新运行 TCP 握手,这对前端静态资源的加载也有很大意义:

Ok, 现在回到 WebSocket, 浏览器端用户程序并不支持和服务端直接建立 TCP 连接,但是上面我们看到每个 HTTP 请求都会建立 TCP 连接, TCP 是可靠的、全双工的数据通信通道,那我们何不直接利用它来进行实时通信?这就是 Websocket 的原理!

我们这里通过一张图,通俗地理解一下 Websocket 的原理:

通过上图可以看到,WebSocket 除最初建立连接时需要借助于现有的 HTTP 协议,其他时候直接基于 TCP 完成通信 。这是浏览器中最靠近套接字的 API,可以实时和服务端进行全双工通信. WebSocket 相比传统的浏览器的 Comet)(下文介绍) 技术, 有很多优势:

  • 更强的实时性。基于 TCP 协议的全双工通信
  • 更高效。一方面是数据包相对较小,另一方面相比传统 XHR-Streaming 和轮询方式更加高效,不需要重复建立 TCP 连接
  • 更好的二进制支持。Websocket 定义了二进制帧,相对 HTTP,可以更轻松地处理二进制内容
  • 保持连接状态。相比 HTTP 无状态的协议,WebSocket 只需要在建立连接时携带认证信息,后续的通信都在这个会话内进行
  • 可以支持扩展。Websocket 定义了扩展,用户可以扩展协议、实现部分自定义的子协议。如部分浏览器支持压缩等

它的接口也非常简单:

const ws = new WebSocket('ws://localhost:8080/socket'); 

// 错误处理
ws.onerror = (error) => {...} 

// 连接关闭
ws.onclose = () => { ...} 

// 连接建立
ws.onopen = () => { 
  // 向服务端发送消息
  ws.send("ping"); 
}

// 接收服务端发送的消息
ws.onmessage = (msg) => {if(msg.data instanceof Blob) { 
  // 处理二进制信息
    processBlob(msg.data);
  } else {
    // 处理文本信息
    processText(msg.data); 
  }
}

本文不会深入解析 Websocket 的协议细节,有兴趣的读者可以看下列文章:

  • WebSocket
  • WebSocket 浅析
  • 阮一峰:WebSocket 教程

如果不考虑低版本 IE,基本上 WebSocket 不会有什么兼容性上面的顾虑. 下面列举了 Websocket 一些常见的问题, 当无法正常使用 Websocket 时,可以利用 sockjs 或者 socket.io 这些方案回退到传统的 Comet 技术方案.

  1. 浏览器兼容性。

    • IE10 以下不支持
    • Safari 下不允许使用非标准接口建立连接
  2. 心跳. WebSocket 本身不会维护心跳机制,一些 Websocket 实现在空闲一段时间会自动断开。所以 sockjs 这些库会帮你维护心跳
  3. 一些负载均衡或代理不支持 Websocket。
  4. 会话和消息队列维护。这些不是 Websocket 协议的职责,而是应用的职责。sockjs 会为每个 Websocket 连接维护一个会话,且这个会话里面会维护一个消息队列,当 Websocket 意外断开时,不至于丢失数据

XHR-streaming

XHR-Streming, 中文名称‘XHR 流’, 这是 WebSocket 的最佳替补方案. XHR-streaming 的原理也比较简单:服务端使用分块传输编码 (Chunked transfer encoding) 的 HTTP 传输机制进行响应,并且服务器端不终止 HTTP 响应流,让 HTTP 始终处于持久连接状态,当有数据需要发送给客户端时再进行写入数据

没理解?没关系,我们一步一步来, 先来看一下正常的 HTTP 请求处理是这样的:

// Node.js 代码
const http = require('http')

const server = http.createServer((req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/plain', // 设置内容格式
    'Content-Length': 11, // 设置内容长度
  })
  res.end('hello world') // 响应 
})

客户端会立即接收到响应:

那么什么是 分块传输编码 呢?

在 HTTP/1.0 之前, 响应是必须作为一整块数据返回客户端的(如上例),这要求服务端在发送响应之前必须设置Content-Length, 浏览器知道数据的大小后才能确定响应的结束时间。这让服务器响应动态的内容变得非常低效,它必须等待所有动态内容生成完,再计算 Content-Length, 才可以发送给客户端。如果响应的内容体积很大,需要占用很多内存空间.

HTTP/1.1 引入了 Transfer-Encoding: chunked; 报头。它允许服务器发送给客户端应用的数据可以分为多个部分, 并以一个或多个块发送,这样服务器可以发送数据而不需要提前计算发送内容的总大小

有了分块传输机制后,动态生成内容的服务器就可以维持 HTTP 长连接, 也就是说服务器响应流不结束,TCP 连接就不会断开.

现在我们切换为分块传输编码模式,且我们不终止响应流,看会有什么情况:

const http = require('http')

const server = http.createServer((req, res) => {
  res.writeHead(200, {
    'Content-Type': 'text/plain'
    // 'Content-Length': 11, // ???? 将 Content-Length 报头去掉,Node.js 默认就是使用分块编码传输的
  })
  res.write('hello world')
  // res.end() // ???? 不终止输出流})

我们会发现请求会一直处于 Pending 状态 (绿色下载图标), 除非出现异常、服务器关闭或显式关闭连接(比如设置超时机制),请求是永远不会终止的。但是即使处于 Pending 状态客户端还是可以接收数据,不必等待请求结束:

基于这个原理我们再来创建一个简单的 ping-pong 服务器:

const server = http.createServer((req, res) => {if (req.url === '/ping') {
    // ping 请求
    if (pendingResponse == null) {res.writeHead(500);
      res.write('session not found');
      res.end();
      return;
    }
    res.writeHead(200)
    res.end()

    // 给客户端推流
    pendingResponse.write('pong\n');
  } else {
    // 保存句柄
    res.writeHead(200, {'Content-Type': 'text/plain',});
    res.write('welcome to ping\n');
    pendingResponse = res
  }
});

测试一下,在另一个窗口访问 /ping 路径:

Ok! 这就是 XHR-Streaming!

那么 Ajax 怎么接收这些数据呢?①一种做法是在 XMLHttpRequestonreadystatechange事件处理器中判断 readyState 是否等于 XMLHttpRequest.LOADING;②另外一种做法是在xhr.onprogress 事件处理器中处理。下面是 ping 客户端实现:

function listen() {const xhr = new XMLHttpRequest();
  xhr.onprogress = () => {
    // 注意 responseText 是获取服务端发送的所有数据,如果要获取未读数据,则需要进行划分
    console.log('progress', xhr.responseText);
  }
  xhr.open('POST', HOST);
  xhr.send(null);
}

function ping() {const xhr = new XMLHttpRequest();
  xhr.open('POST', HOST + '/ping');
  xhr.send(null);
}

listen();
setInterval(ping, 5000);

慢着,不要高兴得太早????. 如果运行上面的代码会发现 onprogress 并没有被正常的触发, 具体原因笔者也没有深入研究,我发现 sockjs 的服务器源码里面会预先写入 2049 个字节,这样就可以正常触发 onprogress 事件了:

const server = http.createServer((req, res) => {if (req.url === '/ping') {
    // ping 请求
    // ...
  } else {
    // 保存句柄
    res.writeHead(200, {'Content-Type': 'text/plain',});
    res.write(Array(2049).join('h') + '\n');
    pendingResponse = res
  }
});

最后再图解一下 XHR-streaming 的原理:

总结一下 XHR-Streaming 的特点:

  • 利用分块传输编码机制实现持久化连接(persistent connection): 服务器不关闭响应流,连接就不会关闭
  • 单工(unidirectional): 只允许服务器向浏览器单向的推送数据

通过 XHR-Streaming,可以允许服务端连续地发送消息,无需每次响应后再去建立一个连接, 所以它是除了 Websocket 之外最为高效的实时通信方案. 但它也并不是完美无缺

比如 XHR-streaming 连接的时间越长,浏览器会占用过多内存,而且在每一次新的数据到来时,需要对消息进行划分,剔除掉已经接收的数据. 因此 sockjs 对它进行了一点优化, 例如 sockjs 默认只允许每个 xhr-streaming 连接输出 128kb 数据,超过这个大小时会关闭输出流,让浏览器重新发起请求.


EventSource

了解了 XHR-Streaming, 就会觉得 EventSource 并不是什么新鲜玩意: 它就是上面讲的XHR-streaming, 只不过浏览器给它提供了标准的 API 封装和协议, 你抓包一看和 XHR-streaming 没有太大的区别:

上面可以看到请求的 Accepttext/event-stream, 且服务端写入的数据都有标准的约定, 即载荷需要这样组织:

const data = `data: ${payload}\r\n\r\n`

EventSource 的 API 和 Websocket 类似, 实例:

const evtSource = new EventSource('sse.php');

// 连接打开
evtSource.onopen = () => {}

// 接受消息
evtSource.onmessage = function(e) {
  // do something
  // ...
  console.log("message:" + e.data)

  // 关闭流
  evtSource.close()}

// 异常
evtSource.onerror = () => {}

因为是标准的,浏览器调试也比较方便,不需要借助第三方抓包工具:


HtmlFile

这是一种古老的‘秘术’????,虽然我们可能永远都不会再用到它,但是它的实现方式比较有意思(类似于 JSONP 这种黑科技), 所以还是值得讲一下。

HtmlFile 的另一个名字叫做 永久帧 (forever-frame), 顾名思义, 浏览器会打开一个隐藏的 iframe,这个 iframe 会请求一个分块传输编码的 html 文件(Transfer-Encoding: chunked), 和 XHR-Streaming 一样,这个请求永远都不会结束,服务器会不断在这个文档上输出内容。 这里面的要点是现代浏览器都会增量渲染 html 文件,所以服务器可以通过添加 script 标签在客户端执行某些代码,先来看个抓包的实例:

从上图可以看出:

  • ① 这里会给服务器传递一个 callback,通过这个 callback 将数据传递给父文档
  • ② 服务器每当有新的数据,就向文档追加一个 <script> 标签,script 的代码就是将数据传递给 callback。利用浏览器会被下载边解析 HTML 文档的特性,新增的 script 会马上被执行

最后还是用流程图描述一下:

除了 IE6、7 以下不支持,大部分浏览器都支持这个方案,当浏览器不支持 XHR-streaming 时,可以作为最佳备胎。


Polling

轮询是最粗暴(或者说最简单),也是效率最低下的‘实时’通信方案,这种方式的原理就是定期向服务器发起请求, 拉取最新的消息队列:

这种轮询方式比较合适 服务器的信息定期更新 的场景,如天气和股票行情信息。举个例子股票信息每隔 5 分钟更新一次,这时候客户端定期轮询, 且轮询间隔和服务端更新频率保持一致是一种理想的方式。

但是如果追求实时性,轮询会导致一些严重的问题:

  • 资源浪费。比如轮询的间隔小于服务器信息更新的频率,这会浪费很多 HTTP 请求, 消耗宝贵的 CPU 时间和带宽
  • 容易导致请求轰炸。比如当服务器负载比较高时,第一个请求还没处理完成,这时候第二、第三个请求接踵而来,无用的额外请求对服务端进行了轰炸。

Long polling

还有一种优化的轮询方法,称为长轮询 (Long Polling),sockjs 就是使用这种轮询方式, 长轮询指的是浏览器发送一个请求到服务器,服务器只有在有可用的新数据时才响应

客户端向服务端发起一个消息获取请求,服务端会将当前的消息队列返回给客户端,然后关闭连接。当消息队列为空时,服务端不会立即关闭连接,而是等待指定的时间间隔,如果在这个时间间隔内没有新的消息,则由客户端主动超时关闭连接

另外一个要点是,客户端的轮询请求只有在上一个请求连接关闭后才会重新发起。这就解决了上文的请求轰炸问题。服务端可以控制客户端的请求时序,因为在服务端未响应之前,客户端不会发送额外的请求(在超时期间内)。

扩展

  • WebRTC 这是浏览器的实时通信技术,它允许网络应用或者站点,在不借助中间媒介的情况下,建立浏览器之间点对点(Peer-to-Peer)的连接,实现视频流和(或)音频流或者其他任意数据的传输。
  • metetor DDP DDP(Distributed Data Protocol), 这是一个 ’ 有状态的 ’ 实时通信协议,这个是 Meteor 框架的基础, 它就是使用这个协议来进行客户端和服务端通信. 他只是一个协议,而不是通信技术,比如它的底层可以基于 Websocket、XHR-Streaming、长轮询甚至是 WebRTC
  • 程序员怎么会不知道 C10K 问题呢?– 池建强 - Medium
退出移动版