0%

Web性能权威指南 笔记

本文整理自:《Web性能权威指南》 作者:Ilya Grigorik

出版时间:2014-04

网络技术概览

带宽与延迟

延迟的最后一公里

traceroute (Windows 系统下是tracert) 命令利用ICMP协议定位您的计算机和目标计算机之间的所有路由器。

TCP的构成

因特网有两个核心协议:IP和TCP。IP,即 Internet Protocol(因特网协议),负责联网主机之间的路由选择和寻址;TCP,即 Transmission Control Protocol(传输控制协议),负责在不可靠的传输信道之上提供可靠的抽象层。

三次握手

所有TCP连接一开始都要经过三次握手(见图 2-1)。客户端与服务器在交换应用数据之前,必须就起始分组序列号,以及其他一些连接相关的细节达成一致。出于安全考虑,序列号由两端随机生成。

  • SYN
    客户端选择一个随机序列号x,并发送一个SYN分组,其中可能还包括其他TCP标志和选项。
  • SYN ACK
    服务器给x加1,并选择自己的一个随机序列号y,追加自己的标志和选项,然后返回响应。
  • ACK
    客户端给x和y加1并发送握手期间的最后一个ACK分组。

SYN:同步序列编号(Synchronize Sequence Numbers)
ACK (Acknowledge character)即是确认字符

三次握手完成后,客户端与服务器之间就可以通信了。客户端可以在发送ACK分组之后立即发送数据,而服务器必须等接收到ACK分组之后才能发送数据。这个启动通信的过程适用于所有TCP连接,因此对所有使用TCP的应用具有非常大的性能影响,因为每次传输应用数据之前,都必须经历一次完整的往返。

队首阻塞

丢包就丢包
事实上,丢包是让TCP达到最佳性能的关键。被删除的包恰恰是一种反馈机制,能够让接收端和发送端各自调整速度,以避免网络拥堵,同时保持延迟最短。另外,有些应用程序可以容忍丢失一定数量的包,比如语音和游戏状态通信,就不需要可靠传输或按序交付。
就算有个包丢了,音频编解码器只要在音频中插入一个小小的间歇,就可以继续处理后来的包。只要间歇够小,用户就注意不到,而等待丢失的包则可能导致音频输出产生无法预料的暂停。相对来说,后者的用户体验更糟糕。
类似地,更新3D游戏中角色的状态也一样:收到T时刻的包而等待T-1时刻的包通常毫无必要。理想情况下,应该可以接收所有状态更新,但为避免游戏延迟,间歇性的丢包也是可以接受的。

针对TCP的优化建议

TCP是一个自适应的、对所有网络节点一视同仁的、最大限制利用底层网络的协议。因此,优化TCP的最佳途径就是调整它感知当前网络状况的方式,根据它之上或之下的抽象层的类型和需求来改变它的行为。

服务器配置调优

在着手调整TCP的缓冲区、超时等数十个变量之前,最好先把主机操作系统升级到最新版本。TCP 的最佳实践以及影响其性能的底层算法一直在与时俱进,而且大多数变化都只在最新内核中才有实现。一句话,让你的服务器跟上时代是优化发送端和接收端TCP栈的首要措施。

有了最新的内核,我们推荐你遵循如下最佳实践来配置自己的服务器。

  • 增大TCP的初始拥塞窗口
    加大起始拥塞窗口可以让TCP在第一次往返就传输较多数据,而随后的速度提升也会很明显。对于突发性的短暂连接,这也是特别关键的一个优化。
  • 慢启动重启
    在连接空闲时禁用慢启动可以改善瞬时发送数据的长TCP连接的性能。
  • 窗口缩放
    启用窗口缩放可以增大最大接收窗口大小,可以让高延迟的连接达到更好吞吐量。
  • TCP快速打开
    在某些条件下,允许在第一个SYN分组中发送应用程序数据。TFO(TCP Fast Open,TCP 快速打开)是一种新的优化选项,需要客户端和服务器共同支持。为此,首先要搞清楚你的应用程序是否可以利用这个特性。

Linux用户可以使用ss来查看当前打开的套接字的各种统计信息。在命令行里运行ss –options –extended –memory –processes –info ,可以看到当前通信节点以及它们相应的连接设置。

UDP的构成

关于UDP的应用,最广为人知同时也是所有浏览器和因特网应用都赖以运作的,就是DNS(Domain Name System,域名系统)。

IETF和W3C工作组共同制定了一套新API WebRTC(Web Real-Time Communication,Web 实时通信)。WebRTC着眼于在浏览器中通过UDP实现原生的语音和视频实时通信,以及其他形式的 P2P(Peer-to-Peer,端到端)通信。

无协议服务

要理解为什么UDP被人称作“无协议”,必须从作为TCP和UDP下一层的IP协议说起。IP层的主要任务就是按照地址从源主机向目标主机发送数据报。为此,消息会被封装在一个IP分组内(图3-1),其中载明了源地址和目标地址,以及其他一些路由参数。注意,数据报这个词暗示了一个重要的信息:IP层不保证消息可靠的交付,也不发送失败通知,实际上是把底层网络的不可靠性直接暴露给了上一层。如果某个路由节点因为网络拥塞、负载过高或其他原因而删除了IP分组,那么在必要的情况下,IP 的上一层协议要负责检测、恢复和重发数据。

UDP协议会用自己的分组结构(图3-2)封装用户消息,它只增加 4个字段:源端口、目标端口、分组长度和校验和。这样,当IP把分组送达目标主机时,该主机能够拆开UDP分组,根据目标端口找到目标应用程序,然后再把消息发送过去。仅此而已。

事实上,UDP数据报中的源端口和校验和字段都是可选的。IP分组的首部也有校验和,应用程序可以忽略UDP校验和。也就是说,所有错误检测和错误纠正工作都可以委托给上层的应用程序。说到底,UDP仅仅是在IP层之上通过嵌入应用程序的源端口和目标端口,提供了一个“应用程序多路复用”机制。明白了这一点,就可以总结一下UDP的无服务是怎么回事了。

  • 不保证消息交付
    不确认,不重传,无超时。
  • 不保证交付顺序
    不设置包序号,不重排,不会发生队首阻塞。
  • 不跟踪连接状态
    不必建立连接或重启状态机。
  • 不需要拥塞控制
    不内置客户端或网络反馈机制。

TCP是一个面向字节流的协议,能够以多个分组形式发送应用程序消息,且对分组中的消息范围没有任何明确限制。因此,连接的两端存在一个连接状态,每个分组都有序号,丢失还要重发,并且要按顺序交付。相对来说,UDP数据报有明确的限制:数据报必须封装在IP分组中,应用程序必须读取完整的消息。换句话说,数据报不能分片。

UDP与网络地址转换器

作为监管全球IP地址分配的机构,IANA(Internet Assigned Numbers Authority,因特网号码分配机构)为私有网络保留了三段IP地址,这些IP地址经常可以在NAT设备后面的内网中看到。

保留的IP地址范围:

IP地址范围 地址数量
10.0.0.0~10.255.255.255 16 777 216
172.16.0.0~172.31.255.255 1 048 576
192.168.0.0~192.168.255.255 65 536

为防止路由错误和引起不必要的麻烦,不允许给外网计算机分配这些保留的私有IP地址。

传输层安全(TLS)

SSL(Secure Sockets Layer,安全套接字层)协议最初是网景公司为了保障网上交易安全而开发的,该协议通过加密来保护客户个人资料,通过认证和完整性检查来确保交易安全。为达到这个目标,SSL协议在直接位于TCP上一层的应用层被实现(图 4-1)。SSL不会影响上层协议(如HTTP、电子邮件、即时通讯),但能够保证上层协议的网络通信安全。

在正确使用SSL的情况下,第三方监听者只能推断出连接的端点、加密类型,以及发送数据的频率和大致数量,不能实际读取或修改任何数据。

加密、身份验证与完整性

Web 代理、中间设备、TLS 与新协议
HTTP 良好的扩展能力和获得的巨大成功,使得 Web 上出现了大量代理和中间设备:缓存服务器、安全网关、Web 加速器、内容过滤器,等等。有时候,我们知道这些设备的存在(显式代理),而有时候,这些设备对终端用户则完全不可见。

然而,这些服务器的存在及成功也给那些试图脱离 HTTP 协议的人带了一些不便。比如,有的代理服务器只会简单地转发自己无法解释的 HTTP 扩展或其他在线格式(wire format),而有的则不管是否必要都会对所有数据执行自己设定的逻辑,还有一些安全设备可能会把本来正常的数据误判成恶意通信。

换句话说,现实当中如果想脱离 HTTP 和 80 端口的语义行事,经常会遭遇各种部署上的麻烦。比如,某些客户端表现正常,另一些可能就会异常,甚至在某个网段表现正常的客户端到了另一个网段又会变得异常。

为解决这些问题,出现了一些新协议和对 HTTP 的扩展,比如 WebSocket、SPDY等。这些新协议一般要依赖于建立 HTTPS 信道,以绕过中间代理,从而实现可靠的部署,因为加密的传输信道会对所有中间设备都混淆数据。这样虽然解决了中间设备的问题,但却导致通信两端不能再利用这些中间设备,从而与这些设备提供的身份验证、缓存、安全扫描等功能失之交臂。

信任链与证书颁发机构

Web以及浏览器中的身份验证需要回答以下几个问题:我的浏览器信任谁?我在使用浏览器的时候信任谁?这个问题至少有三个答案。

  • 手工指定证书
    所有浏览器和操作系统都提供了一种手工导入信任证书的机制。至于如何获得证书和验证完整性则完全由你自己来定。
  • 证书颁发机构
    CA(Certificate Authority,证书颁发机构)是被证书接受者(拥有者)和依赖证书的一方共同信任的第三方。
  • 浏览器和操作系统
    每个操作系统和大多数浏览器都会内置一个知名证书颁发机构的名单。因此,你也会信任操作系统及浏览器提供商提供和维护的可信任机构。

实践中,保存并手工验证每个网站的密钥是不可行的(当然,如果你愿意,也可以)。现实中最常见的方案就是让证书颁发机构替我们做这件事(图 4-5):浏览器指定可信任的证书颁发机构(根CA),然后验证他们签署的每个站点的责任就转移到了他们头上,他们会审计和验证这些站点的证书没有被滥用或冒充。持有CA证书的站点的安全性如果遭到破坏,那撤销该证书也是证书颁发机构的责任。

所有浏览器都允许用户检视自己安全连接的信任链,常见的访问入口就是地址栏头儿上的锁图标,点击即可查看。

证书撤销

证书撤销名单(CRL)

CRL(Certificate Revocation List,证书撤销名单)是RFC 5280规定的一种检查所有证书状态的简单机制:每个证书颁发机构维护并定期发布已撤销证书的序列号名
单。这样,任何想验证证书的人都可以下载撤销名单,检查相应证书是否榜上有名。如果有,说明证书已经被撤销了。

CRL文件本身可以定期发布、每次更新时发布,或通过HTTP或其他文件传输协议来提供访问。这个名单同样由证书颁发机构签名,通常允许被缓存一定时间。实践中,这种机制效果很好,但也存在一些问题:

  • CRL名单会随着要撤销的证书增多而变长,每个客户端都必须取得包含所有序列号的完整名单;
  • 没有办法立即更新刚刚被撤销的证书序列号,比如客户端先缓存了CRL,之后某证书被撤销,那到缓存过期之前,该证书将一直被视为有效。

在线证书状态协议(OCSP)

为解决CRL机制的上述问题,RFC 2560定义了OCSP(Online Certificate Status Protocol,在线证书状态协议),提供了一种实时检查证书状态的机制。与CRL包含被撤销证书的序列号不同,OCSP 支持验证端直接查询证书数据库中的序列号,从而验证证书链是否有效。总之,OCSP 占用带宽更少,支持实时验证。

然而,没有什么机制是完美无缺的!实时OCSP查询也带了一些问题:

  • 证书颁发机构必须处理实时查询;
  • 证书颁发机构必须确保随时随地可以访问;
  • 客户端在进一步协商之前阻塞OCSP请求;
  • 由于证书颁发机构知道客户端要访问哪个站点,因此实时OCSP请求可能会泄露客户端的隐私。

实践中,CRL和OCSP机制是互补存在的,大多数证书既提供指令也支持查询。
更重要的倒是客户端的支持和行为。有的浏览器会分发自己的CRL名单,有的浏览器从证书颁发机构取得并缓存CRL文件。类似地,有的浏览器会进行实时OCSP检查,但在OCSP请求失败的情况下行为又会有所不同。要了解具体的情况,可以检查浏览器和操作系统的证书撤销网络设置。

针对TLS的优化建议

TLS记录大小

不过对于在浏览器中运行的Web应用来说,倒是有一个值得推荐的做法:每个TCP分组恰好封装一个TLS记录,而TLS记录大小恰好占满TCP分配的MSS(Maximum Segment Size,最大段大小)。换句话说,一方面不要让TLS记录分成多个TCP分组,另一方面又要尽量在一条记录中多发送数据。以下数据可作为确定最优TLS记录大小的参考:

  • IPv4 帧需要 20 字节,IPv6 需要 40 字节;
  • TCP 帧需要 20 字节;
  • TCP 选项需要 40 字节(时间戳、SACK 等)。

假设常见的MTU为1500字节,则TLS记录大小在IPv4下是1420字节,在IPv6下是1400字节。为确保向前兼容,建议使用IPv6下的大小:1400字节。当然,如果MTU更小,这个值也要相应调小。

可惜的是,我们不能在应用层控制TLS记录大小。TLS记录大小通常是一个设置,甚至是TLS服务器上的编译时常量或标志。要了解具体如何设置这个值,请参考服务器文档。

如果服务器要处理大量TLS连接,那么关键的优化是把每个连接占用的内存量控制在最小。默认情况下,OpenSSL等常用的库会给每个连接分配50KB空间,但正像设置记录大小一样,有必要查一查文档或者源代码,然后再决定如何调整这个值。谷歌的服务器把OpenSSL缓冲区的大小减少到了大约5KB。

TLS压缩

TLS还有一个内置的小功能,就是支持对记录协议传输的数据进行无损压缩。压缩算法在TLS握手期间商定,压缩操作在对记录加密之前执行。然而,出于如下原因,实践中往往需要禁用服务器上的TLS压缩功能:

  • 2012 年公布的“CRIME”攻击会利用TLS压缩恢复加密认证cookie,让攻击者实施会话劫持;
  • 传输级的TLS压缩不关心内容,可能会再次压缩已经压缩过的数据(图像、视频等等)。

双重压缩会浪费服务器和客户端的CPU时间,而且暴露的安全漏洞也很严重,因此请禁用TLS压缩。实践中,大多数浏览器会禁用TLS压缩,但即便如此你也应该在服务器的配置中明确禁用它,以保护用户的利益。

虽然不能使用TLS压缩,但应该使用服务器的Gzip设置压缩所有文本资源,同时对图像、视频、音频等媒体采用最合适的压缩格式。

无线网络性能

移动网络的优化建议

消除周期性及无效的数据传输

对推送而言,原生应用可以访问平台专有的推送服务,因此应该尽可能使用。对 Web 应用来说,可以使用SSE(Server Sent Events,服务器发送事件)和WebSocket以降低延迟时间和协议消耗,尽可能不使用轮询和更耗资源的XHR技术。

消除不必要的长连接

TCP或UDP连接的连接状态及生命期与设备的无线状态是相互独立的。换句话说,即便与运营商网络仍维持着(两端间)连接不中断,无线模块也可以处于低耗电状态。外部网络的分组到来时,运营商无线网络会通知设备,使其无线模块切换到连接状态,从而恢复数据传输。

明白了吗,应用不必让无线模块“活动”也可以保持连接不被断开。但不必要的长连接也有可能极大地消耗电量,而且由于人们对移动网络无线通信的误解,这种情况经常发生。

预测网络延迟上限

在移动网络中,一个HTTP请求很可能会导致一连串长达几百甚至上几千ms的网络延迟。这一方面是因为有往返延迟,另一方面也不能忘记DNS、TCP、TLS及控制面的延迟(图8-2)。

HTTP

HTTP 简史

HTTP 1.0:迅速发展及参考性RFC

今天,几乎所有Web服务器都支持,而且以后还会继续支持HTTP 1.0。除此之外,剩下的你都知道了。但HTTP 1.0对每个请求都打开一个新TCP连接严重影响性能。

HTTP 1.1:互联网标准

HTTP 1.1 标准厘清了之前版本中很多有歧义的地方,而且还加入了很多重要的性能优化:持久连接、分块编码传输、字节范围请求、增强的缓存机制、传输编码及请求管道

HTTP 1.1 改变了HTTP协议的语义,默认使用持久连接。换句话说,除非明确告知(通过Connection: close 首部),否则服务器默认会保持连接打开。

不过,这个功能也反向移植到了HTTP 1.0,可以通过Connection: Keep-Alive 首部来启用。实际上,如果你使用的是HTTP 1.1,从技术上说不需要Connection: Keep-Alive首部,但很多客户端还是选择加上它。

此外,HTTP 1.1 协议添加了内容、编码、字符集,甚至语言的协商机制,还添加了传输编码、缓存指令、客户端cookie 等十几个可以每次请求都协商的字段。

HTTP 2.0:改进传输性能

HTTP(Hypertext Transfer Protocol)是一个应用层协议,可用于分布协作式的超媒体系统。它是一个通用、无状态的协议。除了超文本,通过扩展它的请求方式、错误编码及首部,还可以将它用于很多其他领域,比如域名服务器和分布式对象管理系统。HTTP的一个功能就是允许数据的类型变化和协商,从而允许系统独立于被传输的数据构建。——RFC 2616:HTTP/1.1(1999 年 6 月)

当前,出现了一种保持HTTP语义,但脱离HTTP/1.x消息分帧及语法的协议用法。这种用法被证明有碍于性能,并且是在鼓励滥用底层传输协议。本工作组将制定一个新规范,从有序、半双工流的角度重新表达当前HTTP的语义。与HTTP/1.x一样,主要将使用TCP作为传输层,不过也应该支持其他传输协议。——HTTP 2.0 纲领 (2012 年 1 月)

HTTP 2.0 的主要目标是改进传输性能,实现低延迟和高吞吐量。主版本号的增加听起来像是要做大的改进,从性能角度说的确如此。但从另一方面看,HTTP的高层协议语义并不会因为这次版本升级而受影响。所有 HTTP 首部、值,以及它们的使用场景都不会变。

Web性能要点

剖析现代Web应用

速度、性能与用户期望

时间和用户感觉

时间 感觉
0 ~100 ms 很快
100~300 ms 有一点点慢
300~1000 ms 机器在工作呢
> 1000 ms 先干点别的吧
> 10000 ms 不能用了

这个表格解释了Web性能社区总结的经验法则:必须250 ms内渲染页面,或者至少提供视觉反馈,才能保证用户不走开!

HTTP 1.x

HTTP 1.0的优化策略非常简单,就一句话:升级到HTTP 1.1。完了!

改进 HTTP 的性能是 HTTP 1.1 工作组的一个重要目标,后来这个版本也引入了大量增强性能的重要特性,其中一些大家比较熟知的有:

  • 持久化连接以支持连接重用;
  • 分块传输编码以支持流式响应;
  • 请求管道以支持并行请求处理;
  • 字节服务以支持基于范围的资源请求; 
  • 改进的更好的缓存机制。

HTTP管道

HTTP 1.x 只能严格串行地返回响应。特别是,HTTP 1.x 不允许一个连接上的多个响应数据交错到达(多路复用),因而一个响应必须完全返回后,下一个响应才会开始传输。为说明这一点,我们可以看看服务器并行处理请求的情况(图 11-4)。

图 11-4 演示了如下几个方面:

  • HTML 和 CSS 请求同时到达,但先处理的是 HTML 请求;
  • 服务器并行处理两个请求,其中处理 HTML 用时 40 ms,处理 CSS 用时 20 ms;
  • CSS 请求先处理完成,但被缓冲起来以等候发送 HTML 响应;
  • 发送完 HTML 响应后,再发送服务器缓冲中的 CSS 响应。

HTTP 管道会导致 HTTP 服务器、代理和客户端出现很多微妙的,不见文档记载的问题:

  • 一个慢响应就会阻塞所有后续请求;
  • 并行处理请求时,服务器必须缓冲管道中的响应,从而占用服务器资源,如果有个响应非常大,则很容易形成服务器的受攻击面;
  • 响应失败可能终止 TCP 连接,从页强迫客户端重新发送对所有后续资源的请求,导致重复处理;
  • 由于可能存在中间代理,因此检测管道兼容性,确保可靠性很重要;
  • 如果中间代理不支持管道,那它可能会中断连接,也可能会把所有请求串联起来。

今天,一些支持管道的浏览器,通常都将其作为一个高级配置选项,但大多数浏览器都会禁用它。换句话说,如果浏览器是 Web 应用的主要交付工具,那还是很难指望通过 HTTP 管道来提升性能。

要在你自己的应用中启用管道,要注意如下事项:

  • 确保 HTTP 客户端支持管道;
  • 确保 HTTP 服务器支持管道;
  • 应用必须处理中断的连接并恢复;
  • 应用必须处理中断请求的幂等问题;
  • 应用必须保护自身不受出问题的代理的影响。

实践中部署 HTTP 管道的最佳途径,就是在客户端和服务器间使用安全通道(HTTPS)。这样,就能可靠地避免那些不理解或不支持管道的中间代理的干扰。

使用多个TCP连接

由于 HTTP 1.x 不支持多路复用,浏览器可以不假思索地在客户端排队所有 HTTP请求,然后通过一个持久连接,一个接一个地发送这些请求。然而,这种方式在实践中太慢。实际上,浏览器开发商没有别的办法,只能允许我们并行打开多个 TCP会话。多少个?现实中,大多数现代浏览器,包括桌面和移动浏览器,都支持每个主机打开 6 个连接。

消耗客户端和服务器资源
限制每个主机最多 6 个连接,可以让浏览器检测出无意(或有意)的 DoS(Denial of Service)攻击。如果没有这个限制,客户端有可能消耗掉服务器的所有资源。讽刺的是,同样的安全检测在某些浏览器上却会招致反向攻击:如果客户端超过
了最大连接数,那么所有后来的客户端请求都将被阻塞。大家可以做个试验,在一个主机上同时打开 6 个并行下载,然后再打开第 7 个下载请求,这个请求会挂起,直到前面的请求完成才会执行。

用足客户端连接的限制似乎是一个可以接受的安全问题,但对于需要实时交付数据的应用而言,这样做越来越容易造成部署上的问题。比如 WebSocket、ServerSent Event 和挂起 XHR,这些会话都会占用整整一个 TCP 流,而不管有无数据传输——记住,没有多路复用一说!实际上,如果你不注意,那很可能自己对自己的应用施加 DoS 攻击。

域名分区

根据 HTTP Archive 的统计,目前平均每个页面都包含 90 多个独立的资源,如果这些资源都来自同一个主机,那么仍然会导致明显的排队等待。实际上,何必把自己只限制在一个主机上呢?我们不必只通过一个主机(例如 www.example.com)提供所有资源,而是可以手工将所有资源分散到多个子域名:{shard1,shardn}.example.com。由于主机名称不一样了,就可以突破浏览器的连接限制,实现更高的并行能力。域名分区使用得越多,并行能力就越强!

当然,天下没有免费的午餐,域名分区也不例外:每个新主机名都要求有一次额外的 DNS 查询,每多一个套接字都会多消耗两端的一些资源,而更糟糕的是,站点作者必须手工分离这些资源,并分别把它们托管到多个主机上。

实践中,把多个域名(如 shard1.example.com、shard2.example.com)解析到同一个 IP 地址是很常见的做法。所有分区都通过 CNAME DNS 记录指向同一个服务器,而浏览器连接限制针对的是主机名,不是 IP 地址。另外,每个分区也可以指向一个 CDN 或其他可以访问到的服务器。

DNS 查询和 TCP 慢启动导致的额外消耗对高延迟客户端的影响最大。换句话说,移动(3G、4G)客户端经常是受过度域名分区影响最大的!

Cookie 在很多应用中都是常见的性能瓶颈,很多开发者都会忽略它给每次请求增加的额外负担。

计算图片对内存的需求
所有编码的图片经浏览器解析后都会以 RGBA 位图的形式保存于内存当中。每个RGBA 图片的像素需要占用 4 字节:红、绿、蓝通道各占 1 字节,Alpha(透明)通道占 1 字节。这样算下来,一张图片占用的内存量就是图片像素宽度 × 像素高度 ×4 字节。
举个例子,800×600 像素的位图会占多大内存呢?
800 × 600 × 4 B = 1 920 000 B ≈ 1.83 MB
在资源受限的设备,比如手机上,内存占用很快就会成为瓶颈。对于游戏等严重依赖图片的应用来说,这个问题就会更明显。

打包文件到底多大合适呢?可惜的是,没有理想的大小。然而,谷歌 PageSpeed团队的测试表明,30~50 KB(压缩后)是每个 JavaScript 文件大小的合适范围:既大到了能够减少小文件带来的网络延迟,还能确保递增及分层式的执行。具体的结果可能会由于应用类型和脚本数量而有所不同。

嵌入资源

嵌入资源是另一种非常流行的优化方法,把资源嵌入文档可以减少请求的次数。比如,JavaScript 和 CSS 代码,通过适当的 script 和 style 块可以直接放在页面中,而图片甚至音频或 PDF 文件,都可以通过数据 URI(data:[mediatype][;base64],data )的方式嵌入到页面中:

1
2
3
<img src="data:image/gif;base64,R0lGODlhAQABAIAAAAA
AAAAAACH5BAAAAAAALAAAAAABAAEAAAICTAEAOw=="
alt="1x1 transparent (GIF) pixel" />

前面的例子是在文档中嵌入了一个 1×1 的透明 GIF 像素。而任何 MIME类型,只要浏览器能理解,都可以通过类似方式嵌入到页面中,包括PDF、音频、视频。不过,有些浏览器会限制数据 URI 的大小,比如 IE8最大只允许 32 KB。

数据 URI 适合特别小的,理想情况下,最好是只用一次的资源。以嵌入方式放到页面中的资源,应该算是页面的一部分,不能被浏览器、CDN 或其他缓存代理作为单独的资源缓存。换句话说,如果在多个页面中都嵌入同样的资源,那么这个资源将
会随着每个页面的加载而被加载,从而增大每个页面的总体大小。另外,如果嵌入资源被更新,那么所有以前出现过它的页面都将被宣告无效,而由客户端重新从服务器获取。

最后,虽然 CSS 和 JavaScript 等基于文本的资源很容易直接嵌入页面,也不会带来多余的开销,但非文本性资源则必须通过 base64 编码,而这会导致开销明显增大:编码后的资源大小比原大小增大 33% !

base64 编码使用 64 个 ASCII 符号和空白符将任意字节流编码为 ASCII字符串。编码过程中,base64 会导致被编码的流变成原来的 4/3,即增大33% 的字节开销。

实践中,常见的一个经验规则是只考虑嵌入 1~2 KB 以下的资源,因为小于这个标准的资源经常会导致比它自身更高的 HTTP 开销。然而,如果嵌入的资源频繁变更,又会导致宿主文档的无效缓存率升高。嵌入资源也不是完美的方法。如果你的应用要使用很小的、个别的文件,在考虑是否嵌入时,可以参照如下建议:

  • 如果文件很小,而且只有个别页面使用,可以考虑嵌入;
  • 如果文件很小,但需要在多个页面中重用,应该考虑集中打包;
  • 如果小文件经常需要更新,就不要嵌入了;
  • 通过减少 HTTP cookie 的大小将协议开销最小化。

HTTP 2.0

HTTP 2.0 的目的就是通过支持请求与响应的多路复用来减少延迟,通过压缩 HTTP首部字段将协议开销降至最低,同时增加对请求优先级和服务器端推送的支持。

HTTP 2.0 不会改动 HTTP 的语义。HTTP 方法、状态码、URI 及首部字段,等等这些核心概念一如往常。但是,HTTP 2.0 修改了格式化数据(分帧)的方式,以及客户端与服务器间传输这些数据的方式。这两点统帅全局,通过新的组帧机制向我们的应用隐藏了所有复杂性。换句话说,所有原来的应用都可以不必修改而在新协议运行。这当然是好事。

走向HTTP 2.0

在此,有必要回顾一下 HTTP 2.0 宣言草稿,因为这份宣言明确了该协议的范围和关键设计要求:

HTTP/2.0 应该满足如下条件:

  • 相对于使用 TCP 的 HTTP 1.1,用户在大多数情况下的感知延迟要有实质上、可度量的改进;
  • 解决 HTTP 中的“队首阻塞”问题;
  • 并行操作无需与服务器建立多个连接,从而改进 TCP 的利用率,特别是拥塞控制方面;
  • 保持 HTTP 1.1 的语义,利用现有文档,包括(但不限于)HTTP 方法、状态码、URI,以及首部字段;
  • 明确规定 HTTP 2.0 如何与 HTTP 1.x 互操作,特别是在中间介质上;
  • 明确指出所有新的可扩展机制以及适当的扩展策略。

之所以要递增一个大版本到 2.0,主要是因为它改变了客户端与服务器之间交换数据的方式。为实现宏伟的性能改进目标,HTTP 2.0增加了新的二进制分帧数据层,而这一层并不兼容之前的 HTTP 1.x 服务器及客户端——是谓 2.0。

除非你在实现 Web 服务器或者定制客户端,需要使用原始的 TCP 套接字,否则你很可能注意不到 HTTP 2.0 技术面的实际变化:所有新的、低级分帧机制都是浏览器和服务器为你处理的。或许唯一的区别就是可选的 API多了一些,比如服务器推送!

设计和技术目标

HTTP/2.0 通过支持首部字段压缩和在同一连接上发送多个并发消息,让应用更有效地利用网络资源,减少感知的延迟时间。而且,它还支持服务器到客户端的主动推送机制。——HTTP/2.0,Draft 4

二进制分帧层

HTTP 2.0 性能增强的核心,全在于新增的二进制分帧层(图 12-1),它定义了如何封装 HTTP 消息并在客户端与服务器之间传输。

这里所谓的“层”,指的是位于套接字接口与应用可见的高层 HTTP API 之间的一个新机制:HTTP 的语义,包括各种动词、方法、首部,都不受影响,不同的是传输期间对它们的编码方式变了。HTTP 1.x 以换行符作为纯文本的分隔符,而 HTTP2.0 将所有传输的信息分割为更小的消息和帧,并对它们采用二进制格式的编码。

流、消息和帧


  • 已建立的连接上的双向字节流。
  • 消息
    与逻辑消息对应的完整的一系列数据帧。

  • HTTP 2.0 通信的最小单位,每个帧包含帧首部,至少也会标识出当前帧所属的流。

所有 HTTP 2.0 通信都在一个连接上完成,这个连接可以承载任意数量的双向数据流。相应地,每个数据流以消息的形式发送,而消息由一或多个帧组成,这些帧可以乱序发送,然后再根据每个帧首部的流标识符重新组装。

HTTP 2.0 的所有帧都采用二进制编码,所有首部数据都会被压缩。因此,图 12-2 只是说明了数据流、消息和帧之间的关系,而非它们实际传输时的编码结果。

要理解 HTTP 2.0,就必须理解流、消息和帧这几个基本概念。

  • 所有通信都在一个 TCP 连接上完成。
  • 流是连接中的一个虚拟信道,可以承载双向的消息;每个流都有一个唯一的整数标识符(1、2…N)。
  • 消息是指逻辑上的 HTTP 消息,比如请求、响应等,由一或多个帧组成。
  • 帧是最小的通信单位,承载着特定类型的数据,如 HTTP 首部、负荷,等等。

简言之,HTTP 2.0 把 HTTP 协议通信的基本单位缩小为一个一个的帧,这些帧对应着逻辑流中的消息。相应地,很多流可以并行地在同一个 TCP 连接上交换消息。

多向请求与响应

在 HTTP 1.x 中,如果客户端想发送多个并行的请求以及改进性能,那么必须使用多个 TCP 连接。这是 HTTP 1.x 交付模型的直接结果,该模型会保证每个连接每次只交付一个响应(多个响应必须排队)。更糟糕的是,这种模型也会导致队首阻塞,从而造成底层 TCP 连接的效率低下。

HTTP 2.0 中新的二进制分帧层突破了这些限制,实现了多向请求和响应:客户端和服务器可以把 HTTP 消息分解为互不依赖的帧(图 12-3),然后乱序发送,最后再在另一端把它们重新组合起来。

图 12-3 中包含了同一个连接上多个传输中的数据流:客户端正在向服务器传输一个DATA 帧(stream 5),与此同时,服务器正向客户端乱序发送 stream 1 和 stream 3的一系列帧。此时,一个连接上有 3 个请求/响应并行交换!

总之,HTTP 2.0 的二进制分帧机制解决了 HTTP 1.x 中存在的队首阻塞问题,也消除了并行处理和发送请求及响应时对多个连接的依赖。

支持多向请求与响应,可以省掉针对 HTTP 1.x 限制所费的那些脑筋和工作,比如拼接文件、图片精灵、域名分区。类似地,通过减少 TCP 连接的数量,HTTP 2.0 也会减少客户端和服务器的 CPU 及内存占用。

请求优先级

把 HTTP 消息分解为很多独立的帧之后,就可以通过优化这些帧的交错和传输顺序,进一步提升性能。为了做到这一点,每个流都可以带有一个 31 比特的优先值:

  • 0 表示最高优先级;
  • 2^31 -1 表示最低优先级。

有了这个优先值,客户端和服务器就可以在处理不同的流时采取不同的策略,以最优的方式发送流、消息和帧。具体来讲,服务器可以根据流的优先级,控制资源分配(CPU、内存、带宽),而在响应数据准备好之后,优先将最高优先级的帧发送给客户端。

浏览器请求优先级与 HTTP 2.0
浏览器在渲染页面时,并非所有资源都具有相同的优先级:HTML 文档本身对构建 DOM 不可或缺,CSS 对构建 CSSOM 不可或缺,而 DOM 和 CSSOM 的构建都可能受到 JavaScript 资源的阻塞,其他资源(如图片)的优先级都可以降低。
为加快页面加载速度,所有现代浏览器都会基于资源的类型以及它在页面中的位置排定请求的优先次序,甚至通过之前的访问来学习优先级模式——比如,之前的渲染如果被某些资源阻塞了,那么同样的资源在下一次访问时可能就会被赋予更高的优先级。
在 HTTP 1.x 中,浏览器极少能利用上述优先级信息,因为协议本身并不支持多路复用,也没有办法向服务器通告请求的优先级。此时,浏览器只能依赖并行连接,且最多只能同时向一个域名发送 6 个请求。于是,在等连接可用期间,请求只能
在客户端排队,从而增加了不必要的网络延迟。理论上,HTTP 管道可以解决这个问题,只是由于缺乏支持而无法付诸实践。
HTTP 2.0 一举解决了所有这些低效的问题:浏览器可以在发现资源时立即分派请求,指定每个流的优先级,让服务器决定最优的响应次序。这样请求就不必排队了,既节省了时间,也最大限度地利用了每个连接。

HTTP 2.0 没有规定处理优先级的具体算法,只是提供了一种赋予数据优先级的机制,而且要求客户端与服务器必须能够交换这些数据。这样一来,优先值作为提示信息,对应的次序排定策略可能因客户端或服务器的实现而不同:客户端应该明确指定优先值,服务器应该根据该值处理和交付数据。

在这个规定之下,尽管你可能无法控制客户端发送的优先值,但或许你可以控制服务器。因此,在选择 HTTP 2.0 服务器时,可以多留点心!为说明这一点,考虑下面几个问题。

  • 如果服务器对所有优先值视而不见怎么办?
  • 高优先值的流一定优先处理吗?
  • 是否存在不同优先级的流应该交错的情况?
    如果服务器不理睬所有优先值,那么可能会导致应用响应变慢:浏览器明明在等关键的 CSS 和 JavaScript,服务器却在发送图片,从而造成渲染阻塞。不过,规定严格的优先级次序也可能带来次优的结果,因为这可能又会引入队首阻塞问题,即某
    个高优先级的慢请求会不必要地阻塞其他资源的交付。

服务器可以而且应该交错发送不同优先级别的帧。只要可能,高优先级流都应该优先,包括分配处理资源和客户端与服务器间的带宽。不过,为了最高效地利用底层连接,不同优先级的混合也是必需的。

每个来源一个连接

有了新的分帧机制后,HTTP 2.0 不再依赖多个 TCP 连接去实现多流并行了。现在,每个数据流都拆分成很多帧,而这些帧可以交错,还可以分别优先级。于是,所有HTTP 2.0 连接都是持久化的,而且客户端与服务器之间也只需要一个连接即可。

实验表明,客户端使用更少的连接肯定可以降低延迟时间。HTTP 2.0 发送的总分组数量比 HTTP 差不多要少 40%。而服务器处理大量并发连接的情况也变成了可伸缩性问题,因为 HTTP 2.0 减轻了这个负担。
——HTTP/2.0 Draft 2

每个来源一个连接显著减少了相关的资源占用:连接路径上的套接字管理工作量少了,内存占用少了,连接吞吐量大了。此外,从上到下所有层面上也都获得了相应的好处:

  • 所有数据流的优先次序始终如一;
  • 压缩上下文单一使得压缩效果更好;
  • 由于 TCP 连接减少而使网络拥塞状况得以改观;
  • 慢启动时间减少,拥塞和丢包恢复速度更快。

大多数 HTTP 连接的时间都很短,而且是突发性的,但 TCP 只在长时间连接传输大块数据时效率才最高。HTTP 2.0 通过让所有数据流共用同一个连接,可以更有效地使用 TCP 连接。

丢包、高 RTT 连接和 HTTP 2.0 性能
等一等,我听你说了一大堆每个来源一个 TCP 连接的好处,难道它就一点坏处都
没有吗?有,当然有。;

  • 虽然消除了 HTTP 队首阻塞现象,但 TCP 层次上仍然存在队首阻塞;
  • 如果 TCP 窗口缩放被禁用,那带宽延迟积效应可能会限制连接的吞吐量;
  • 丢包时,TCP 拥塞窗口会缩小。

上述每一点都可能对 HTTP 2.0 连接的吞吐量和延迟性能造成不利影响。然而,除了这些局限性之外,实验表明一个 TCP 连接仍然是 HTTP 2.0 基础上的最佳部署策略。

总之,一定要知道 HTTP 2.0 与之前的版本一样,并不强制使用 TCP。UDP 等其他传输协议也并非不可以。

流量控制

在同一个 TCP 连接上传输多个数据流,就意味着要共享带宽。标定数据流的优先级有助于按序交付,但只有优先级还不足以确定多个数据流或多个连接间的资源分配。为解决这个问题,HTTP 2.0 为数据流和连接的流量控制提供了一个简单的机制:

  • 流量控制基于每一跳进行,而非端到端的控制;
  • 流量控制基于窗口更新帧进行,即接收方广播自己准备接收某个数据流的多少字节,以及对整个连接要接收多少字节;
  • 流量控制窗口大小通过WINDOW_UPDATE 帧更新,这个字段指定了流 ID 和窗口大小递增值;
  • 流量控制有方向性,即接收方可能根据自己的情况为每个流乃至整个连接设置任意窗口大小;
  • 流量控制可以由接收方禁用,包括针对个别的流和针对整个连接。

HTTP 2.0 连接建立之后,客户端与服务器交换 SETTINGS 帧,目的是设置双向的流量控制窗口大小。除此之外,任何一端都可以选择禁用个别流或整个连接的流量控制。

优先级可以决定交付次序,而流量控制则可以控制 HTTP 2.0 连接中每个流占用的资源:接收方可以针对特定的流广播较低的窗口大小,以限制它的传输速度。

服务器推送

HTTP 2.0 新增的一个强大的新功能,就是服务器可以对一个客户端请求发送多个响应。换句话说,除了对最初请求的响应外,服务器还可以额外向客户端推送资源,而无需客户端明确地请求。

建立 HTTP 2.0 连接后,客户端与服务器交换 SETTINGS 帧,借此可以限定双向并发的流的最大数量。因此,客户端可以限定推送流的数量,或者通过把这个值设置为 0 而完全禁用服务器推送。

为什么需要这样一个机制呢?通常的 Web 应用都由几十个资源组成,客户端需要分析服务器提供的文档才能逐个找到它们。那为什么不让服务器提前就把这些资源推送给客户端,从而减少额外的时间延迟呢?服务器已经知道客户端下一步要请求什么资源了,这时候服务器推送即可派上用场。事实上,如果你在网页里嵌入过 CSS、JavaScript,或者通过数据 URI 嵌入过其他资源,那你就已经亲身体验过服务器推送了。

所有推送的资源都遵守同源策略。换句话说,服务器不能随便将第三方资源推送给客户端,而必须是经过双方确认才行。

首部压缩

HTTP 的每一次通信都会携带一组首部,用于描述传输的资源及其属性。在 HTTP 1.x 中,这些元数据都是以纯文本形式发送的,通常会给每个请求增加 500~800 字节的负荷。如果算上 HTTP cookie,增加的负荷通常会达到上千字节。为减少这些开销并提升性能,HTTP 2.0 会压缩首部元数据:

  • HTTP 2.0 在客户端和服务器端使用“首部表”来跟踪和存储之前发送的键-值对,对于相同的数据,不再通过每次请求和响应发送;
  • 首部表在HTTP 2.0的连接存续期内始终存在,由客户端和服务器共同渐进地更新;
  • 每个新的首部键-值对要么被追加到当前表的末尾,要么替换表中之前的值

于是,HTTP 2.0 连接的两端都知道已经发送了哪些首部,这些首部的值是什么,从而可以针对之前的数据只编码发送差异数据(图 12-5)。

请求与响应首部的定义在 HTTP 2.0 中基本没有改变,只是所有首部键必须全部小写,而且请求行要独立为 :method 、 :scheme 、 :host 和 :path 这些键-值对。

在前面的例子中,第二个请求只需要发送变化了的路径首部(:path),其他首部没有变化,不用再发送了。这样就可以避免传输冗余的首部,从而显著减少每个请求的开销。通信期间几乎不会改变的通用键-值对(用户代理、可接受的媒体类型,等等)只需发送一次。事实上,如果请求中不包含首部(例如对同一资源的轮询请求),那么首部开销就是零字节。此时所有首部都自动使用之前请求发送的首部!

有效的HTTP 2.0升级与发现

通过常规非加密信道建立 HTTP 2.0 连接需要多做一点工作。因为 HTTP 1.0 和HTTP 2.0 都使用同一个端口(80),又没有服务器是否支持 HTTP 2.0 的其他任何信息,此时客户端只能使用 HTTP Upgrade 机制通过协调确定适当的协议:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
GET /page HTTP/1.1
Host: server.example.com
Connection: Upgrade, HTTP2-Settings
Upgrade: HTTP/2.0 ➊
HTTP2-Settings: (SETTINGS payload) ➋
HTTP/1.1 200 OK ➌
Content-length: 243
Content-type: text/html
(... HTTP 1.1 response ...)
(or)
HTTP/1.1 101 Switching Protocols ➍
Connection: Upgrade
Upgrade: HTTP/2.0
(... HTTP 2.0 response ...)

➊ 发起带有 HTTP 2.0 Upgrade 首部的 HTTP 1.1 请求
➋ HTTP/2.0 SETTINGS 净荷的 Base64 URL 编码
➌ 服务器拒绝升级,通过 HTTP 1.1 返回响应
➍ 服务器接受 HTTP 2.0 升级,切换到新分帧

使用这种 Upgrade 流,如果服务器不支持 HTTP 2.0,就立即返回 HTTP 1.1 响应。否则,服务器就会以 HTTP 1.1 格式返回 101 Switching Protocols 响应,然后立即切换到 HTTP 2.0 并使用新的二进制分帧协议返回响应。无论哪种情况,都不需要额外往返。

为确定服务器和客户端都有意使用 HTTP 2.0 对话,双方还必须发送“连接首部”,也就是一串标准的字节。这种信息交换本质上是一种“尽早失败”(fail-fast)的机制,可以避免客户端、服务器,以及中间设备偶尔接受请
求的升级却不理解新协议。而且,这种信息交换也不会带来额外的往返,只是在连接开始时要多传一些字节。

最后,如果客户端因为自己保存有或通过其他手段(如 DNS 记录、手工配置等)获得了关于 HTTP 2.0 的支持信息,它也可以直接发送 HTTP 2.0 分帧,而不必依赖Upgrade 机制。有了这些信息,客户端可以一上来就通过非加密信道发送 HTTP 2.0 分帧,其他就不管了。最坏的情况,就是无法建立连接,客户端再回退一步,重新使用 Upgrade 首部,或者切换到带 ALPN 协商的 TLS 信道。

服务器之前的 HTTP 2.0 支持信息并不能保证下一次就能可靠地建立连接。以这种方式通信的前提,就是各端都必须支持 HTTP 2.0。如果任何中间设备不支持,连接都不会成功。

二进制分帧简介

HTTP 2.0 的根本改进还是新增的长度前置的二进制分帧层。

建立了 HTTP 2.0 连接后,客户端与服务器会通过交换帧来通信,帧是基于这个新协议通信的最小单位。所有帧都共享一个 8 字节的首部(图 12-6),其中包含帧的长度、类型、标志,还有一个保留位和一个 31 位的流标识符。

  • 16 位的长度前缀意味着一帧大约可以携带 64 KB 数据,不包括 8 字节首部。
  • 8 位的类型字段决定如何解释帧其余部分的内容。
  • 8 位的标志字段允许不同的帧类型定义特定于帧的消息标志。
  • 1 位的保留字段始终置为 0。
  • 31 位的流标识符唯一标识 HTTP 2.0 的流。

在调试 HTTP 2.0 通信时,有人会使用自己喜欢的十六进制查看器。其实,Wireshark 及其他类似的工具也有相应的插件,使用很简单,也很人性化。比如,谷歌 Chrome 就支持 chrome://internals#spdy ,通过它可以查看通信细节。

知道了 HTTP 2.0 规定的这个共享的帧首部,就可以自己编写一个简单的解析器,通过分析 HTTP 2.0 字节流,根据每个帧的前 8 字节找到帧的类型、标志和长度。而且,由于每个帧的长度都是预先定义好的,解析器可以迅速而准确地跳到下一帧的开始,这也是相对于 HTTP 1.x 的一个很大的性能提升。

知道了帧类型,解析器就知道该如何解释帧的其余内容了。HTTP 2.0 规定了如下帧类型。

  • DATA:用于传输 HTTP 消息体。
  • HEADERS:用于传输关于流的额外的首部字段。
  • PRIORITY:用于指定或重新指定引用资源的优先级。
  • RST_STREAM:用于通知流的非正常终止。
  • SETTINGS:用于通知两端通信方式的配置数据。
  • PUSH_PROMISE:用于发出创建流和服务器引用资源的要约。
  • PING:用于计算往返时间,执行“活性”检查。
  • GOAWAY:用于通知对端停止在当前连接中创建流。
  • WINDOW_UPDATE:用于针对个别流或个别连接实现流量控制。
  • CONTINUATION:用于继续一系列首部块片段。

服务器可以利用 GOAWAY 类型的帧告诉客户端要处理的最后一个流的 ID,从而消除一些请求竞争,而且浏览器也可以据此智能地重试或取消“悬着的”请求。这也是保证复用连接安全的一个重要和必要的功能!

既然有了这个分帧层,即使它对我们的应用不可见,我们也应该更进一步,分析一下两种最常见的工作流:发起新流和交换应用数据。只有明白了一个请求或响应如何转换成一个一个的帧,才能理解 HTTP 2.0 对性能的提升来自哪里。

固定长度与可变长度字段
HTTP 2.0 只使用固定长度字段,HTTP 2.0 帧占用带宽很少(帧首部是 8 字节)。采用可变长度编码的确可以节省一点带宽和时延,但却无法抵偿由此带来的分析复杂性。
即使可变长度编码能减少 50% 的带宽占用,那么在 1 Mbit/s 的连接上传输 1400 字节的分组,也只能节省 4 字节(0.3%)和每帧不到 100 纳秒的延迟时间。

发起新流

在发送应用数据之前,必须创建一个新流并随之发送相应的元数据,比如流优先级、HTTP 首部等。HTTP 2.0 协议规定客户端和服务器都可以发起新流,因此有两种可能:

  • 客户端通过发送HEADERS 帧来发起新流(图 12-7),这个帧里包含带有新流 ID 的公用首部、可选的 31 位优先值,以及一组 HTTP 键-值对首部;
  • 服务器通过发送PUSH_PROMISE 帧来发起推送流,这个帧与 HEADERS 帧等效,但它包含“要约流 ID”,没有优先值。

这两种帧的类型字段都只用于沟通新流的元数据,净荷会在 DATA 帧中单独发送。同样,由于两端都可以发起新流,流计数器偏置:客户端发起的流具有偶数 ID,服务器发起的流具有奇数 ID。这样,两端的流 ID 不会冲突,而且各自持有一个简单的计数器,每次发起新流时递增 ID 即可。

由于流的元数据与应用数据是单独发送的,因此客户端和服务器可以分别给它们设定不同的优先级。比如,“控制流量”的流优先级可以高一些,但只将其应用给 DATA 帧。

发送应用数据

创建新流并发送 HTTP 首部之后,接下来就是利用 DATA 帧(图 12-8)发送应用数据。应用数据可以分为多个 DATA 帧,最后一帧要翻转帧首部的 END_STREAM 字段。

数据净荷不会被另行编码或压缩。编码方式取决于应用或服务器,纯文本、gzip 压缩、图片或视频压缩格式都可以。既然如此,关于 DATA 帧再也没有什么新东西好说了!整个帧由公用的 8 字节首部,后跟 HTTP 净荷组成。

从技术上说, DATA 帧的长度字段决定了每帧的数据净荷最多可达 2^16-1(65 535)字节。可是,为减少队首阻塞,HTTP 2.0 标准要求 DATA 帧不能超过 2^14 -1(16383)字节。长度超过这个阀值的数据,就得分帧发送。

HTTP 2.0帧数据流分析

  • 有 3 个活动的流:stream 1、stream 3 和 stream 5。
  • 3 个流的 ID 都是奇数,说明都是客户端发起的。
  • 这里没有服务器发起的流。
  • 服务器发送的 stream 1 包含多个DATA 帧,这是对客户端之前请求的响应数据。
  • 这也说明在此之前已经发送过 HEADERS 帧了。
  • 服务器在交错发送 stream 1 的DATA 帧和 stream 3 的 HEADERS 帧,这就是响应的多路复用!
  • 客户端正在发送 stream 5 的DATA 帧,表明 HEADERS 帧之前已经发送过了。

简言之,图 12-3 中连接正在并行传送 3 个数据流,每个流都处于各自处理周期的不同阶段。服务器决定帧的顺序,而我们不用关心每个流的类型或内容。stream 1 携带的数据量可能比较大,也许是视频,但它不会阻塞共享连接中的其他流!

优化应用的交付

事实上,影响绝大多数 Web 应用性能的并非带宽,而是延迟。网速虽然越来越快,但不幸的是,延迟似乎并没有缩短。

说到底,成功的、可持续的 Web 性能优化策略其实很简单:先度量,然后拿业务目标与性能指标进行比较,采取优化措施,紧了松点,松了紧点,如此反复。开发和购买合用的度量工具及选择恰当的度量手段具有最高优先级;

经典的性能优化最佳实践

无论什么网络,也不管所用网络协议是什么版本,所有应用都应该致力于消除或减少不必要的网络延迟,将需要传输的数据压缩至最少。这两条标准是经典的性能优化最佳实践,是其他数十条性能准则的出发点。

  • 减少DNS查找
    每一次主机名解析都需要一次网络往返,从而增加请求的延迟时间,同时还会阻塞后续请求。
  • 重用TCP连接
    尽可能使用持久连接,以消除 TCP 握手和慢启动延迟。
  • 减少HTTP重定向
    HTTP 重定向极费时间,特别是不同域名之间的重定向,更加费时;这里面既有额外的 DNS 查询、TCP 握手,还有其他延迟。最佳的重定向次数为零。
  • 使用CDN(内容分发网络)
    把数据放到离用户地理位置更近的地方,可以显著减少每次 TCP 连接的网络延迟,增大吞吐量。这一条既适用于静态内容,也适用于动态内容(比如:不缓存的原始获取)。
  • 去掉不必要的资源
    任何请求都不如没有请求快。

不缓存的原始获取
使用 CDN 或代理服务器取得资源的技术,如果要根据用户定制或者涉及隐私数据,则不能做到全球缓存,这种情况被称为“不缓存的原始获取”(uncached origin fetch)。
虽然只有把数据缓存到全球各地的服务器上 CDN 才能发挥最大的效用,但“不缓存的原始获取”仍然具有性能优势:客户端连接终止于附近的服务器,从而显著减少握手延迟。相应地,CDN 或你的代理服务器可以维护一个“热连接池”(warm connection pool),通过它将数据转发给原始服务器,同时做到对客户端快速响应。
事实上,作为附加的一个优化层,CDN 提供商在连接两端都会使用邻近服务器!客户端连接终止于邻近 CDN 节点,该节点将请求转发到与对端服务器邻近的 CDN节点,之后请求才会被路由到原始服务器。CDN 网络中多出来这一跳,可以让数据在优化的 CDN 骨干网中寻路,从而进一步减少客户端与服务器之间的延迟。

  • 在客户端缓存资源
    应该缓存应用资源,从而避免每次请求都发送相同的内容。
  • 传输压缩过的内容
    传输前应该压缩应用资源,把要传输的字节减至最少:确保对每种要传输的资源采用最好的压缩手段。
  • 消除不必要的请求开销
    减少请求的 HTTP 首部数据(比如 HTTP cookie),节省的时间相当于几次往返的延迟时间。
  • 并行处理请求和响应
    请求和响应的排队都会导致延迟,无论是客户端还是服务器端。这一点经常被忽视,但却会无谓地导致很长延迟。
  • 针对协议版本采取优化措施
    HTTP 1.x 支持有限的并行机制,要求打包资源、跨域分散资源,等等。相对而言,HTTP 2.0 只要建立一个连接就能实现最优性能,同时无需针对 HTTP 1.x 的那些优化方法。

在客户端缓存资源

要说最快的网络请求,那就是不用发送请求就能获取资源。将之前下载过的数据缓存并维护好,就可以做到这一点。对于通过 HTTP 传输的资源,要保证首部包含适当的缓存字段:

  • Cache-Control 首部用于指定缓存时间;
  • Last-Modified 和 ETag 首部提供验证机制。

只要可能,就给每种资源都指定一个明确的缓存时间。这样客户端就可以直接使用本地副本,而不必每次都请求相同的内容。类似地,指定验证机制可以让客户端检查过期的资源是否有更新。没有更新,就没必要重新发送。

最后,还要注意应同时指定缓存时间和验证方法!只指定其中之一是最常见的错误,于是要么导致每次都在没有更新的情况下重发相同内容(这是没有指定验证),要么导致每次使用资源时都多余地执行验证检查(这是没有指定缓存时间)。

压缩传输的数据

利用本地缓存可以让客户端避免每次请求都重复取得数据。不过,还是有一些资源是必须取得的,比如原来的资源过期了,或者有新资源,再或者资源不能缓存。对于这些资源,应该保证传输的字节数最少。因此要保证对它们进行最有效的压缩。

HTML、CSS 和 JavaScript 等文本资源的大小经过 gzip 压缩平均可以减少 60%~80%。而图片则需要仔细考量:

  • 图片一般会占到一个网页需要传输的总字节数的一半;
  • 通过去掉不必要的元数据可以把图片文件变小;
  • 要调整大小就在服务器上调整,避免传输不必要的字节;
  • 应该根据图像选择最优的图片格式;
  • 尽可能使用有损压缩。

不同图片格式的压缩率迥然不同,因为不同的格式是分别为不同使用场景设计的。事实上,如果选错了图片格式(比如,使用了 PNG 而非 JPG 或 WebP),多产生几百甚至上千 KB 数据是轻而易举的事。建议大家多找一些工具和自动化手段,以确定最佳图片格式。

选定图片格式后,其次就是不要让图片超过它需要的大小。如果在客户端对超出需要大小的图片做调整,那么除了额外传输不必要的字节之外,还会浪费 CPU、GPU和内存资源。

最后,选择了正确的格式,确定了必需的大小,接下来就要研究使用哪一种有损图片格式,比如 JPEG 还是 WebP,以及压缩到哪个级别:较高压缩率可以明显减少字节数,同时图片品质不会有太大或太明显的损失,尤其是在较小(手机)的屏幕上看,不容易发现。

WebP:Web 上的新图片格式
WebP 是谷歌开发的一种新图片格式,得到了 Chrome 和 Opera 浏览器支持。这种格式的无损压缩和有损压缩效能都有所提升:

  • WebP 的无损压缩图片比 PNG 的小 26%;
  • WebP 的有损压缩图片比 JPG 的小 25%~34%;
  • WebP 支持无损透明压缩,但因此仅增加 22% 的字节。

在现有网页平均 1 MB 大小,其中图片占一半的情况下,WebP 节省的 20%~30%,对每个页面而言就是几百 KB。这种格式需要客户端 CPU 多花点时间解码(大约相当于处理 JPG 的 1.4 倍),但字节的节省完全可以补偿处理时间的增长。此外,由于数据流量的限制和高速网络的存在,对很多用户而言,节省字节才是当务之急。
事实上,Chrome Data Compression Proxy 和 Opera Turbo 等工具为用户降低带宽占用的主要手段,就是重新把每张图片编码为 WebP 格式。正常情况下,Chrome Data Compression Proxy 的数据压缩率可以达到 50%,这说明我们自己的应用也有很多可以通过压缩提升性能的空间。

消除不必要的请求字节

HTTP 是一种无状态协议,也就是说服务器不必保存每次请求的客户端的信息。然而,很多应用又依赖于状态信息以实现会话管理、个性化、分析等功能。为了实现这些功能,HTTP State Management Mechanism(RFC 2965)作为扩展,允许任何网站针对自身来源关联和更新 cookie 元数据:浏览器保存数据,而在随后发送给来源的每一个请求的 Cookie 首部中自动附加这些信息。

上述标准并未规定 cookie 最大不能超过多大,但实践中大多数浏览器都将其限制为4 KB。与此同时,该标准还规定每个站点针对其来源可以有多个关联的 cookie。于是,一个来源的 cookie 就有可能多达几十 KB !不用说,这么多元数据随请求传递,必然会给应用带来明显的性能损失:

  • 浏览器会在每个请求中自动附加关联的 cookie 数据;
  • 在 HTTP 1.x 中,包括 cookie 在内的所有 HTTP 首部都会在不压缩的状态下传输;
  • 在 HTTP 2.0 中,这些元数据经过压缩了,但开销依然不小;
  • 最坏的情况下,过大的 HTTP cookie 会超过初始的 TCP 拥塞窗口,从而导致多余的网络往返。

应该认真对待和监控 cookie 的大小,确保只传输最低数量的元数据,比如安全会话令牌。同时,还应该利用服务器上共享的会话缓存,从中查询缓存的元数据。更好的结果,则是完全不用cookie。比如,在请求图片、脚本和样式表等静态资源时,
浏览器绝大多数情况下不必传输特定于客户端的元数据。

在使用 HTTP 1.x 的情况下,可以指定一个专门的“无需 cookie”的来源服务器。这个服务器可以用于交付那些不区分客户端的共用资源。

针对HTTP 1.x的优化建议

针对 HTTP 1.x 的优化次序很重要:首先要配置服务器以最大限度地保证 TCP 和TLS 的性能最优,然后再谨慎地选择和采用移动及经典的应用最佳实践,之后再度量,迭代。

采用了经典的应用优化措施和适当的性能度量手段,还要进一步评估是否有必要为应用采取特定于 HTTP 1.x 的优化措施(其实是权宜之计)。

  • 利用HTTP管道
    如果你的应用可以控制客户端和服务器这两端,那么使用管道可以显著减少网络延迟。
  • 采用域名分区
    如果你的应用性能受限于默认的每来源 6 个连接,可以考虑将资源分散到多个来源。
  • 打包资源以减少HTTP请求
    拼接和精灵图等技巧有助于降低协议开销,又能达成类似管道的性能提升。
  • 嵌入小资源
    考虑直接在父文档中嵌入小资源,从而减少请求数量。

管道缺乏支持,而其他优化手段又各有各的利弊。事实上,这些优化措施如果过于激进或使用不当,反倒会伤害性能。总之,要有务实的态度,通过度量来评估各种措施对性能的影响,在此基础上再迭代改进。天底下就没有包治百病的灵丹妙药。

对了,还有最后一招儿 —— 升级到 HTTP 2.0。仅此一招儿抵得上前面提到的大多数针对 HTTP 1.x 的优化手段! HTTP 2.0 不光能让应用加载更快,还能让开发更简单。

针对HTTP 2.0的优化建议

HTTP 2.0 的主要目标就是提升传输性能,实现客户端与服务器间较低的延迟和较高的吞吐量。显然,在 TCP 和 TLS 之上实现最佳性能,同时消除不必要的网络延迟,从来没有如此重要过。最低限度:

  • 服务器的初始cwnd 应该是 10 个分组;
  • 服务器应该通过 ALPN(针对 SPDY 则为 NPN)协商支持 TLS;
  • 服务器应该支持 TLS 恢复以最小化握手延迟。

接下来,或许有点意外,那就是采用移动及其他经典的最佳做法:少发数据、削减请求,根据无线网络情况调整资源供给。不管使用什么版本的协议,减少传输的数据量和消除不必要的网络延迟,对任何应用都是最有效的优化手段。

最后,杜绝和忘记域名分区、文件拼接、图片精灵等不良的习惯,这些做法在HTTP 2.0 之上完全没有必要。事实上,继续使用这些手段反而有害!可以利用HTTP 2.0 内置的多路分发以及服务器推送等新功能。

去掉对1.x的优化

针对 HTTP 2.0 和 HTTP 1.x 的优化策略没有什么重叠。因此,不仅不必担心 HTTP 1.x 协议的种种限制,而且要撤销原先那些必要的做法。

  • 每个来源使用一个连接
    HTTP 2.0 通过将一个 TCP 连接的吞吐量最大化来提升性能。事实上,在 HTTP 2.0 之下再使用多个连接(比如域名分区)反倒成了一种反模式,因为多个连接会抵消新协议中首部压缩和请求优先级的效用。
  • 去掉不必要的文件合并和图片拼接
    打包资源的缺点很多,比如缓存失效、占用内存、延缓执行,以及增加应用复杂性。有了 HTTP 2.0,很多小资源都可以并行发送,导致打包资源的效率反而更低。
    *利用服务器推送
    之前针对 HTTP 1.x 而嵌入的大多数资源,都可以而且应该通过服务器推送来交付。这样一来,客户端就可以分别缓存每个资源,并在页面间实现重用,而不必把它们放到每个页面里了。

要获得最佳性能,应该尽可能把所有资源都集中在一个域名之下。域名分区在 HTTP 2.0 之下属于反模式,对发挥协议的性能有害:分区是开始,之后影响会逐渐扩散。打包资源不会影响 HTTP 2.0 协议本身,但对缓存性能和执行速度有负面影响。

类似地,把嵌入资源改为服务器推送能提升客户端的缓存性能,又不会导致额外网络延迟。事实上,由于 3G 和 4G 网络的往返时间更长,因而服务器推送对移动应用来说效果更明显。

HTTP 2.0 中的打包与协议开销
由于 HTTP 1.x 做不到多路复用,而且每次请求的协议开销很高,这才有了连接和拼合等打包技术。在 HTTP 2.0 之下,多路复用已经不成问题,首部压缩也可以降低每次 HTTP 请求要传输的元数据量,打包技术在多数情况下都不再需要了。
不过,请求开销只是减少了,并没有等于零。少数情况下,某些资源必须一块使用,而且更新也不频繁,此时使用打包技术仍然可以提升性能。但这些情况很少见,可以算作例外。具体措施可以通过性能度量确定。

双协议应用策略

遗憾的是,升级到 HTTP 2.0 不会在一夜之间完成。因此,很多应用都需要认真考虑双协议并存的部署策略,即同一个应用既能通过 HTTP 1.x 交付,也能通过 HTTP2.0 交付,无需任何改动。然而,过于激进的 HTTP 1.x 优化可能伤害 HTTP 2.0 性能,反之亦然。

如果应用可以同时控制服务器和客户端,那倒简单了,因为它可以决定使用什么协议。但大多数应用不能也无法控制客户端,只有采用一种混合或自动策略,以适应两种协议并存的现实。下面我们就分析几种可能的情况。

  • 相同的应用代码,双协议部署
    相同的应用代码可能通过 HTTP 1.x 也可能通过 HTTP 2.0 交付。可能任何一种协议之下都达不到最佳性能,但可以追求性能足够好。所谓足够好,需要通过针对每一种应用单独度量来保证。这种情况下,第一步可以先撤销域名分区以实现HTTP 2.0 交付。然后,随着更多用户迁移到 HTTP 2.0,可以继续撤销资源打包并尽可能利用服务器推送。
  • 分离应用代码,双协议部署
    根据协议不同分别交付不同版本的应用。这样会增加运维的复杂性,但实践中对很多应用倒是十分可行。比如,一台负责完成连接的边界服务器可以根据协商后的协议版本,把客户端请求引导至适当的服务器。
  • 动态HTTP 1.x和HTTP 2.0优化
    某些自动化的 Web 优化框架,以及开源及商业产品,都可以在响应请求时动态重写交付的应用代码(包括连接、拼合、分区,等等)。此时,服务器也可以考虑协商的协议版本,并动态采用适当的优化策略。
  • HTTP 2.0,单协议部署
    如果应用可以控制服务器和客户端,那没理由不只使用 HTTP 2.0。事实上,如果真有这种可能,那就应该专一使用 HTTP 2.0。

选择路线时,要看当前的基础设施、应用的复杂程度,以及用户的构成。让人哭笑不得的是,那些在 HTTP 1.x 优化上投资很大的应用,反倒在这种情况下最难办。如果你能控制客户端,有自动的应用优化策略,或者没有使用任何特定于 1.x 的优化,那么就可以专注于 HTTP 2.0,而没有后顾之忧了。

使用 PageSpeed 实现动态优化
谷歌的 PageSpeed Optimization Libraries(PSOL)提供了 40 多种“Web 优化过滤器”的开源实现,可以集成到任何服务器运行时,动态应用各种优化策略。

在使用 PSOL 库的情况下, mod_pagespeed (Apache)和 ngx_pagespeed (Nginx)模块都可以基于指定的优化过滤器(如嵌入、压缩、拼接、分片等)实现动态重写,并优化资源交付方式。每次优化都在请求时动态应用(并被缓存),整个优化过程完全自动化了。
在动态优化下,服务器还可以根据所用协议,甚至用户代理的类型和版本调整优化策略。比如,可以配置 mod_pagespeed 模块,在客户端使用 HTTP 2.0 时跳过某些优化:

1
2
3
4
5
6
7
8
#  对 SPDY/HTTP 2.0 客户端禁用拼接
<ModPagespeedIf spdy>
ModPagespeedDisableFilters combine_css,combine_javascript
</ModPagespeedIf>
# 只对 HTTP 1.x 客户端使用域名分区
<ModPagespeedIf !spdy>
ModPagespeedShardDomain www.site.com s1.site.com,s2.site.com
</ModPagespeedIf>

使用 PageSpeed 这样的自动 Web 优化库,可以让我们省去不少麻烦,值得考虑。

1.x与2.0的相互转换

除了双协议优化策略,很多已部署的应用都需要在自己的应用服务器上采取一种折中方案:两端都是 HTTP 2.0 是追求最佳性能的目标,但(新增)一个转换层(图13-2)也可以让 1.x 服务器利用 HTTP 2.0。

一台居间服务器可以接受 HTTP 2.0 会话,处理之后再向既有基础设施分派 1.x 格式的请求。接到响应后,再将其转换成 HTTP 2.0 的流并返回客户端。通常,这是应用 HTTP 2.0 更新的最简单方式,因为这样可以重用已有的 1.x 基础设施,而且基本不用修改。

大多数支持 HTTP 2.0 的 Web 服务器默认都提供 2.0 到 1.x 的转换机制:2.0 会话终止于服务器(Apache 或 Nginx),如果服务器被配置为反向代理,那么分派给具体应用服务器的就是 1.x 请求。

然而,2.0 到 1.x 的这种简单策略并非长久之计。从很多方面来说,这种工作流实际是一种倒退。真正正确的做法,不是把优化的、可复用的会话转换成一系列 1.x请求,因基础设施而废优化,而是相反:把接收到的 1.x 客户端请求转换成 2.0 流,并把我们的基础设施标准化,使其在任何时候都处理 2.0 会话。

为获得最佳性能,同时实现低延迟和实时的 Web 应用,应该要求我们的内部基础设施达到如下标准:

  • 负载均衡器和代理与应用的连接应该持久化
  • 请求和响应流及多路复用应该是默认配置
  • 与应用服务器的通信应该基于消息
  • 客户端与应用服务器的通信应该是双向的

端到端的 HTTP 2.0 会话符合上述所有条件,能实现对客户端以及数据中心内部的低延迟交付:无需定制的 RPC 层及相应机制,就能实现内部服务之间的通信,并获得理想的性能。简言之,不要把 2.0 降级到 1.x,这不是长久之计。长久之计是把1.x 升级到 2.0,这样才能求得最佳性能。

评估服务器质量与性能

HTTP 2.0 服务器实现的质量对客户端性能影响很大。HTTP 服务器的配置当然是一个重要因素,但服务器实现逻辑的质量同样与优先级、服务器推送、多路复用等性能机制的发挥紧密相关。

  • HTTP 2.0 服务器必须理解流优先级;
  • HTTP 2.0 服务器必须根据优先级处理响应和交付资源;
  • HTTP 2.0 服务器必须支持服务器推送;
  • HTTP 2.0 服务器应该提供不同推送策略的实现。

HTTP 2.0 服务器的初级实现也能支持某些功能,但不能明确支持请求的优先级和服务器推送,可能导致次优性能。比如,发送大型、静态图片导致带宽饱和,而客户端又因为其他重要资源(如 CSS 或 JavaScript)被阻塞。

为尽可能获得最佳性能,HTTP 2.0 客户端必须是个“乐观主义者”:尽可能早地发送所有请求,然后完全听凭服务器的优化。事实上,HTTP 2.0 客户端对服务器的依赖程度较之以前更甚。

2.0与TLS

实践中,由于存在很多不兼容的中间代理,早期的 HTTP 2.0 部署必然依赖加密信道。这样一来,我们就面临两种可能出现 ALPN 协商和 TLS 终止的情况:

  • TLS 连接可能会在 HTTP 2.0 服务器上终止;
  • TLS 连接可能会在上游(如负载均衡器)上终止。

第一种情况要求 HTTP 2.0 服务器能够处理 TLS,除此之外就没有什么了。第二种情况复杂一些:TLS+ALPN 握手可能会在上游代理处终止(图 13-3),然后再从那里建立一条加密信道,或者直接将非加密的 HTTP 2.0 流发送到服务器。

代理和应用服务器之间使用安全信道还是非加密信道,取决于应用:只要能控制中间设备,就可以保证未加密的帧不会被修改或丢弃。那么,虽然大多数 HTTP 2.0 服务器都应该支持 TLS+ALPN 协商,但它们同时也应该在不加密的情况下实现HTTP 2.0 通信。

另外,智能负载均衡器也可以使用 TLS+ALPN 协商机制,根据协商后的协议,选择性地将不同的客户端路由到不同的服务器。

负载均衡器、代理及应用服务器

根据现有基础设施以及应用的复杂程度和规模,你的基础设施中可能需要一台或多台负载均衡器(图 13-4)或者 HTTP 2.0 代理。

最简单的情况下,HTTP 2.0 服务器与客户端直接对话,并负责完成 TLS 连接,进行 ALPN 协商,以及处理所有请求。

然而,一台服务器对于大型应用是不够的。大型应用必须要添加一台负载均衡器,以分流大量请求。此时,负载均衡器可以终止 TLS 连接(参见 上节 “2.0 与TLS”),也可以经过配置作为 TCP 代理并直接将加密数据发送给应用服务器。

很多云提供商也会提供负载均衡器服务。然而,这些负载均衡器大多支持TLS 终止,却不支持 ALPN 协商,而这对于通过 TLS 实现 HTTP 2.0 通信是必需的。在这种情况下,应该将负载均衡器配置为 TCP 代理,即通过它们将加密数据发送给应用服务器,让应用服务器完成 TLS+ALPN 协商。

实践中,要回答的最重要的一个问题,就是你的基础设施中的哪个组件负责终止TLS 连接,以及它是否能够执行必要的 ALPN 协商?

  • 要在 TLS 之上实现 HTTP 2.0 通信,终端服务器必须支持 ALPN;
  • 尽可能在接近用户的地方终止 TLS;
  • 如果无法支持 ALPN,那么选择 TCP 负载均衡模式;
  • 如果无法支持 ALPN 且 TCP 负载均衡也做不到,那么就退而求其次,在非加密
  • 信道上使用 HTTP 的 Upgrade 流

浏览器API与协议

浏览器网络概述

连接管理与优化

运行在浏览器中的 Web 应用并不负责管理个别网络套接字的生命周期,这是好事。通过把这个任务委托给浏览器,可以自动化很多重要的性能优化任务,包括套接字重用、请求优先级排定、晚绑定、协议协商、施加连接数限制,等等。事实上,浏览器是有意把请求管理生命周期与套接字管理分开的。这一点很微妙,但却至关重要。

套接字是以池的形式进行管理的(图 14-2),即按照来源,每个池都有自己的连接限制和安全约束。挂起的请求是排好队的、有优先次序的,然后再适时把它们绑定到池中个别的套接字上。除非服务器有意关闭连接,否则同一个套接字可以自动用于多个请求!

  • 来源
    由应用协议、域名和端口三个要件构成,比如 (http, www.example.com, 80) 与(https, www.example.com, 443) 就是两个不同的来源。
  • 套接字池
    属于同一个来源的一组套接字。实践中,所有主流浏览器的最大池规模都是 6 个套接字。

自动化的套接字池管理会自动重用 TCP 连接,从而有效保障性能。除此之外,这种架构设计还提供了其他优化的机会:

  • 浏览器可以按照优先次序发送排队的请求;
  • 浏览器可以重用套接字以最小化延迟并提升吞吐量;
  • 浏览器可以预测请求提前打开套接字;
  • 浏览器可以优化何时关闭空闲套接字;
  • 浏览器可以优化分配给所有套接字的带宽。

谷歌 Chrome 的推测性网络优化
我们已经知道了,现代浏览器的网络组件并非一个套接字管理器那么简单。但是,即使如此有时候也足以客观地评价现代浏览器中的某些优化技术。
比如,你使用谷歌 Chrome 浏览器的次数越多,它的速度就会越快。Chrome 会学习访问过的站点的拓扑,以及常见的浏览模式,然后利用这些信息进行各种“推测性优化”,以预测用户下一步的操作,从而消除不必要的网络延迟:DNS 预解析、TCP 预连接、页面预渲染,等等。像鼠标悬停在链接上这么个简单的动作,就可以触发浏览器向其网络组件的“预测器”发送信号,后者则会依据过往的性能数据选择最佳的优化措施。
如果你对Chrome 浏览器的网络优化技术感兴趣,可以看看这篇文章“High Performance Networking in Google Chrome”:http://hpbn.co/chrome-networking

网络安全与沙箱

将个别套接字的管理任务委托给浏览器还有另一个重要的用意:可以让浏览器运用沙箱机制,对不受信任的应用代码采取一致的安全与策略限制。比如,浏览器不允许直接访问原始网络套接字 API,因为这样给恶意应用向任意主机发起任意请求(端口扫描、连接邮件服务器或发送未知消息)提供可乘之机。

  • 连接限制
    浏览器管理所有打开的套接字池并强制施加连接数限制,保护客户端和服务器的资源不会被耗尽。
  • 请求格式化与响应处理
    浏览器格式化所有外发请求以保证格式一致和符合协议的语义,从而保护服务器。类似地,响应解码也会自动完成,以保护用户。
  • TLS 协商
    浏览器执行 TLS 握手和必要的证书检查。任何证书有问题(比如服务器正在使用自已签发的证书),用户都会收到通知。
  • 同源策略
    浏览器会限制应用只能向哪个来源发送请求。

以上列出的安全限制机制只是一部分,但已经可以体现“最低特权”(least privilege)原则了。浏览器只向应用代码公开那些必要的 API 和资源:应用提供数据和 URL,浏览器执行请求并负责管理每个连接的整个生命周期。

有必要提一句,并没有单独一条原则叫“同源策略”。实际上,这是一组相关的机制,涉及对 DOM 访问、cookie 和会话状态管理、网络及其他浏览器组件的限制。

资源与客户端状态缓存

最好最快的请求是没有请求。在分派请求之前,浏览器会自动检查其资源缓存,执行必要的验证,然后在满足限制条件的情况下返回资源的本地副本。类似地,如果某本地资源不在缓存中,那么浏览器就会发送网络请求,将响应自动填充到缓存中,
以备后续访问使用。

  • 浏览器针对每个资源自动执行缓存指令。
  • 浏览器会尽可能恢复失效资源的有效性。
  • 浏览器会自动管理缓存大小及资源回收。

浏览器还有一个经常被人忽视的重要功能,那就是提供会话认证和 cookie 管理。浏览器为每个来源维护着独立的 cookie 容器,为读写新 cookie、会话和认证数据提供必要的应用及服务器 API,还会为我们自动追加和处理 HTTP 首部,让一切都自动化。

举一个简单但直观的例子,它能说明把会话状态管理委托给浏览器的好处:认证的会话可以在多个标签页或浏览器口间共享,反之亦然;如果用户在某个标签页中退出,那么其他所有打开窗口中的会话都将失效。

应用API与协议

我们在这个表中有意忽略了 WebRTC,因为那是一种端到端的交付模型,与 XHR、SSE 和 WebSocket 协议有着根本的不同。

XMLHttpRequest

XHR简史

尽管名字里有 XML 的 X,XHR 也不是专门针对 XML 开发的。这只是因为 Internet Explorer 5 当初发布它的时候,把它放到 MSXML 库里,这才“继承”了这个 X。

跨源资源共享(CORS)

XHR 是一个浏览器层面的 API,向我们隐藏了大量底层处理,包括缓存、重定向、内容协商、认证,等等。这样做有两个目的。第一,XHR 的 API 因此非常简单,开发人员可以专注业务逻辑。其次,浏览器可以采用沙箱机制,对应用代码强制施加一套安全限制。

XHR 接口强制要求每个请求都严格具备 HTTP 语义:应用提供数据和 URL,浏览器格式化请求并管理每个连接的完整生命周期。类似地,虽然 XHR API 允许应用添加自定义的 HTTP 首部(通过 setRequestHeader() 方法),同时也有一些首部是应用代码不能设定的:

  • Accept-Charset、Accept-Encoding、Access-Control-*
  • Host、Upgrade、Connection、Referer、Origin
  • Cookie、Sec-*、Proxy-* 以及很多其他首部

浏览器会拒绝对不安全首部的重写,以此保证应用不能假扮用户代理、用户或请求来源。事实上,保护来源(Origin)首部特别重要,因为这是对所有 XHR 请求应用“同源策略”的关键。

一个“源”由应用协议、域名和端口这三个要件共同定义。比如,(http,example.com, 80) 和 (https, example.com, 443) 就是不同的源。

同源策略的出发点很简单:浏览器存储着用户数据,比如认证令牌、cookie 及其他私有元数据,这些数据不能泄露给其他应用。如果没有同源沙箱,那么 example.com 中的脚本就可以访问并操纵 thirdparty.com 的用户数据!

为解决这个问题,XHR 的早期版本都限制应用只能执行同源请求,即新请求的来源必须与旧请求的来源一致:来自 example.com 的 XHR 请求,只能从 example.com 请求其他资源。如果后续请求不同源,浏览器就拒绝该 XHR 请求并报错。

可是,在某些必要的情况下,同源策略也会给更好地利用 XHR 带来麻烦:如果服务器想要给另一个网站中的脚本提供资源怎么办?这就是 Cross-Origin Resource Sharing(跨源资源共享,CORS)的来由! CORS 针对客户端的跨源请求提供了安全的选择同意机制:

1
2
3
4
5
6
7
8
9
//  脚本来源: (http, example.com, 80)
var xhr = new XMLHttpRequest();
xhr.open('GET', '/resource.js'); ➊
xhr.onload = function() { ... };
xhr.send();
var cors_xhr = new XMLHttpRequest();
cors_xhr.open('GET', 'http://thirdparty.com/resource.js'); ➋
cors_xhr.onload = function() { ... };
cors_xhr.send();

➊ 同源 XHR 请求
➋ 跨源 XHR 请求

CORS 请求也使用相同的 XHR API,区别仅在于请求资源用的 URL 与当前脚本并不同源。在前面的例子中,当前执行的脚本来自 (http, example.com, 80),而第二个XHR 请求访问的 resource.js 则来自 (http, thirdparty.com, 80)。

针对 CORS 请求的选择同意认证机制由底层处理:请求发出后,浏览器自动追加受保护的 Origin HTTP 首部,包含着发出请求的来源。相应地,远程服务器可以检查 Origin 首部,决定是否接受该请求,如果接受就返回 Access-Control-Allow-Origin 响应首部:

1
2
3
4
5
6
7
8
9
=>  请求
GET /resource.js HTTP/1.1
Host: thirdparty.com
Origin: http://example.com ➊
...
<= 响应
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://example.com ➋
...

➊ Origin 首部由浏览器自动设置
➋ 选择同意首部由服务器设置

在前面的例子中,thirdparty.com 决定同意与 example.com 跨源共享资源,因此就在响应中返回了适当的访问控制首部。假如它选择不同意接受这个请求,那么只要不在响应中包含 Access-Control-Allow-Origin 首部即可。这样,客户端的浏览器就会自动将发出的请求作废。

如果第三方服务器不支持 CORS,那么客户端请求同样会作废,因为客户端会验证响应中是否包含选择同意的首部。作为一个特例,CORS 还允许服务器返回一个通配值 ( Access-Control-Allow-Origin: * ),表示它允许来自任何源的请求。不过,在启用这个选项前,请大家务必三思!

这就是全部了吧?准确地讲,不是。因为 CORS 还会提前采取一系列安全措施,以确保服务器支持 CORS:

  • CORS 请求会省略 cookie 和 HTTP 认证等用户凭据;
  • 客户端被限制只能发送“简单的跨源请求”,包括只能使用特定的方法(GET、POST 和 HEAD),以及只能访问可以通过 XHR 发送并读取的 HTTP 首部。

要启用 cookie 和 HTTP 认证,客户端必须在发送请求时通过 XHR 对象发送额外的属性( withCredentials ),而服务器也必须以适当的首部(Access-Control-Allow-Credentials)响应,表示它允许应用发送用户的隐私数据。类似地,如果客户端需要写或者读自定义的 HTTP 首部,或者想要使用“不简单的方法”发送请求,那么它必须首先要获得第三方服务器的许可,即向第三方服务器发送一个预备(preflight)请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
=>  预备请求
OPTIONS /resource.js HTTP/1.1 ➊
Host: thirdparty.com
Origin: http://example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: My-Custom-Header
...
<= 预备响应
HTTP/1.1 200 OK ➋
Access-Control-Allow-Origin: http://example.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: My-Custom-Header
...
(正式的 HTTP 请求) ➌

➊ 验证许可的预备 OPTIONS 请求
➋ 第三方源的成功预备响应
➌ 实际的 CORS 请求

W3C 官方的 CORS 规范规定了何时何地必须使用预备请求:“简单的”请求可以跳过它,但很多条件下这个请求都是必需的,因此也会为验证许可而增加仅有一次往返的网络延迟。好在,只要完成预备请求,客户端就会将结果缓存起来,后续请求就不必重复验证了。

CORS 得到了所有现代浏览器支持,参见:caniuse.com/cors。要全面了解CORS 的各种策略及实现,请参考 W3C 官方标准(http://www.w3.org/TR/cors/)。

通过XHR下载数据

浏览器可以自动解码的数据类型如下:

  • ArrayBuffer
    固定长度的二进制数据缓冲区。
  • Blob
    二进制大对象或不可变数据。
  • Document
    解析后得到的 HTML 或 XML 文档。
  • JSON
    表示简单数据结构的 JavaScript 对象。
  • Text
    简单的文本字符串。

浏览器可以依靠 HTTP 的 content-type 首部来推断适当的数据类型(比如把application/json 响应解析为 JSON 对象),应用也可以在发起 XHR 请求时显式重写数据类型。

这里的二进制大对象接口( Blob )属于 HTML5 的 File API,就像一个不透明的引用,可以指向任何数据块(二进制或文本)。这个对象本身没有太多功能,只能查询其大小、MIME 类型,或将它切分成更小的块。这个对象存在的真正目的,是作为各种 JavaScript API 之间的一种高效的互操作机制。

要估算传输完成的数据量,服务器必须在其响应中提供内容长度(Content-Length)首部。而对于分块数据,由于响应的总长度未知,因此就无法估计进度了。另外,XHR 请求默认没有超时限制,这意味着一个请求的“进度”可以无限长。作为最佳实践,一定要为应用设置合理的超时时间,并适当处理错误。

服务器发送事件

Server-Sent Events(SSE)让服务器可以向客户端流式发送文本消息,比如服务器上生成的实时通知或更新。为达到这个目标,SSE 设计了两个组件:浏览器中的EventSource 和新的“事件流”数据格式。其中, EventSource 可以让客户端以 DOM 事件的形式接收到服务器推送的通知,而新数据格式则用于交付每一次更新。

EventSource API 和定义完善的事件流数据格式,使得 SSE 成为了在浏览器中处理实时数据的高效而不可或缺的工具:

  • 通过一个长连接低延迟交付;
  • 高效的浏览器消息解析,不会出现无限缓冲;
  • 自动跟踪最后看到的消息及自动重新连接;
  • 消息通知在客户端以 DOM 事件形式呈现。

实际上,SSE 提供的是一个高效、跨浏览器的 XHR 流实现,消息交付只使用一个长 HTTP 连接。然而,与我们自己实现 XHR 流不同,浏览器会帮我们管理连接、解析消息,从而让我们只关注业务逻辑。

EventSource API

EventSource 接口通过一个简单的浏览器 API 隐藏了所有的底层细节,包括建立连接和解析消息。

SSE 实现了节省内存的 XHR 流。与原始的 XHR 流在连接关闭前会缓冲接收到的所有响应不同,SSE 连接会丢弃已经处理过的消息,而不会在内存中累积。

值得一提的是, EventSource 接口还能自动重新连接并跟踪最近接收的消息:如果连接断开了, EventSource 会自动重新连接到服务器,还可以向服务器发送上一次接收到的消息 ID,以便服务器重传丢失的消息并恢复流。

Event Stream协议

SSE 事件流是以流式 HTTP 响应形式交付的:客户端发起常规 HTTP 请求,服务器以自定义的“text/event-stream”内容类型响应,然后交付 UTF-8 编码的事件数据。这么简单几句话似乎都有点说复杂了,看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
=>  请求
GET /stream HTTP/1.1 ➊
Host: example.com
Accept: text/event-stream
<= 响应
HTTP/1.1 200 OK ➋
Connection: keep-alive
Content-Type: text/event-stream
Transfer-Encoding: chunked
retry: 15000 ➌
data: First message is a simple string. ➍
data: {"message": "JSON payload"} ➎
event: foo ➏
data: Message of type "foo"
id: 42 ➐
event: bar
data: Multi-line message of
data: type "bar" and id "42"
id: 43 ➑
data: Last message, id "43"

➊ 客户端通过 EventSource 接口发起连接
➋ 服务器以 “text/event-stream” 内容类型响应
➌ 服务器设置连接中断后重新连接的间隔时间(15 s)
➍ 不带消息类型的简单文本事件
➎ 不带消息类型的 JSON 数据载荷
➏ 类型为 “foo” 的简单文本事件
➐ 带消息 ID 和类型的多行事件
➑ 带可选 ID 的简单文本事件

在接收端, EventSource 接口通过检查换行分隔符来解析到来的数据流。

SSE 中的 UTF-8 编码与二进制传输 EventSource 不会对实际载荷进行任何额外处理:从一或多个 data 字段中提取出来的消息,会被拼接起来直接交给应用。因此,服务器可以推送任何文本格式(例如,简单字符串、JSON,等等),应用必须自己解码。

话虽如此,但所有事件源数据都是 UTF-8 编码的:SSE 不是为传输二进制载荷而设计的!如果有必要,可以把二进制对象编码为 base64 形式,然后再使用 SSE。但这样会导致很高(33%)的字节开销。

担心 UTF-8 编码也会造成高开销? SSE 连接本质上是 HTTP 流式响应,因此响应是可以压缩的(如 gzip 压缩),就跟压缩其他 HTTP 响应一样,而且是动态压缩!虽然 SSE 不是为传输二进制数据而设计的,但它却是一个高效的机制——只要让你的服务器对 SSE 流应用 gzip 压缩。

不支持二进制传输是有意为之的。SSE 的设计目标是简单、高效,作为一种服务器向客户端传送文本数据的机制。如果你想传输二进制数据,WebSocket 才是更合适的选择。

最后,除了自动解析事件数据,SSE 还内置支持断线重连,以及恢复客户端因断线而丢失的消息。默认情况下,如果连接中断,浏览器会自动重新连接。SSE 规范建议的间隔时间是 2~3 s,这也是大多数浏览器采用的默认值。不过,服务器也可以设置一个自定义的间隔时间,只要在推送任何消息时向客户端发送一个 retry 命令即可。

类似地,服务器还可以给每条消息关联任意 ID 字符串。浏览器会自动记录最后一次收到的消息 ID,并在发送重连请求时自动在 HTTP 首部追加“Last-Event-ID”值。下面看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
(既有 SSE 连接)
retry: 4500 ➊
id: 43 ➋
data: Lorem ipsum
(连接断开)
(4500 ms 后)
=> 请求
GET /stream HTTP/1.1 ➌
Host: example.com
Accept: text/event-stream
Last-Event-ID: 43
<= 响应
HTTP/1.1 200 OK ➍
Content-Type: text/event-stream
Connection: keep-alive
Transfer-Encoding: chunked
id: 44 ➎
data: dolor sit amet

➊ 服务器将客户端的重连间隔设置为 4.5 s
➋ 简单文本事件,ID:43
➌ 带最后一次事件 ID 的客户端重连请求
➍ 服务器以 ‘text/event-stream’ 内容类型响应
➎ 简单文本事件,ID:44

客户端应用不必为重新连接和记录上一次事件 ID 编写任何代码。这些都由浏览器自动完成,然后就是服务器负责恢复了。值得注意的是,根据应用的要求和数据流,服务器可以采取不同的实现策略。

  • 如果丢失消息可以接受,就不需要事件 ID 或特殊逻辑,只要让客户端重连并恢复数据流即可。

  • 如果必须恢复消息,那服务器就需要指定相关事件的 ID,以便客户端在重连时报告最后接收到的 ID。同样,服务器也需要实现某种形式的本地缓存,以便恢复并向客户端重传错过的消息。

SSE使用场景及性能

通过 TLS 实现 SSE 流
SSE 通过常规 HTTP 连接实现了简单便捷的实时传输机制,服务器端容易部署,客户端也容易打补丁。可是,现有网络中间设备,比如代理服务器和防火墙,都不支持 SSE,而这有可能带来问题:中间设备可能会缓冲事件流数据,导致额外延迟,甚至彻底毁掉 SSE 连接。

如果你碰到了这样或类似的问题,那么可以考虑通过 TLS 发送 SSE 事件流。

WebSocket

WebSocket 可以实现客户端与服务器间双向、基于消息的文本或二进制数据传输。

接收文本和二进制数据

WebSocket 协议不作格式假设,对应用的净荷也没有限制:文本或者二进制数据都没问题。从内部看,协议只关注消息的两个信息:净荷长度和类型(前者是一个可变长度字段),据以区别 UTF-8 数据和二进制数据。

浏览器接收到新消息后,如果是文本数据,会自动将其转换成 DOMString 对象,如果是二进制数据或 Blob 对象,会直接将其转交给应用。唯一可以(作为性能暗示和优化措施)多余设置的,就是告诉浏览器把接收到的二进制数据转换成 ArrayBuffer而非 Blob。

用户代理可以将这个选项看作一个暗示,以决定如何处理接收到的二进制数据:如果这里设置为“blob”,那就可以放心地将其转存到磁盘上;而如果设置为“arraybuffer”,那很可能在内存里处理它更有效。自然地,我们鼓励用户代理使用更细微的线索,以决定是否将到来的数据放到内存里…… ——The WebSocket API W3C Candidate Recommendation

Blob 对象一般代表一个不可变的文件对象或原始数据。如果你不需要修改它或者不需要把它切分成更小的块,那这种格式是理想的(比如,可以把一个完整的 Blob 对象传给 img 标签)。而如果你还需要再处理接收到的二进制数据,那么选择 ArrayBuffer 应该更合适。

子协议协商

WebSocket 协议对每条消息的格式事先不作任何假设:仅用一位标记消息是文本还是二进制,以便客户端和服务器有效地解码数据,而除此之外的消息内容就是未知的。

此外,与 HTTP 或 XHR 请求不同——它们是通过每次请求和响应的 HTTP 首部来沟通元数据,WebSocket 并没有等价的机制。因此,如果需要沟通关于消息的元数据,客户端和服务器必须达成沟通这一数据的子协议。

  • 客户端和服务器可以提前确定一种固定的消息格式,比如所有通信都通过 JSON编码的消息或者某种自定义的二进制格式进行,而必要的元数据作为这种数据结构的一个部分。

  • 如果客户端和服务器要发送不同的数据类型,那它们可以确定一个双方都知道的消息首部,利用它来沟通说明信息或有关净荷的其他解码信息。

  • 混合使用文本和二进制消息可以沟通净荷和元数据,比如用文本消息实现 HTTP首部的功能,后跟包含应用净荷的二进制消息。

子协议名由应用自己定义,且在初次 HTTP 握手期间发送给服务器。除此之外,指定的子协议对核心 WebSocket API 不会有任何影响。

WebSocket协议

WebSocket 协议尝试在既有 HTTP 基础设施中实现双向 HTTP 通信,因此也使用 HTTP 的 80 和 443 端口……不过,这个设计不限于通过 HTTP 实现WebSocket 通信,未来的实现可以在某个专用端口上使用更简单的握手,而
不必重新定义么一个协议。 ——WebSocket Protocol RFC 6455

二进制分帧层

客户端和服务器 WebSocket 应用通过基于消息的 API 通信:发送端提供任意 UTF-8或二进制的净荷,接收端在整个消息可用时收到通知。为此,WebSocket 使用了自定义的二进制分帧格式(图 17-1),把每个应用消息切分成一或多个帧,发送到目的地之后再组装起来,等到接收到完整的消息后再通知接收端。


  • 最小的通信单位,包含可变长度的帧首部和净荷部分,净荷可能包含完整或部分应用消息。
  • 消息
    一系列帧,与应用消息对等。
  • 每一帧的第一位(FIN)表示当前帧是不是消息的最后一帧。一条消息有可能只对应一帧。
  • 操作码(4 位)表示被传输帧的类型:传输应用数据时,是文本(1)还是二进制(2);连接有效性检查时,是关闭(8)、呼叫(ping,9)还是回应(pong,10)。
  • 掩码位表示净荷是否有掩码(只适用于客户端发送给服务器的消息)。
  • 净荷长度由可变长度字段表示:
    • 如果是 0~125,就是净荷长度;
    • 如果是 126,则接下来 2 字节表示的 16 位无符号整数才是这一帧的长度;
    • 如果是 127,则接下来 8 字节表示的 64 位无符号整数才是这一帧的长度。
  • 掩码键包含 32 位值,用于给净荷加掩护。
  • 净荷包含应用数据,如果客户端和服务器在建立连接时协商过,也可以包含自定义的扩展数据。

所有客户端发送帧的净荷都要使用帧首部中指定的值加掩码,这样可以防止客户端中运行的恶意脚本对不支持 WebSocket 的中间设备进行缓存投毒攻击(cache poisoning attack)。要了解这种攻击的细节,请参考 W2SP
2011 的论文“Talking to Yourself for Fun and Profit”(http://w2spconf.com/2011/papers/websocket.pdf)。

算下来,服务器发送的每个 WebSocket 帧会产生 2~10 字节的分帧开销。而客户端必须发送掩码键,这又会增加 4 字节,结果就是 6~14 字节的开销。除此之外,没有其他元数据(比如首部字段或其他关于净荷的信息):所有 WebSocket 通信都是通过交换帧实现的,而帧将净荷视为不透明的应用数据块。

WebSocket 的多路复用及队首阻塞
WebSocket 很容易发生队首阻塞的情况:消息可能会被分成一或多个帧,但不同消息的帧不能交错发送,因为没有与 HTTP 2.0 分帧机制中“流 ID”对等的字段。
显然,如果一个大消息被分成多个 WebSocket 帧,就会阻塞其他消息的帧。如果你的应用不容许有交付延迟,那可以小心控制每条消息的净荷大小,甚至可以考虑把大消息拆分成多个小消息!
WebSocket 不支持多路复用,还意味着每个 WebSocket 连接都需要一个专门的TCP 连接。对于 HTTP 1.x 而言,由于浏览器针对每个来源有连接数量限制,因此可能会导致问题。
好 在,HyBi Working Group 正 着 手 制 定 的 新 的“Multiplexing Extension for WebSockets”(WebSockets 多路复用扩展)会解决这个问题:
这个扩展通过封装帧并加上信道 ID,可以让一个 TCP 连接支持多个虚拟 WebSocket 连接……这个多路复用扩展维护独立的逻辑信道,每个逻辑信道与独立的 WebSocket 连接没有差别,包括独立的握手首部。
——WebSocket Multiplexing(Draft 10)
有了这个扩展后,多个 WebSocket 连接(信道)就可能在同一个 TCP 连接上得到复用。可是,每个信道依旧容易产生队首阻塞问题!可能的解决方案是使用不同的信道,或者专用 TCP 连接,多路并行发送消息。
最后,注意前面的扩展仅对 HTTP 1.x 连接是必要的。虽然通过 HTTP 2.0 传输WebSocket 帧的官方规范尚未发布,但相对来说就容易多了。因为 HTTP 2.0 内置了流的多路复用,只要通过 HTTP 2.0 的分帧机制来封装 WebSocket 帧,多个WebSocket 连接就可以在一个会话中传输。

协议扩展

WebSocket 规范允许对协议进行扩展:数据格式和 WebSocket 协议的语义可以通过新的操作码和数据字段扩展。虽然有些不同寻常,但这却是一个非常强大的特性,因为它允许客户端和服务器在基本的 WebSocket 分帧层之上实现更多功能,又不需要应用代码介入或协作。

要使用扩展,客户端必须在第一次的 Upgrade 握手中通知服务器,服务器必须选择并确认要在商定连接中使用的扩展。

HTTP升级协商

WebSocket 协议提供了很多强大的特性:基于消息的通信、自定义的二进制分帧层、子协议协商、可选的协议扩展,等等。换句话说,在交换数据之前,客户端必须与服务器协商适当的参数以建立连接。

利用 HTTP 完成握手有几个好处。首先,让 WebSockets 与现有 HTTP 基础设施兼容:WebSocket 服务器可以运行在 80 和 443 端口上,这通常是对客户端唯一开放的端口。其次,让我们可以重用并扩展 HTTP 的 Upgrade 流,为其添加自定义的WebSocket 首部,以完成协商。

  • Sec-WebSocket-Version
    客户端发送,表示它想使用的 WebSocket 协议版本(“13”表示 RFC 6455)。如果服务器不支持这个版本,必须回应自己支持的版本。
  • Sec-WebSocket-Key
    客户端发送,自动生成的一个键,作为一个对服务器的“挑战”,以验证服务器支持请求的协议版本。
    Sec-WebSocket-Accept
    服务器响应,包含 Sec-WebSocket-Key 的签名值,证明它支持请求的协议版本。
  • Sec-WebSocket-Protocol
    用于协商应用子协议:客户端发送支持的协议列表,服务器必须只回应一个协议名。
  • Sec-WebSocket-Extensions
    用于协商本次连接要使用的 WebSocket 扩展:客户端发送支持的扩展,服务器通过返回相同的首部确认自己支持一或多个扩展。

有了这些协商字段,就可以在客户端和服务器之间进行 HTTP Upgrade 并协商新的WebSocket 连接了:

1
2
3
4
5
6
7
8
9
GET /socket HTTP/1.1
Host: thirdparty.com
Origin: http://example.com
Connection: Upgrade
Upgrade: websocket ➊
Sec-WebSocket-Version: 13 ➋
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== ➌
Sec-WebSocket-Protocol: appProtocol, appProtocol-v2 ➍
Sec-WebSocket-Extensions: x-webkit-deflate-message, x-custom-extension ➎

➊ 请求升级到 WebSocket 协议
➋ 客户端使用的 WebSocket 协议版本
➌ 自动生成的键,以验证服务器对协议的支持
➍ 可选的应用指定的子协议列表
➎ 可选的客户端支持的协议扩展列表
与浏览器中客户端发起的任何连接一样,WebSocket 请求也必须遵守同源策略:浏览器会自动在升级握手请求中追加 Origin 首部,远程服务器可能使用 CORS 判断接受或拒绝跨源请求。要完成握手,服务器必须返回一个成功的“Switching Protocols”(切换协议)响应,并确认选择了客户端发送的哪个选项:

1
2
3
4
5
6
7
HTTP/1.1 101 Switching Protocols ➊
Upgrade: websocket
Connection: Upgrade
Access-Control-Allow-Origin: http://example.com ➋
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= ➌
Sec-WebSocket-Protocol: appProtocol-v2 ➍
Sec-WebSocket-Extensions: x-custom-extension ➎

➊ 101 响应码确认升级到 WebSocket 协议
➋ CORS 首部表示选择同意跨源连接
➌ 签名的键值验证协议支持
➍ 服务器选择的应用子协议
➎ 服务器选择的 WebSocket 扩展

所有兼容 RFC 6455 的 WebSocket 服务器都使用相同的算法计算客户端挑战的答案:将 Sec-WebSocket-Key 的内容与标准定义的唯一 GUID 字符串拼接起来,计算出 SHA1 散列值,结果是一个 base-64 编码的字符串,把这个字符串发给客户端即可。

最低限度,成功的 WebSocket 握手必须是客户端发送协议版本和自动生成的挑战值,服务器返回 101 HTTP 响应码(Switching Protocols)和散列形式的挑战答案,确认选择的协议版本:

  • 客户端必须发送Sec-WebSocket-Version 和 Sec-WebSocket-Key ;
  • 服务器必须返回Sec-WebSocket-Accept 确认协议;
  • 客户端可以通过Sec-WebSocket-Protocol 发送应用子协议列表;
  • 服务器必须选择一个子协议并通过Sec-WebSocket-Protocol 返回协议名;如果服务器不支持任何一个协议,连接断开;
  • 客户端可以通过Sec-WebSocket-Extensions 发送协议扩展;
  • 服务器可以通过Sec-WebSocket-Extensions 确认一或多个扩展;如果服务器没有返回扩展,则连接不支持扩展。

最后,前述握手完成后,如果握手成功,该连接就可以用作双向通信信道交换WebSocket 消息。从此以后,客户端与服务器之间不会再发生 HTTP 通信,一切由WebSocket 协议接管。

代理、中间设备与 WebSocket
实践中,考虑到安全和保密,很多用户都只开放有限的端口,通常只有 80(HTTP)和 443(HTTPS)。正因为如此,WebSocket 协商是通过 HTTP Upgrade流进行的,这样可以确保与现有网络策略及基础设施兼容。
不过,正如 “Web 代理、中间设备、TLS 与新协议”所说,很多现有的HTTP 中间设备可能不理解新的 WebSocket 协议,而这可能导致各种问题:盲目的连接升级、意外缓冲 WebSocket 帧、不明就里地修改内容、把 WebSocket 流量误当作不完整的 HTTP 通信,等等。
WebSocket 的 Key 和 Accept 握手可以解决其中一些问题:这是服务器的一个安全策略,而盲目“升级”连接的中间设备可能并不理解 WebSocket 协议。虽然这个预防措施对某些代理可以解决问题,但对于那些“透明代理”还是不行,它们可能会分析并意外地修改数据。
解决之道?建立一条端到端的安全通道。比如,使用 WSS !在执行 HTTP Upgrade 握手之前,先协商一次 TLS 会话,在客户端与服务器之间建立一条加密通道,就可以解决前述所有问题。这个方案尤其适合移动客户端,因为它们的流量经常要穿越各种代理服务,这些代理服务很可能不认识 WebSocket。

WebSocket使用场景及性能

请求和响应流

把传输机制从 XHR 切换为 SSE 或 WebSocket 并不会减少客户端与服务器间的往返次数!不管什么传输机制,数据包的传播延迟都一样。不过,除了传播延迟,还有一个排队延迟——消息在被发送给另一端之前必须在客户端或服务器上等待的时间。

WebRTC

PDF书籍下载地址:https://github.com/jiankunking/books-recommendation/tree/master/HTTP

欢迎关注我的其它发布渠道