HTTP 协议的发展概述

1969 年诞生于美国的因特网(Internet),现如今已经发展为覆盖五大洲 150 多个国家和地区的开放型全球计算机网络系统,我们普通用户只需要一根网线,一个调制解调器与因特网服务提供商相连,便可以进入其中。当然,因特网并不是全球唯一的互联网络,比如在欧洲就存在欧盟网(Euronet),在美国也还有国际学术网(BITNET)等。一般来说,我们会将因特网看作是互联网(internet),但是严格来说互联网泛指由多个计算机网络互相连接而组成的一个大型网络,因此因特网只是互联网的一种。

万维网(World Wide Web)作为因特网最主要的组成部分,由无数个网络站点和网页组成,而负责传输这些超文本信息的就是 HTTP 协议(HyperText Transfer Protocol)。HTTP 协议建立在 TCP/IP 协议簇之上,从早期的单行协议,到如今的 QUIC,总体上看还是保持着一些基本的特征。

首先 HTTP 是简单的,虽然 HTTP/2 协议将 HTTP 消息封装到了帧(frames)中,但是 HTTP 从大体上看还是简单易读的。HTTP 是可扩展的,在 HTTP/1.0 协议中添加的 HTTP headers 让协议的扩展变得非常容易,只需要服务端与客户端就新 header 的语义达成一致,新功能就可以很轻松的添加进来。HTTP 是无状态的,这意味着请求之间是没有关系的,这就带来一个问题,即用户无法在一个网站中进行连续的交互,所幸我们可以通过 HTTP 的头部扩展,也就是 HTTP Cookies 来解决这个问题。

HTTP 的发展

HTTP/0.9

最初的 HTTP 协议并没有版本号,后来它的版本号被定为 0.9 是为了区分在它之后的版本,即 HTTP/1.0。HTTP/0.9 极其简单,请求由单行指令构成,只能使用 GET 方法,后面跟着目标资源的路径,比如:GET /index.html,响应则只包含文档本身。由于没有头部信息,所以无法传输其他类型的文件。在此协议中,连接会在单个请求和响应完成之后关闭。关于 HTTP/0.9 的详细信息,可以查看这份文档

HTTP/1.0

在 20 世纪 90 年代初的互联网热潮中,不断增长的 Web 需求很快就暴露了 HTTP/0.9 的种种缺陷,于是浏览器和服务器纷纷扩展内容使其用途更广。在 1991 年到 1995 年,这些新的扩展并没有被引入到标准中以促进协议的完善,而是仅仅作为一些尝试,在这之中,也出现了一些最佳实践和常见模式。直到 1996 年 5 月,网络工作组(Network Working Group)才发布了 RFC 1945,也就是 HTTP/1.0 协议的规范。它记录了许多已经被广泛使用的 HTTP/1.0 的“常见用法”,由于这只是一个偏向信息性质的 RFC,因此不能算作是一个正式的规范或 Internet 标准。

在 HTTP/1.0 中,请求和响应的消息格式得到了规范。一个完整的请求消息包含请求行(Request Line)、请求头、空行(CRLF)和请求实体(Entity Body)四个部分,请求行新增了 HTTP 版本号,同时请求方法也扩展出了 HEAD 和 POST。一个完整的响应消息则包含状态行(Status Line)、响应头、空行和响应实体(Entity Body)四个部分。HTTP Header 的引入让协议变得非常灵活,得益于 Content-Type 头的出现,新的 HTTP 协议具备了能够传输除纯文本 HTTP 文件以外其他类型文件的能力。新增的响应状态码会在响应开始时发送,使浏览器能够了解请求执行的情况,并作出相应的调整行为。

需要注意的是,在 HTTP/1.0 中,连接默认是不持久的,这就意味着每次请求都需要开启一个新的连接,而开启连接是一个很耗时的操作,同时连接过多也会造成大量的资源消耗,因此一般浏览器都会有针对每个服务端的最大连接数限制。下表是各个浏览器的单个 host 的最大连接数限制,数据来源于 Roundup on Parallel Connections 这篇文章。

Browser HTTP/1.1 HTTP/1.0
IE 6,7 2 4
IE 8 6 6
Firefox 2 2 8
Firefox 3 6 6
Safari 3,4 4 4
Chrome 1,2 6 ?
Chrome 3 4 4

虽然 HTTP/1.0 中没有关于 keepalive 操作的正式规范,但是有一些客户端和服务端已经实现了该功能。在这些实现中,请求或响应的头部中可以使用 Connection: keep-alive,表示连接不会在请求和响应结束后立即断开,而是保持连接。一般情况下,此时还会带有一个类似 Keep-Alive: timeout=5, max=100 的头部信息,用来描述连接保持的时间和最多支持的请求数。

HTTP/1.1

HTTP/1.0 的多种不同的实现方式在实际运用中显得有些混乱,所以在 1997 年初,HTTP 的第一个标准化版本 RFC 2068,也就是针对 HTTP/1.0 规范的修订版 HTTP/1.1 标准正式发布。1999 年 6 月,HTTP/1.1 的第一个修订版 RFC 2616 发布。2014 年 6 月,IETF 废弃了 RFC 2616 并将它拆分到了六个单独的协议中,同时重点对原来语义模糊的部分进行了解释。

HTTP/1.1 消除了大量具有歧义的内容,并引入了多项改进,比如默认使用连接复用,支持管线化技术(Pipelining),支持响应分块传输,增加了额外的缓存控制机制,内容协商机制等等。

连接复用

HTTP/1.1 默认使用持久化连接机制,因此多个请求和响应可以共用一个 TCP 连接,这样可以明显减少请求延迟,因为在发送第一个请求之后,客户端不再需要重新与服务端进行 TCP 的三次握手来建立连接。

连接复用的好处有很多,但是随之而来的一个问题就是:客户端在请求得到响应后,又该如何得知服务端响应的数据已经全部传输完毕了呢?在没有使用连接复用的时候,服务端在发送完所有的响应内容之后就会关闭连接,客户端读取数据时就会返回 EOF(-1),这样就能够得知数据已经全部接收完毕了;而使用了连接复用后,当客户端发送第一个请求时就与服务端建立了连接,这个连接会在一段时间内保持,此时客户端无法得知响应的数据是否已经全部传输完毕,比如客户端可能会在接收到部分数据后,短时间内一直没有再次收到响应的数据,但是我们无法确定是不是由于网络延迟等原因导致响应没有及时到达。

为了解决这个问题,可以使用 Content-Length 头,用来指明 Entity Body 的大小。这个标志在 GET 请求中不能使用,在 POST 请求中需要使用,同时也经常出现在响应头中。比如,当响应头中带有 Content-Length: 700,则表示该响应体的内容共有 700 字节,浏览器在接收到 700 个字节的响应体数据之后就知道响应已经完成了。

分块传输

有时响应体的大小并不容易确定,比如由动态语言生成的响应体,此时就需要用到 Transfer-Encoding: chunked 消息头,它表示响应体的内容会采用分块传输。如果一个 HTTP 消息的头信息包含该字段和值,那么之后的消息体则会由数量未定的块组成,每一个非空的块都以该块所包含的数据长度(十六进制的字节数)开始,跟随一个 CRLF,之后是数据本身,最后以 CRLF 结尾。最后一个块则不包含任何数据,由块大小为 0,一些可选的填充空白格以及 CRLF 结尾,代表分块内容结束。如果还有额外的数据,可以在结束之后,使用 Trailer 进行拖挂传输额外的数据。

管线化

默认情况下,HTTP 请求是按顺序发出的。下一个请求只有在当前请求收到响应之后才会被发出,由于受到网络延迟和带宽的限制,在下一个请求被发送之前,可能需要等待很长一段时间。管线化(Pipelining)的出现使得客户端在同一条连接上可以发送多个连续的请求,而不用等待响应返回,但是服务端需要按照客户端发送请求的顺序来返回响应。

管线化

管线化实际上是将多个 HTTP 请求同时输出到一个连接中(更确切的说是输出到一个 TCP 连接中,或者说是放到一个 TCP 分组中),然后等待响应。这在浏览器需要大批量提交请求时可以大幅度缩短页面的加载时间,特别是在传输延迟较高的情况下(比如使用卫星网络)会更加明显。

使用管线化的请求方法需要是幂等的,非幂等的方法,例如 POST 将不会被管线化。连续的 GET 和 HEAD 请求总是可以管线化的,其他的幂等方法,如 PUT 和 DELETE 是否可以被管线化取决于一连串请求是否依赖于其他请求的响应。在初次创建连接时,不应启动管线化机制,因为对方(服务器或代理服务器)不一定支持该机制。支持管线化的服务端并不一定需要提供管线化的回复,只要求在收到管线化请求时不会失败即可。

管线化看起来很美好,然而很多现代的浏览器都没有进行支持或者默认关闭了该功能(在目前 HTTP/2 的大背景下,浏览器大都选择了支持多路复用)。导致这一现象的原因主要有:很多 HTTP 代理服务器可能不支持或者不通过管线化传输请求。正确的实现管线化是复杂的,传输中的资源大小,多少有效的 RTT 会被用到,还有有效带宽,管线化带来的改善有多大的影响范围。不知道这些的话,重要的消息可能被延迟到不重要的消息后面。这个重要性的概念甚至会演变为影响到页面布局,因此 HTTP 管线化在大多数情况下带来的改善并不明显。最后一个关键性的原因就是管线化会导致队首阻塞(Head-of-line blocking, HOL)。

从本质上讲,发生 HOL 的根本原因是由于 HTTP 请求和响应使用的是 FIFO 的队列机制,因此不管是 HTTP/1.0 还是 HTTP/1.1,都会发生 HOL。在 HTTP/1.1 中,我们假设客户端在一个连接上发送了几个连续的请求,按照规定,服务端应该按照收到请求的顺序返回结果,如果服务端在处理某个请求时花费了大量的时间,那么后面所有的请求都需要等待这个请求处理结束后才能进行。为了避免或者减少该问题,一般常见的处理方式有两种:一种是减少请求数量,这间接地催生出了很多网页设计的技巧,比如合并脚本和样式表,使用精灵图,以及将图片嵌入样式(将图片转换成 Base64 编码的形式)等等。另一种是同时打开多个持久连接,也就是所谓的域名分片。假设网站域名为 www.example.com,我们可以将它拆分成好几个域名,比如: www1.example.comwww2.example.com 等等,所有的域名都指向同一台服务器,这样浏览器访问网站时就可以同时开启多个 TCP 连接了。当然,这些都是无奈之举,如果 HTTP 协议设计得更好一些,这些额外的工作是可以避免的。

HTTP/2

说起 HTTP/2,就不得不提 SPDY。它是 Google 开发的一个基于 TCP 传输控制协议的应用层协议,也是 HTTP/2 的前身。Google 最早是在 Chromium 中提出的 SPDY 协议,并于 2009 年 11 月发布了 SPDY 协议的第一份草案。

SPDY 设计之初就是为了解决 HTTP/1.1 效率不高的问题。如果以提高效率为目的,那么不光应用层的 HTTP 协议,甚至传输层的 TCP 协议都有调整的空间,但是由于 TCP 作为更底层的协议已经存在了长达几十年之久,其实现已根植于全球网络的基础设施当中,可谓是牵一发而动全身,业界响应的积极性必然不高,所以 SPDY 一开始瞄准的就是 HTTP。

为了兼容现有的协议,减少甚至避免服务端升级带来的改动,SPDY 只是修改了 HTTP 请求和响应在网络上传输的方式,这就意味着只需在 HTTP 之下,TCP 和 SSL 之上增加一个 SPDY 传输层,这样就可以轻松兼容老版本的 HTTP 协议(将 HTTP/1.x 的内容封装成一种新的 frame 格式),同时可以使用已有的 SSL 功能,现有的服务端应用几乎不用做任何修改。

SPDY

2015 年 5 月,作为 HTTP 协议的第二个主要版本,HTTP/2 标准的 RFC 7540 规范发布,SPDY 协议最终只成为了一个互联网草案,但是 SPDY 开发组的成员全程参与了 HTTP/2 的制定,HTTP/2 的很多关键功能都来自于 SPDY,也就是说,SPDY 的成果被采纳并最终演变成了 HTTP/2。

消息帧

HTTP/2 与 HTTP/1.1 相比,其中一个最大的变化就是:HTTP/2 变成了一个二进制协议,消息头和消息体都被封装为更小的采用二进制编码的帧。

1
2
3
4
5
6
7
8
9
+-----------------------------------------------+
| Length (24) |
+---------------+---------------+---------------+
| Type (8) | Flags (8) |
+-+-------------+---------------+-------------------------------+
|R| Stream Identifier (31) |
+=+=============================================================+
| Frame Payload (0...) ...
+---------------------------------------------------------------+

在 HTTP/1.x 中,报文不是基于帧的,而是以文本分隔的,比如这样一个简单的例子:

1
2
3
4
5
6
7
8
9
GET / HTTP/1.1 <crlf>
Host: www.example.com <crlf>
Connection: keep-alive <crlf>
Accept: text/html,application/xhtml+xml,application/xml;q=0.9... <crlf>
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_4)... <crlf>
Accept-Encoding: gzip, deflate, sdch <crlf>
Accept-Language: en-US,en;q=0.8 <crlf>
Cookie: pfy_cbc_lb=p-browse-w; customerZipCode=99912|N; ltc=%20;...<crlf>
<crlf>

解析这种数据只需不断读入字节,直到遇到分隔符为止。由于一次只能处理一个请求或响应,并且解析在完成之前不能停止,所以往往速度慢且容易出错,同时也无法预测解析需要多少内存。这就带来了一系列的问题:该把一行读到多大的缓冲区里?如果行太长会发生什么,是应该增加并重新分配内存,还是返回 400 错误?

比较 HTTP/1.x,你会发现 HTTP/2 的帧格式定义更接近 TCP 层的方式。其中 length 是帧载荷的大小,type 是当前帧的类型(一共 10 种),flags 是具体帧类型的标识,stream id 作为每个流的唯一 ID,用于流控制,payload 是消息的正文,长度在 length 中设定。因为规范严格且明确,所以解析的逻辑可以像下面这样:

1
2
3
4
5
6
loop 
Read 9 bytes off the wire // 读前 9 字节
Length = the first three bytes // 长度值为前 3 字节
Read the payload based on the length. // 基于长度读载荷
Take the appropriate action based on the frame type. // 根据帧类型采取对应操作
end loop

这样一来,实现和维护都会简单很多。虽然看上去协议的格式完全不同了,但是实际上 HTTP/2 并没有改变 HTTP/1.x 的语义,只是把原来 HTTP/1.x 的 header 和 body 部分用 frame 重新封装了一层而已。调试的时候浏览器甚至会把 HTTP/2 的 frame 自动还原成 HTTP/1.x 的格式。

帧

值得一提的是,HTTP/2 将 HTTP/1.x 中的请求行变成了魔法伪首部,即所谓的一切都是 Header。举个例子,HTTP/1.1 的请求和响应可能是这样的:

1
2
3
4
5
6
7
8
GET / HTTP/1.1 
Host: www.example.com
User-agent: Next-Great-h2-browser-1.0.0
Accept-Encoding: compress, gzip

HTTP/1.1 200 OK
Content-type: text/plain
Content-length: 2

在 HTTP/2 中,它等价于:

1
2
3
4
5
6
7
8
9
:scheme: https 
:method: GET
:path: /
:authority: www.example.com
User-agent: Next-Great-h2-browser-1.0.0
Accept-Encoding: compress, gzip

:status: 200
content-type: text/plain

同时 HTTP/2 也没有了分块编码(chunked encoding),在基于帧的世界里,谁还需要分块?只有在无法预知数据长度的情况下向对方发送数据时,才会用到分块。

多路复用

在 HTTP/2 中,一个 TCP 连接可以存在若干个双向字节流,每个流可以承载若干条消息,每条消息都是一条逻辑 HTTP 消息,由若干最小的二进制帧(Frame)组成。帧是最小的通信单位,承载着特定类型的数据,例如 HTTP 头、消息载荷等等。下图是流的逻辑结构,图中的流作为一种逻辑通道被划分,而实际上应该将流理解成在连接上的一系列帧。

流的逻辑结构

HTTP/2 实现了完整的请求和响应复用,用户的每个请求都分配了一个流编号(Stream Id),客户端和服务器可以将 HTTP 消息分解为互不依赖的帧,然后交错发送,最后在另一端根据流编号将它们重新组装起来。

多路复用

如上图,客户端正在向服务端发送一个 DATA 帧(数据流 5),与此同时,服务端正向客户端交错发送数据流 1 和数据流 3 的一系列帧。此时一个连接上同时有三个并行的数据流。

有了多路复用,客户端就可以一次性发出所有资源的请求,服务端也可以立即着手处理这些请求。但是因此也带来了一个问题,我们假设服务端同时接收到了 100 个请求,但是请求没有标识哪个更重要,那么它将几乎同时发送每个资源,次要资源可能就会影响到关键资源的传输。因此,HTTP/2 提供了优先级(Priority)和依赖(Dependency)的功能,每个流都可以设置优先级和依赖。依赖为客户端提供了一种能力,通过指明某些对象对另一些对象有依赖,告知服务端这些对象应该优先传输,而优先级则能够让客户端告知服务端如何确定具有共同依赖关系的对象的传输顺序。优先级和依赖都是可以动态调整的。动态调整在有些场景下很有用,比如用户在浏览商品的时候,快速的滑动到了商品列表的底部,但前面的请求先发出,如果不把后面请求的优先级调高,用户当前浏览的图片要到最后才能加载完成,体验就要差一些。

头部压缩

HTTP 无状态的特性导致报文的头部一般都会携带大量的字段,有些字段是固定出现的,比如 User-AgentAcceptHost 等,有些则是自定义的。这些字段有时能够多达成百上千字节,但是 Body 部分却常常只有几十字节。过大的 Header 在一定程度上增加了传输的成本,更要命的是,大量报文中的很多字段都是重复的。

为了减少此类开销和提升性能,HTTP/2 使用了专门设计的 HPACK 算法来压缩请求和响应头的元数据,具体来说主要有两点:其一是客户端和服务端会根据 RFC 7541 的附录 B 中的哈夫曼编码(Huffman Coding)表,对传输的头字段和值进行编码,从而减小传输数据的大小。其二是客户端和服务端还会同时维护一份包含之前见过的头字段的索引列表。这份列表包含一个静态表和一个动态表:静态表由 RFC 7541 的附录 A 定义,其中包含常见头部名称以及常见头部名称与值的组合。动态表最初为空,根据先入先出的原则,在客户端与服务端进行数据交换时进行更新。

利用哈夫曼编码,我们可以在传输时对各个值进行压缩,而利用之前传输值的索引列表,我们可以通过传输索引值的方式对重复值进行编码。对于相同的数据,不再通过每次的请求和响应发送。比如下图中的两个请求,请求一发送了所有的头部字段,请求二则只需要发送差异数据。

头部压缩

服务端推送

提升单个对象性能的最佳方式,就是在它被用到之前就已经放到浏览器缓存当中,这也正是 HTTP/2 服务端推送的目的,说白了就是提前发送响应。当然,服务端也不能随意就发送对象给客户端,这会带来性能和安全问题,因此它需要解决推什么、何时推以及怎么推的问题。

首先,被推送的对象必须是可被缓存的,不能被缓存也就失去了推送的意义。如果服务端决定要推送一个对象(在 RFC 中被称为“推送响应”),就会先构造一个 PUSH_PROMISE 帧。在这个帧中,流 ID 一定会对应到客户端某个已发送的请求。举个例子:假设浏览器请求一个 HTML 页面,页面中有一些 JavaScript 对象,如果要推送此页面使用的某个 JavaScript 对象,服务端就会使用请求对应的流 ID 来构造 PUSH_PROMISE 帧,与此同时,PUSH_PROMISE 帧还会指示将要发送的响应所使用的流 ID。

在理想情况下,PUSH_PROMISE 帧应该更早发送,最起码应该早于客户端可能接收到承载着推送对象的 DATA 帧之前发送。如果客户端对 PUSH_PROMISE 中的任何元素不满,都可以按照拒收原因选择重置这个流(使用 RST_STREAM),或者发送 PROTOCOL_ERROR。一般当浏览器缓存中已经有了该对象时,会选择重置流。当 PUSH_PROMISE 涉及的协议层面有问题时,比如方法不安全(不是幂等的方法),或者客户端已经在 SETTINGS 帧中表明自己不接受推送时,仍然进行了推送,此时客户端会发送 PROTOCOL_ERROR。

假如客户端不拒收推送,服务端就会继续进行推送流程,用 PUSH_PROMISE 中指明的流来发送对象。需要注意的是,客户端会从 1 开始设置流 ID,之后每新开启一个流就会增加 2,即一直使用奇数。服务端开启在 PUSH_PROMISE 中标明的流时,设置的流 ID 从 2 开始,之后一直使用偶数。这种设计避免了客户端和服务端之间的流 ID 冲突,也可以轻松地判断哪些对象是由服务端推送的。0 是保留数字,用于连接级控制消息,不能用于创建新的流。

服务端推送

对于不同的应用,选择要推送哪些资源的逻辑可能也有很大不同,有些逻辑可能很简单,有些则有可能异常复杂。如果服务端的选择正确,那么确实会有助于提升页面的整体性能,反之则会损耗页面性能。尽管 SPDY 在很早之前就已经引入了服务端推送的特性,但是直到如今,通用的服务端推送解决方案还是很少。

关于 TLS

在 HTTP/2 的规范中并没有明确要求必须使用 TLS,也就是说 HTTP/2 其实是支持明文通信的,但是实际上主流的浏览器都不支持基于非 TLS 的 HTTP/2,所以从“事实上”来看,可以认为 HTTP/2 就是加密通信的。这背后主要有两个原因。一个非常现实的原因是,从之前对 WebSocket 和 SPDY 的实验看来,使用 Upgrade 首部,通过 80 端口(明文的 HTTP 端口)通信时,通信链路上代理服务器的中断等因素会导致非常高的错误率。如果基于 443 端口(HTTPS 端口)上的 TLS 发起请求,错误率会显著降低,并且协议通信也更简洁。还有一个原因是,人们越来越相信,考虑到安全和隐私,一切都应该被加密。HTTP/2 被视为一次推动全网加密通信发展的机会。

关于 HOL

我们前面说过,由于 HTTP 请求和响应使用的是 FIFO 的队列机制,因此不管是 HTTP/1.0 还是 HTTP/1.1,都会发生 HOL。而 HTTP/2 引入的双向多路复用流,则直接消除了 HTTP/1.x 请求的阻塞性质,相当于引入了一个更好的,功能完备的,完全支持的管线化机制(Pipelining)。

应当注意的是,虽然 HTTP/2 解决了 HTTP HOL 的问题,但是由于它还是构建在 TCP 之上,所以仍然面临着 TCP 层面的 HOL 问题,并且该问题在 HTTP/2 下会引发更严重的后果。我们知道,TCP 为了保证可靠传输,有一个“丢包重传”的机制,发生丢包时必须等待重新传输确认。由于 HTTP/2 只有一条 TCP 连接,如果单个 TCP 数据包丢失,则 TCP 连接必须请求重新发送该数据包,并等待该数据包成功传输,然后才能处理后续的 TCP 数据包,即使这些数据包是用于其他 HTTP/2 流的,这就导致了整个 TCP 连接中的请求都被阻塞。而在 HTTP/1.x 中,由于可以开启多个 TCP 连接,出现这种情况时反倒只会影响其中的某个或某些连接,其他连接还可以正常传输数据。

参考

HTTP/1.x 的连接管理

HTTP/3 原理实战

HTTP/2

《HTTP/2 基础教程》