跳到主要内容

HTTP2之旅

· 阅读需 23 分钟
info

本篇文章翻译至 https://kamranahmed.info/blog/2016/08/13/http-in-depth/

作者:Kamran Ahmed

HTTP 是每个开发者都知道的协议,因为所有的网页都在使用它,了解它绝对可以帮助你写出更好的应用。在这篇文章中,我会探讨 HTTP 是什么,它是如何形成的,以及未来又将如何。

什么是 HTTP

首先需要探讨的是,什么是 HTTPHTTP 是基于 TCP/IP 的应用层通信协议,它规定了客户端与服务器段之间如何通信。它定义了内容是如何在因特网上被请求和传输的。应用层协议只是一个抽象层协议,它规范了主机(客户端和服务器)是如何通信的,它本身依赖于 TCP/IP 来获得客户端和服务器之间的请求和响应。HTTP 默认使用 80 端口,但是也可以使用其他端口。HTTPS 使用 443 端口。

HTTP/0.9 - 只有一行(1991)

HTTP 的第一个版本是 HTTP/0.9,于 1991 年被提出。它是最简单的协议,只有一个 GET 方法。如果客户端想要获取服务器上的一个网页,它可能发出如下请求

GET /index.html

来自服务器的响应会是如下的形式

(response body)
(connection closed)

也就是说,服务器获得请求之后,将 HTML 作为响应返回,一旦内容传送完毕,连接就会关闭。

HTTP/0.9 有如下特点:

  • 没有头部
  • 只允许 GET 方法
  • 响应必须是 HTML

正如你所看到的,该协议除了成为未来的垫脚石外,真的没有其他内容。

HTTP/1.0 -1996

1996 年,HTTP 的下一个版本 HTTP/1.0 进化而出,它极大的改进了之前的版本。

不像 HTTP/0.9 被设计为只能返回 HTMLHTTP/1.0 可以处理其他类型的格式,例如图片、视频文件,文本或者其他内容。它添加了更多的方法(例如 POSTHEAD),请求/响应格式也发生了改变,在请求和响应中添加了头部,在响应中添加了状态码,字符集的支持也被引入,multi-part 类型,认证,缓存,内容编码以及更多的东西被添加进来。

HTTP/1.0 的请求的一个例子如下:

GET / HTTP/1.0
Host: kamranahmed.info
User-Agent: Mpzilla/5.0 (Macintosh; Intel Mac OS)
Accept: */*

正如你所看到的,除了请求之外,客户端还发送了它的个人信息和需要的响应类型。这个在 HTTP/0.9 的客户端中是不会发送的,因为 HTTP/0.9 没有头部。

如上请求的一个响应可能如下:

HTTP/1.0 200 OK 
Content-Type: text/plain
Content-Length: 137582
Expires: Thu, 05 Dec 1997 16:00:00 GMT
Last-Modified: Wed, 5 August 1996 15:55:28 GMT
Server: Apache 0.84

(response body)
(connection closed)

在响应的最开始是 HTTP/1.0 (跟在 HTTP 后面的数字表示版本号),然后是状态码 200,然后是状态信息(你也可以成为是状态吗的描述)。

在这个版本中,请求和响应中的头部都必须以 ASCII 编码,但是响应体可以是任何的类型,例如图像、视频、HTML、纯文本或者其他类型。所以,服务器现在可以发生任何类型给客户端,在引入不久之后,HTTP 中的 "Hyper Text" 成了误称,HMTP 或者 Hypermeida transfer protocol 是更准确的称呼,但是我猜测我们将会一直使用 HTTP 这个名称。

HTTP/1.0 的一个主要缺点就是你不能在每一个连接中发送多个请求,也就是说,不管什么时候客户端向服务器请求数据,它都需要打开一个新的 TCP 连接,然后当这个请求被处理完毕之后,连接就会被关闭。然后下一次发送请求的时候,它需要重新打开一个新连接。为什么这样不行呢? 我们假设你访问一个网页,它有 10 张图片,5 个样式表以及 5 个 javascript 文件,当我们访问这个网页的时候,总共 20 个资源需要被获取。因为服务器每次处理完一个响应之后就关闭连接,因此会有 20 条单独的连接,对于每一个资源服务器都会创建一个连接。因为 TCP 连接的三次握手以及慢启动,每一个连接都会带来性能问题, 因此数目巨大的连接数会导致性能急剧下降。

三次握手

在客户端和服务器交换它们的应用数据之前都需要经历 TCP 三次握手

  • SYN:客户端生成一个随机数,我们称之为 x,然后发送给服务器
  • SYN ACK:服务器通过发送一个 ACK x + 1 包给客户端表示收到了 x,并且也会生成一个随机数,我们称之为 y,将其发送给客户端
  • ACK:客户端返回 ACK y + 1,表示收到了服务端的数据

一旦三次握手完成之后,客户端和服务器之间就开始发送数据。我们需要注意的是,当客户端发送完最后一个 ACK 信号之后,客户端也许会立马发送应用层数据,但是服务器仍然需要等待 ACK 包的到来之后才能处理请求。

然后,一个 HTTP/1.0 的实现通过引入一个称为 Connection: keep-alive 的新的头部来克服上述问题,这个头部的意思就是告诉服务器:“嗨服务器,不要关闭连接哦!我待会还有用”。但是它没有被广泛的支持,因此问题仍然存在。

除了是无连接之外,HTTP 还是一个无状态的协议,也就是说服务器不会保存客户端的信息,为了让服务器处理请求,每个请求都需要携带必要的信息,每个连接与之前的连接都没有关系。这更加的火上浇油了,不仅客户端需要建立大量的连接,而且每次都需要发送冗余的数据,这会增加带宽的使用。

HTTP/1.1 - 1999

HTTP/1.0 发布三年之后,下一个版本 HTTP/1.1 于 1999 年发布,在之前基础上做出了很多的改进。HTTP/1.1 主要的改进包括:

  • 添加了新的 HTTP 方法,包括 PUT, PATCH, OPTIONS, DELETE

  • HTTP/1.0 中,Host 头部不是必须的,但是在 HTTP/1.1 中它是必须的。

  • 持久连接,正如上文讨论的一样,在 HTTP/1.0 中,每一个连接只有一个请求,每当请求被处理完毕之后连接立马被关闭,这会造成性能的急剧下降以及时延问题。HTTP/1.1 引入了持久连接,也就是说连接默认不会关闭,它可以多个连续的请求。为了关闭连接,需要在请求中设置 Connection: close。客户端一般不会最后一个请求中发送此头部来关闭连接。

  • 流水线,在 HTTP/1.1 中也引入了对流水线的支持,即在同一条连接中,客户端中可以同时发送多条请求而不必等待服务器做出响应,服务器必须按接收到请求的顺序做出响应。但是你可能会问,客户端是如何知道第一个响应接收完成了,下一个响应内容开始了。为了解决这个问题,我们必须使用 Content-Length 头部来表示响应体的数据有多长,这样客户端就可以根据接收到的数据长度来判断这个响应是否结束了。

    需要注意的是,为了从持久连接和流水线中获益,在响应中携带 Content-Length 头部是必需的,因为通过它客户端就可以知道传输什么时候完成,客户端就可以发送下一个请求了(通常发送连续的请求)或者开始等待下一个响应数据(如果允许以流水线发送请求)

    但是使用这种方法仍然有一个问题,当数据是动态的,服务器不清楚发送的数据有多长,这个时候怎么办? 在这种情况下,你就不能从持久连接中获得益处了!为了解决这个问题,HTTP/1.1 引入了分块编码(chunked encoding)。在这种情况下会省略 Content-Length,而支持分块编码(稍后再谈)。然后,如何它们两个都不可用,那么在该请求处理完毕之后连接就必须被关闭。

  • 分块传输,当传输动态内容时,服务器不知道 Conetnt-Length 的值,服务器就会以块的形式发送数据(一块一块的发送数据),并且发送的时候为每一个块添加 Content-Length。当所有的块被发送完毕之后,整个传输完成之后,服务器会发送一个空的块,它的 Conetnt-Length 大小被设置为 0 来指示传输已经完成了。为了通知客户端这个响应是分块传输的,服务器需要包含一个头部 Transfer-Encoding: chunked

  • 不像 HTTP/1.0 只有基本认证,HTTP/1.1 包括摘要和代理认证。

  • 缓存

  • 字节范围

  • 字符集

  • 语言协商

  • 客户端 cookies

  • 更强的压缩方式支持

  • 新的状态码

  • ...以及更多

我不打算在这片文章中深入讲解关于 HTTP/1.1 的特性,因为它本身就是一个话题,你可以找到很多关于它的内容。我会推荐一篇文档给你 Key differences between HTTP/1.0 and HTTP/1.1 以及它的 RFC 文档链接 origin RFC

HTTP/1.1 是在 1999 年推出的,它已经是一个多年的标准。即使它相对于之前的版本做出了很大的改进,但是 web 每天都在变化,它开始表现的过时了。如今,加载一个网页比以前更耗费资源。如今的一个简单网页都需要打开超过 30 个连接。你可能会说,为什么 HTTP/1.1 都有持久连接了,还需要打开这么多连接呢? 原因是,在 HTTP/1.1 中,它在任何时候都只能有一个未决的连接。HTTP/1.1 试图通过引入流水线来解决这个问题,但它并没有完全解决这个问题,因为队头阻塞,一个耗时较长的请求可能会阻塞后面的请求,只有等待这个请求被服务器处理完毕以后,下一个请求才会被服务器处理。为了克服 HTTP/1.1 的缺点,开发者相出了一些变通的法子,例如使用精灵图,CSS 中的编码图像,单个巨大的 CSS/JavaSCript 文件,域名分片等等。

SPDY - 2009

Google 带头冲锋开始研究另一个协议来使网页更快,以及在减少网页时延的同时提高网页的安全性。在 2009 年,他们宣布了 SPDY

note

SPDY 是 Google 的商标而不是缩写。

我们可以看到,如果我们持续的增加带宽,网络的性能在一开始会增加,一旦到达某个点后,性能的增益就不明显了。但是如果你对时延做同样的处理,如果我们不断的降低时延,就可以有一个稳定的性能增益。这就是 SPDY 获得性能增益背后的核心思想:减少时延来提高网络性能。

我不打算讨论 SPDY 的细节,因为当我们在下一节讨论 HTTP/2 的细节时,你会明白的,因为我说过 HTTP/2 的灵感主要来自 SPDY

SPDY 不是尝试要取代 HTTP,它是一个基于 HTTP 的转义层,它存在于应用层,在每次发送请求之前它会对请求进行更改。SPDY 已经开始成为事实上的标准,大多数浏览器开始实现它。

在 2015 年,Google 不想有两个竞争的标准,于是它们决定将其融入到 HTTP 中,这就导致了 HTTP/2 的诞生以及 SPDY 的废弃。

HTTP/2 - 2015

现在你一定明白我们为什么要对 HTTP 做一次修订。HTTP/2 被设计用来以低时延传输内容。与老版本 HTTP/1.1 不同的关键特性包括:

  • 二进制而不是文本
  • 多路复用——在一条连接上发送多个异步请求
  • 使用 HPACK 进行头部压缩
  • 服务器推送——对于单个请求有多个响应
  • 请求优先级
  • 安全

1. 二进制协议

HTTP/2 通过使用二进制协议来解决在 HTTP/1.x 中存在的高时延问题。二进制协议更容易被解析,虽然不像 HTTP/1.x 那样人眼可读。HTTP/2 的主要组成快是帧和流。

帧和流:

HTTP 消息由一个或多个帧组成。头部帧用来传输元数据(meta data),数据帧用来传递负载,存在几种不同的帧(HEADERS, DATA, RST_STREAM, SETTINGS, PRIORITY 等)。

每一个 HTTP/2 请求和响应都有一个唯一的流 ID (Stream ID),一个流可以被分为多个帧,帧其实就是二进制数据。一个流包含多个帧。每一个帧都有一个流 ID 来表明它属于哪一个流,每一个帧都有一个公共的头部。除了唯一的流ID之外,还值得一提的是,由客户端发出请求的流使用奇数作为流ID,由服务器做出响应的流使用偶数作为流ID。

除了 HEADERSDATA,其他帧类型中值得一提还有 RST_STREAM,它通常用来终止一些流。客户端会发送这个帧,让服务器知道它不在需要这个流了。在 HTTP/1.1 中让服务器终止发送响应给客户端的唯一方法就是关闭连接,但这会导致时延的增加,因为对于随后的请求它需要创建一个新的连接。但是在 HTTP/2 中,客户端可以使用 RST_STREAM 来阻止接收一个特定的流并且连接不会被关闭,其他的流仍然可以被传输。

2. 多路复用

正如我所说,HTTP/2 是一个二进制协议,使用帧和流发送请求和响应,一旦 TCP 连接被打开,所有的流通过同一个连接异步的发送,不用打开新的连接。同样,服务器以同样异步的方式做出响应,每一个响应都是无序的,客户端使用赋予的流ID来识别流属于哪一个包。这解决在 HTTP/1.x 中存在的队头阻塞问题,也就是说,客户端不用那些花时间的请求阻塞,其他请求也可以得到处理。

3. HPACK 头部压缩

它是一个单独的 RFC 的一部分,专门针对优化发送的头部。它的必要性在于,当我们不断的从一个客户端访问服务器时,我们每次都需要发送头部信息,每条请求的头部信息相差不大,意味着这些信息是冗余的,有的时候如果由 cookie 的话,会导致头部的大小增大,这会增大带宽的使用以及时延的增加。为了克服这点,HTTP/2 引入了头部压缩。

与请求和响应不同,头文件不是以 gzipcompress 等格式压缩的,而是有一个不同的头文件压缩机制,即使用 Huffman 编码对字面值进行编码,并由客户和服务器维护一个头文件表,客户和服务器在随后的请求中省略任何重复的头文件(如用户代理等),并使用双方维护的头文件表来引用它们。

当我们在谈论头信息时,让我在这里补充一下,头信息仍然与HTTP/1.1相同,只是增加了一些伪头信息,如 :method, :scheme, :host:path

4. 服务器推送

服务器推送是 HTTP/2 的另一个巨大功能,即服务器在知道客户端将要求某种资源的情况下,可以将其推送给客户端,甚至无需客户端要求。例如,假设浏览器加载了一个网页,它解析整个页面,找出需要从服务器加载的远程内容,然后向服务器发送相应的请求以获得这些内容。

服务器推送允许服务器通过推送它知道客户将需要的数据来减少往返的次数。它是如何做到的,服务器发送一个特殊的帧,称为 PUSH_PROMISE,通知客户端:"嘿,我即将把这个资源发送给你了!不要向我要了"。PUSH_PROMISE 帧与导致推送发生的流有关,它包含流ID,即服务器将发送要推送的资源的流。

5. 请求优先级

客户端可以通过在打开一个流的 HEADERS 帧中包含优先级信息来为一个流分配优先级。在任何其他时间,客户端可以发送一个 PRIORITY 帧来改变流的优先级。

在没有任何优先级信息的情况下,服务器以异步方式处理请求,即没有任何顺序。如果有优先权分配给一个流,那么基于这个优先权信息,服务器决定需要给多少资源来处理哪个请求。

6. 安全

对于HTTP/2是否应该强制要求安全(通过 TLS),进行了广泛的讨论。最后,决定不把它作为强制性的。然而,大多数供应商表示,只有当 HTTP/2 通过 TLS 使用时,他们才会支持它。因此,虽然在规范中 HTTP/2 并不要求加密,但它已经成为一种默认的强制性。说到这里,HTTP/2TLS上实现时确实提出了一些要求,即必须使用 TLS 1.2 版本或更高的版本,必须有一定程度的最小密钥大小,需要短暂的密钥等。