本文主要探讨现阶段浏览器端可行的实时通信方案,以及它们的发展历史。
这里以 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 技术方案.
-
浏览器兼容性。
- IE10 以下不支持
- Safari 下不允许使用非标准接口建立连接
- 心跳. WebSocket 本身不会维护心跳机制,一些 Websocket 实现在空闲一段时间会自动断开。所以 sockjs 这些库会帮你维护心跳
- 一些负载均衡或代理不支持 Websocket。
- 会话和消息队列维护。这些不是 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 怎么接收这些数据呢?①一种做法是在 XMLHttpRequest
的onreadystatechange
事件处理器中判断 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 没有太大的区别:
上面可以看到请求的 Accept
为text/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