支撑现有 Web 服务的 HTTP 协议距离其发布时的 1997 年已经有些年月了,随后的 HTTP/1.1 版本发布自 1999 年。随着技术的进步和需求的进化,对于数据快速高效地传输,HTTP/1.1 显得捉襟见肘,大部分优化是在应用层来做的,因为协议是改不了的嘛,比如雪碧图减少请求数,域名共享(Domain sharing)增加同时建立的请求数。
SPDY 与 HTTP/2
早在 2009 年,Google 便尝试推出了一个叫 SPDY 的实验性协议来对 HTTP/1.1 进行升级优化。在一个工作了这么久的协议上进行大的改进,如何做到对现有应用影响最小又收效显著,相信每个对历史代码有重构的开发者来说是能够体会的。所以 Google 在尝试对协议进行优化时,其目标是:
- 减少 50% 页面加载耗时(PLT/Page Load Time)
- 做到无须开发者适配网页
- 简化部署和避免对现有网络设施进行大改动
- 与开源社区一同推进实施
- 采集实际运行时的性能数据以评估实施结果
为了达到减少 50% 页面加载耗时的目标,分帧层便是在这时引入的。
在最初的内部实验结果中,页面加载耗时的提升相当显著,达到了 55%。随后该协议在主流浏览器中得以实现,Chrome, Firefix 及 Opera,各大小公司也开始在其服务中部署实施。借着 SPDY 这波势头,HTTP 工作组 (HTTP Working Group/HTTP-WG)以其为基础开始了 HTTP/2 的提案,随后两者并行发展,SPDY 可看成是 HTTP/2 的一个实验分支,在这上面进行新功能开发和测试,然后将稳定的功能提交到 HTTP/2 草案中。
经过长时间的不断测试演进,2015 年 HTTP/2 通过了可发布的标准,是时,Chrome 团队给出了废弃 SPDY 的日程,开始了 HTTP/2 的时代。但其实早在此协议正式通过前,已经有好些浏览器及站点完整地实施了 HTTP/2,享受到了性能上的巨大提升。
HTTP/2 对于现有应用层的东西没有改变,比如 HTTP 动词,状态码,请求响应头。它的变动主要在数据格式及传输上,通过新增的分帧层(Frame layer)将这些变动和应用层隔离,所以对于应用层来说是透明的,现有 Web 应用可以不经任何修改就切到 HTTP/2。
关于版本
为何不是 HTTP/1.2
为了达到预期的性能优化,HTTP/2 中新增了二进制分帧层,这与现有 HTTP/1.1 协议的服务器及客户端是不兼容的,所以版本号来了个大升级。
除非你是服务器的开发者,需要关注这些底层的实现,否则对于应用来说,这些优化是服务器和客户端替你实现的,无须关心。
如何看站点使用的 HTTP 版本
最简单的办法,通过浏览器的开发者工具中的网络面板查看。
Chrome DevTools 网络面板中查看 HTTP 版本
Chrome DevTools 网络面板中查看 HTTP 版本
其中 Protocal
一列显示 h2
即为 HTTP/2 协议。
也可使用 curl
查看到 HTTP/2 的返回。因为只需要查看响应头,所以加 -I
参数即可。
-I, --head
(HTTP FTP FILE) Fetch the headers only! HTTP-servers feature the command HEAD which this uses to get nothing but the header of a document. When used on an FTP or FILE file, curl displays the file size and last modification time only.
-- 来自 curl 工具 man 页面对
-I
参数的解释。
$ curl -I <url>
以下是 Bing 中国及百度的返回。
<details style="margin: 0px; padding: 0px; box-sizing: border-box; display: block;"><summary style="margin: 0px; padding: 0px; box-sizing: border-box; display: list-item; cursor: pointer;"> 对 Bing 中国使用 curl 查看返回的结果</summary></details><details style="margin: 0px; padding: 0px; box-sizing: border-box; display: block;"><summary style="margin: 0px; padding: 0px; box-sizing: border-box; display: list-item; cursor: pointer;"> 对 百度使用 curl 查看返回的结果</summary></details>
通过返回可以了解到,Bing 中国站点已经启用 HTTP/2,百度使用的是 HTTP/1.1。
二进制分帧层
HTTP/2 所有优化中,最关键的技术点莫过于二进制分帧层(Binary framing layer)了,它负责 HTTP 消息在服务器与客户端之间封装及传输。
从示意图可看出新引入的分帧层位于 HTTP 应用层与套接字接口(socket interface )之间。上层 HTTP API 相关的部分比如 HTTP 动词,headers 并不受影响,所以做到了既有的应用无需做适配。但真实的 HTTP 消息在传输时,经过分帧层的处理,数据被划分成了更小单位的帧,以二进制的形式传送。所以浏览器与服务器需要处理对这种新增二进制数据的解析。
流,消息与帧
进一步了解 HTTP/2 中数据传输前,先来理解一些新的概念。
流/Stream
客户端与服务器建立连接后,两者之间一个双向的数据流,也可理解为管道,一次可传输一个或多个消息(Message)。每个流都有唯一标识和一个可选的优先级。
消息/Message
一序列有序的帧(Frame),对应一个完整请求(Request)或响应(Response)数据。
帧/Frame
HTTP/2 中数据传输的最小单位,每一帧都包含一个头部(frame header)以标识该帧属于哪个流(Stream)。同时帧里还会承载特定数据的传输,比如请求头,请求数据,响应头,响应数据。
所有通信都通过一个 TCP 链接进行,其中可承载任意数量的流。 来自不同流的帧数据在 TCP 连接中是混合着传输的,这使得帧在传输时是非常高效的,数据到达后会通过其中的头部来重新进行组装,还原到原来所属的流中。上图中可看出可各术语的包含关系。
简单理解,HTTP/2 中将通信数据切分成一小片一小片的帧并以二进制形式传输,在单个 TCP 中多路复用(multiplexed)。这是 HTTP/2 提供的基础特性,为其他性能优化提供了技术上的保障。
请求与响应的多路复用
HTTP/1.1 协议下,客户端如果想并行加载资源,需要建立多个 TCP 连接,每个请求单独一个。这样的效率是不高的,比如 header 中的 cookie 冗余,客户端能够同时发起的链接也有限,建立 TCP 连接成本很大,然而这个建立好的连接却没有被充分利用,只能返回一个响应。
HTTP/2 中通过将请求数据分帧,在同一个 TCP 链接中交错传输,在接收方再重新将分片的数据组装回来,同时数据的传输双向同时并行,提高了连接的利用效率,也避免了建立多个 TCP 连接的资源和时间消耗。
上面示例中,服务器通过 stream 1 和 stream 3 向客户端发送数据,同时客户端也在通过 stream 5 向服务器发送数据。
HTTP/2 提供的这种分帧数据传输带来了一系列建立在其之上的优化:
- 并行发起多个请求和发送多个响应,互不阻塞。
- 多个请求与响应并行地在一个 TCP 连接中完成。
- HTTP/1.1 中一些优化可以去掉了(比如 Optimizing for HTTP/1.x 中提到的雪碧图,域名共享)。
- 通过减少了不必要的连接及优化现有网络机制减少了时延,加快了页面加载速度。
- ...
流处理的优先级
来自不同请求的数据都在同一个连接中双向并行传输,这些帧的顺序及优先级该如何设置才能让信息处理得高效,这成了一个问题的关键。所以 HTTP/2 中为流提供了权重(weight)和依赖两个属性:
- 每个流都可设置一个介于 1~256 之间的权重。
- 第个流都可设置其依赖于另一个流。
结合以上两个因素可以分析形成一个对流数据的优先级树(prioritization tree),根据它客户端可做出如何接收数据的最佳决策,服务器也可以根据它来决定如何优先处理哪些请求,进行合理的 CPU ,内存及其他资源的分配。
前面介绍流的时候有提到其身上有个唯一的身分标识,通过这个标识,一个流可指定依赖于另一个流,形成父子关系。如果流没有指定依赖,默认依赖于根节点(root stream)。这个依赖关系很好理解,就是被依赖的流数据应该优先被处理及返回,想象一下页面中 JS 资源的相互依赖。
多个流数据所依赖的流相同,即拥有同一个父级,则根据他们的权重来决定处理的优先级和资源分配。
比如上图中第一个情况,权重值为 12 流 A 和权重为 4 的流 B 都没有显式指定依赖,他们隐式依赖于 root stream,在处理他们优先级时 A 为 12/(12+4),B 为 4/(12+4)。
第二个情况下,D 先于 C 拥有全部优先级,处理完成后才是 C。其他情况推而广之同样的逻辑。
更重要的是,这些优先级可随时根据情况而动态调整。比如浏览器可根据用户的交互动态调整资源的优先级,重新分配,以达到资源加载的最优效果。
单个源对应一个持久的连接
基于 HTTP/2 将所有请求分片后在一个连接中传输处理的特点,对于一个域(origin),只需要建立一个长久的连接即可。大多数 HTTP 通信是瞬时的,而 TCP 是为长连接批量数据传输而设计。通过始终复用该连接,可以达到减少重复建立连接的不必要资源消耗,极大地减少网络时延,同时也会减少持有多个连接带来的内存等资源的消耗。
流的控制
流的控制是这么一种机制,它防止在接收方已经无暇接收或疲于处理时,发送方仍不断发送数据。比如服务器压力过大,无法处理更多请求时;比如浏览器中请求视频资源,但用户点击了暂停,不想再继续加载后续的数据;或链路中有多层代理时,不同节点处理能力不一致,需要平衡一下数据传输量。
TCP 中是有流控制机制的,但无法满足 HTTP/2 中分帧情况下的控制需求,因此 HTTP/2 提供了一些基础设施(building blocks)让浏览器和服务器可实现自己的控制逻辑,或更加底层的控制:
- 流控制是定向的,每个接收方可根据需求设置其接收的阈值。
- 流控制基于约定。每个接收方在连接之处告之它的流控制阈值(以字节为单位),每当接收到
DATA
帧时减少而发送WINDOW_UPDATE
帧时增大。 - 流控制不能被关闭。通过
SETTING
帧客户端与服务器建立连接时,就初始化了两端的流控制阈值(默认为 65,535 字节)。但接收方可自行设置这个初始值,然后在每次接收到数据后发送WINDOW_UPDATE
来维护这个阈值。 - 流控制不是端对端的控制,链路上不同节点可根据自身情况来进行。
服务端推送
另一个 HTTP/2 带来的特性便是允许服务器向单个请求发送多次响应。典型的场景便是 Web 网页。浏览器请求网页后分析其中的资源,然后再请求相应的其他资源。这一过程完全可以省掉,转而由服务器一并发送。因为文档在服务器这边,服务器是知道该页面还需要哪些资源的。下图展示了对于客户端的请求,服务器推送了额外资源的情况。
服务器通过发送 PUSH_PROMISE
帧描述即将主动推送的资源,里面包含的只是资源的响应头 HEADER
,而不是实际的响应 DATA
。浏览器收到后可根据情况发送 RST_STREAM
帧来取消(比如该资源已经加载过在本地有缓存)。
头部信息的压缩
每次 HTTP 的数据传输都包含了大量头部以描述请求的目的和传送的资源的属性,这些 header 通过普通字符串的形式发送,带上 cookie 时其大小可达几 Kb 。HTTP/2 中使用 HPACK 格式来对这些头信息进行压缩优化:
- 对头信息使用哈夫曼编码(Huffman code)进行压缩。
- 要求客户端及服务器同时维护一个指向上一次请求头中字段的索引列表,这样在后续传输中可高效地对之前传输过的字段进行编码。
从对上图的理解可以看出,新发送的头部只需要包含变更的部分,而剩余的部分可通过本地索引找出,无须再次发送。
一个本地示例
是时候 show me the code 了。下面的示例来自 Node.js 中 HTTP/2 的文档。
服务端代码:
server.js
const http2 = require('http2');
const fs = require('fs');
const server = http2.createSecureServer({
key: fs.readFileSync('localhost-privkey.pem'),
cert: fs.readFileSync('localhost-cert.pem')
});
server.on('error', (err) => console.error(err));
server.on('stream', (stream, headers) => {
// stream is a Duplex
stream.respond({
'content-type': 'text/html',
':status': 200
});
stream.end('<h1>Hello World</h1>');
});
server.listen(8443);
因为浏览器不支持未加密的 HTTP/2,所以需要创建本地证书来启用 HTTPS。
通过如下命令创建本地证书。
openssl req -x509 -newkey rsa:2048 -nodes -sha256 -subj '/CN=localhost' \
-keyout localhost-privkey.pem -out localhost-cert.pem
然后启动服务器就可以在浏览器中访问查看了。
$ node node.js
打开浏览器访问 https://localhost:8443
。注意一定是带 https
访问,protocal 省略的情况下会以 HTTP/1.1 协议访问,这样是访问不到页面的,因为 server 端代码中没有使用 allowHTTP1
开启对 HTTP/1.1 回退的支持。这里开启也没有意义,因为我们在测试 HTTP/2 不是么。
本地 Node.js 搭建的 HTTP/2 示例
也可通过 curl 工具访问查看:
$ curl -ki https://localhost:8443
HTTP/2 200
content-type: text/html
date: Sun, 14 Apr 2019 08:44:34 GMT
<h1>Hello World</h1>⏎
这里 -k
(--insecure
)参数表示允许不安全的连接,本地测试时需要加上,不然访问不了。
相关资源
- Web Fundamentals - Introduction to HTTP/2
- how-do-i-know-if-my-website-is-being-served-over-http-or-http
- Node.js 文档 - HTTP/2
- Easy HTTP/2 Server with Node.js and Express.js