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

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

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

因此sockjs自己就是浏览器实时通讯方案的编年史, 本文也是按照由新到老这样的顺序来介绍这些解决方案.前端

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

若是你以为文章不错,请不要吝惜你的点赞👍,鼓励笔者写出更精彩的文章git

目录程序员


WebSocket

WebSocket其实不是本文的主角,并且网上已经有不少教程,本文的目的是介绍WebSocket以外的一些回退方案,在浏览器不支持Websocket的状况下, 能够选择回退到这些方案.github

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

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

后来人们发现为每一个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的协议细节,有兴趣的读者能够看下列文章:

若是不考虑低版本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会立刻被执行

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

除了IE六、7如下不支持,大部分浏览器都支持这个方案,当浏览器不支持XHR-streaming时,能够做为最佳备胎。


Polling

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

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

可是若是追求实时性,轮询会致使一些严重的问题:

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

Long polling

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

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

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

扩展

  • WebRTC 这是浏览器的实时通讯技术,它容许网络应用或者站点,在不借助中间媒介的状况下,创建浏览器之间点对点(Peer-to-Peer)的链接,实现视频流和(或)音频流或者其余任意数据的传输。
  • metetor DDP DDP(Distributed Data Protocol), 这是一个'有状态的'实时通讯协议,这个是Meteor框架的基础, 它就是使用这个协议来进行客户端和服务端通讯. 他只是一个协议,而不是通讯技术,好比它的底层能够基于Websocket、XHR-Streaming、长轮询甚至是WebRTC
  • 程序员怎么会不知道C10K 问题呢? - 池建强- Medium
相关文章
相关标签/搜索