第十四讲 HTTP/2 协议
本章全面探讨了 HTTP/2 的底层工作原理,深入到数据层传输的帧及其通信方式。这将帮你理解协议提供的许多益处和问题。读完本章后,你应该能掌握足够的信息来调试和优化自己的H2安装设置,以便充分利用协议。如果你是勇士,希望进一步深入协议或靠自己来实现,RFC 75401 会是很好的起点。
14.1 HTTP/2分层
HTTP/2 大致可以分为两部分:分帧层,即H2多路复用能力的核心部分;数据或HTTP层,其中包含传统上被认为是HTTP及其关联数据的部分。彻底分开这两层,把它们当成彼此独立的事物,这是非常诱人的想法。仔细阅读 HTTP/2 规范的读者会发现,分帧层究竟是被设计成完全通用的、可重用的数据结构,还是用来传输HTTP内容,二者是有些分歧的。比如,规范起初泛泛地讨论了终端和双向通信——这对许多消息系统来讲是完美无缺的——然后话锋一转,讨论起了客户端、服务器、请求和响应。读到分帧层的时候,不要忘记这一事实:它的目的是传输 HTTP,而不是其他。
尽管数据层被设计成可以向后兼容 HTTP/1.1,对于熟悉H1并习惯于阅读线上协议的开发者来说,还有些地方需要重新确认。
二进制协议
H2的分帧层是基于帧的二进制协议。这方便了机器解析,但是肉眼识别起来比较困难。
首部压缩
仅仅使用二进制协议似乎还不够,H2的首部还会被深度压缩。这将显著减少传输中的冗余字节。
多路复用
在你喜爱的调试工具里查看基于H2传输的连接的时候,你会发现请求和响应交织在一起。
加密传输
最重要的是,线上传输的绝大部分数据是加密过的,所以在中途读取会更加困难。
现在,我们来展开这些话题。
14.2 连接
连接是所有 HTTP/2 会话的基础元素,其定义是客户端初始化的一个 TCP/IP socket,客户端是指发送HTTP请求的实体。这和H1是一样的,不过与完全无状态的H1不同的是,H2把它所承载的帧(frame)和流(stream)共同依赖的连接层元素捆绑在一起,其中既包含连接层设置也包含首部表(稍后有对两者更详细的描述)。也就是说,与之前的HTTP版本不同,每个H2连接都有一定的开销。之所以这么设计,是考虑到收益远远超过其开销。
是否支持h2
协议发现——识别终端是否支持你想使用的协议——会比较棘手。HTTP/2 提供两种协议发现的机制。
在连接不加密的情况下,客户端会利用 Upgrade 首部来表明期望使用h2。如果服务器也可以支持h2,它会返回一个“101 Switching Protocols”(协议转换)响应。这增加了一轮完整的请求-响应通信。
如果连接基于TLS,情况就不同了。客户端在 Client Hello 消息中设置 ALPN(Application-Layer Protocol Negotiation,应用层协议协商)扩展来表明期望使用H2协议,服务器用同样的方式回复。如果使用这种方式,那么H2在创建 TLS 握手的过程中完成协商,不需要多余的网络通信。值得注意的是,SPDY 和H2的早期修订版本使用 NPN(Next Protocol Negotiation,下一代协议协商)扩展来完成H2协商。它在 2014 年中期被 ALPN 取代。
表明终端支持H2的最后一个方法是使用HTTPAlternative Services(HTTP 替代服务)2 或 Alt-Svc。服务器可以用这种办法,在返回给客户端的响应首部中,表示后续的请求或许可以使用更合适的请求地址或协议。这个工具非常灵活,浏览器的支持也在不断增加。它不是用来替代 ALPN 的,但它是值得特别关注。
为了向服务器双重确认客户端支持 h2,客户端会发送一个叫作 connection preface(连接前奏)的魔法字节流,作为连接的第一份数据。这主要是为了应对客户端通过纯文本的 HTTP/1.1 升级上来的情况。该字节流用十六进制表示如下:
0x505249202a20485454502f322e300d0a0d0a534d0d0a0d0a
解码为 ASCII 是:
PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n
这个字符串的用处是,如果服务器(或者中间网络设备)不支持 h2,就会产生一个显式错误。这个消息特意设计成H1消息的样式。如果运行良好的H1服务器收到这个字符串,它会阻塞这个方法(PRI)或者版本(HTTP/2.0),并返回错误,可以让H2客户端明确地知道发生了什么错误。
这个魔法字符串会有一个 SETTINGS 帧紧随其后。服务器为了确认它可以支持 h2,会声明收到客户端的 SETTINGS 帧,并返回一个它自己的 SETTINGS 帧(反过来也需要确认),然后确认环境正常,可以开始使用 h2。大家做了很多工作,保证这个流程尽可能高效。虽然表面上看起来有点啰嗦,但客户端可以立即开始发送帧,并假设服务器的 SETTINGS 帧已经到了。如果在偶然情况下,过份乐观的客户端在 SETTINGS 帧之前收到一些数据,那么协商会失败,客户端和服务端都会收到 GOAWAY 帧。
隐藏的信息
连接前奏包含两条“秘密”信息。第一条是关于美国国家安全局 PRISM(棱镜)监控计划的一个笑话。HTTP/2 还处于发展早期时,恰好这份计划公开曝光,于是有些聪明人决定借协议让大家铭记这份计划。(你还认为我们协议开发人员没有幽默感吗?)第二条涉及 HTTP/2.0 的名称。在协议制定过程中,很早就把小数点去掉了,这表明未来的HTTP版本不能保证语义的向后兼容 3。然而,更早的版本一直没有去掉它。因此,本书中出现 HTTP/2.0 的地方,主要是考虑到历史准确性和上下文的需要。
14.3 帧
之前说过,HTTP/2 是基于帧(frame)的协议。采用分帧是为了将重要信息都封装起来,让协议的解析方可以轻松阅读、解析并还原信息。相比之下,H1不是基于帧的,而是以文本分隔。看看下面的简单例子:
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/14.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>
解析这种数据用不着什么高科技,但往往速度慢且容易出错。你需要不断读入字节,直到遇到分隔符为止(这里是指 <crlf>),同时还要考虑一些不太守规矩的客户端,它们会只发送 <lf>。于是大概需要这样一台状态机:
loop
while( ! CRLF )
read bytes
end while
if first line
parse line as the Request-Line
else if line is empty
break out of the loop # 完成
else if line starts with non-whitespace
parse the header line into a key/value pair
else if line starts with space
add the continuation header to the previous header
end if
end loop
# 好了,准备根据Transfer-encoding首部的值处理请求和响应,还有各种浏览器bug吧
这样写程序是可行的,并且这事已经做过无数次了。解析H1的请求或响应可能出现下列问题。
- 一次只能处理一个请求或响应,完成之前不能停止解析。
- 无法预判解析需要多少内存。这会带来一系列问题:你要把一行读到多大的缓冲区里;如果行太长会发生什么;应该增加并重新分配内存,还是返回 400 错误。为了解决这些问题,保持内存处理的效率和速度可不简单。
从另一方面来说,有了帧,处理协议的程序就能预先知道会收到什么。基于帧的协议,特别是 h2,开始有固定长度的字节,其中包含表示整帧长度的字段。图 14-1 是一个 HTTP/2 帧的结构。
图 14-1:HTTP/2 帧结构
前 9 个字节对于每个帧是一致的。解析时只需要读取这些字节,就可以准确地知道在整个帧中期望的字节数。其中每个字段的说明,参见表 14-1。
名称 |
长度 |
描述 |
Length |
3 字节 |
表示帧负载的长度(取值范围为 214~224-1 字节)。请注意,214 字节是默认的最大帧大小,如果需要更大的帧,必须在 SETTINGS 帧中设置 |
Type |
1 字节 |
当前帧类型(见表 14-2 中介绍) |
Flags |
1 字节 |
具体帧类型的标识 |
R |
1 位 |
保留位,不要设置,否则可能带来严重后果 |
StreamIDentifier |
31 位 |
每个流的唯一ID |
Frame Payload |
长度可变 |
真实的帧内容,长度是在 Length 字段中设置的 |
表14-1:帧首部字段
因为规范严格明确,所以解析逻辑大概是这样:
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
这样一来,实现和维护都会简单很多。相比依靠分隔符的H1,H2还有另一大优势:如果使用H1的话,你需要发送完上一个请求或者响应,才能发送下一个;由于H2是分帧的,请求和响应可以交错甚至多路复用。多路复用有助于解决类似队头阻塞的问题,具体描述见第 3 章。
H2协议中有 10 种不同的帧类型。概览见表 14-2,具体细节在附录 A 中讲解。
表14-2:HTTP/2帧类型
名称 |
ID |
描述 |
DATA |
0x0 |
传输流的核心内容 |
HEADERS |
0x1 |
包含HTTP首部,和可选的优先级参数 |
PRIORITY |
0x2 |
指示或者更改流的优先级和依赖 |
RST_STREAM |
0x3 |
允许一端停止流(通常由于错误导致的) |
SETTINGS |
0x4 |
协商连接级参数 |
PUSH_PROMISE |
0x5 |
提示客户端,服务器要推送些东西 |
PING |
0x6 |
测试连接可用性和往返时延(RTT) |
GOAWAY |
0x7 |
告诉另一端,当前端已结束 |
WINDOW_UPDATE |
0x8 |
协商一端将要接收多少字节(用于流量控制) |
CONTINUATION |
0x9 |
用以扩展 HEADER 数据块 |
可扩展空间
HTTP/2 内置了名为扩展帧的处理新的帧类型的能力。依靠这种机制,客户端和服务器的实现者可以实验新的帧类型,而无需制定新协议。按照规范,任何客户端不能识别的帧都会被丢弃,所以网络上新出现的帧就不会影响核心协议。当然,如果你的应用程序依赖于新的帧,而中间代理会丢弃它,那么可能会出现问题。
14.4 流
HTTP/2 规范对流(stream)的定义是:“HTTP/2 连接上独立的、双向的帧序列交换。”你可以将流看作在连接上的一系列帧,它们构成了单独的HTTP请求和响应。如果客户端想要发出请求,它会开启一个新的流。然后,服务器将在这个流上回复。这与H1的请求/响应流程类似,重要的区别在于,因为有分帧,所以多个请求和响应可以交错,而不会互相阻塞。流ID(帧首部的第 6~9 字节)用来标识帧所属的流。
客户端到服务器的H2连接建立之后,通过发送 HEADERS 帧来启动新的流,如果首部需要跨多个帧,可能还发会送 CONTINUATION 帧(更多信息参见下面的附注栏“CONTINUATIONS 帧”)。该 HEADERS 帧可能来自HTTP请求,也可能来自响应,具体取决于发送方。后续流启动的时候,会发送一个带有递增流ID 的新 HEADERS 帧。
CONTINUATION 帧
HEADERS 帧通过在帧的 Flags 字段中设置 END_HEADERS 标识位来标识首部的结束。在单个 HEADERS 帧装不下所有HTTP首部的情况下(例如,帧可能比当前最大长度还长),不会设置 END_HEADERS 标识位,而是在之后跟随一个或多个 CONTINUATION 帧。我们可以把 CONTINUATION 帧当作特殊的 HEADERS 帧。那么,为什么要使用特殊的帧,而不是再次使用 HEADERS 帧?如果重复使用 HEADERS,那么后续的 HEADERS 帧的负载就得经过特殊处理才能和之前的拼接起来。这些帧首部是否需要重复?这样的话,如果帧之间存在分歧该怎么办?协议开发者不喜欢这类模棱两可的情况,因为它可能在未来引起麻烦。考虑到这一点,工作组决定增加一个明确的帧类型,以避免实现混淆。
需要注意的是,由于 HEADERS 和 CONTINUATION 帧必须是有序的,使用 CONTINUATION 帧会破坏或减损多路复用的益处。CONTINUATION 帧是解决重要场景(大首部)的工具,但只能在必要时使用。
14.4.1 消息
HTTP 消息泛指HTTP请求或响应。上一节已经讲过,流是用来传输一对请求/响应消息的。一个消息至少由 HEADERS 帧(它初始化流)组成,并且可以另外包含 CONTINUATION 和 DATA 帧,以及其他的 HEADERS 帧。图 14-2 是普通 GET 请求的示例流程。
图 14-2:GET 请求和响应消息
图 14-3 展示了某个 POST 消息对应的各帧可能的样子。请注意,POST 和 GET 的主要差别之一就是 POST 请求通常包含客户端发出的大量数据。
图 14-3:Post 请求的请求和响应消息
H1的请求和响应都分成消息首部和消息体两部分;与之类似,H2的请求和响应分成 HEADERS 帧和 DATA 帧。
HTTP 消息是在 HTTP/1.1 的 RFC 72304 中定义的,此处供参考。
HTTP/1 和 HTTP/2 消息的下列差别是需要注意的。
一切都是 header
H1把消息分成两部分:请求/状态行;首部。H2取消了这种区分,并把这些行变成了魔法伪首部。举个例子,HTTP/1.1 的请求和响应可能是这样的:
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 中,它等价于:
: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
请注意,请求和状态行在这里拆分成了多个首部,即 :scheme、:method、:path 和 :status。同时要注意的是,H2的这种表示方式跟数据传输时不同。想了解更多信息的话,可以翻到附录 A 的 14.8.3 节查看 HEADERS 帧的描述,14.6 节也有相关内容可供参考。
没有分块编码(chunked encoding)
在基于帧的世界里,谁还需要分块?只有在无法预先知道数据长度的情况下向对方发送数据时,才会用到分块。在使用帧作为核心协议的H2里,就不再需要它了。
不再有 101 的响应
Switching Protocol 响应是H1的边缘应用。它如今最常见的应用可能就是用以升级到 WebSocket 连接。ALPN 提供了更明确的协议协商路径,往返的开销也更小。
14.4.2 流量控制
H2的新特性之一是基于流的流量控制。不同于H1的世界,只要客户端可以处理,服务端就会尽可能快地发送数据,H2提供了客户端调整传输速度的能力。(并且,由于在H2中,一切几乎都是对称的,服务端也可以调整传输的速度。)WINDOW_UPDATE 帧用来指示流量控制信息。每个帧告诉对方,发送方想要接收多少字节。当一端接收并消费被发送的数据时,它将发出一个 WINDOW_UPDATE 帧以指示其更新后的处理字节的能力。(许多早期的 HTTP/2 实现者花了大量时间调试窗口更新机制,来回答“为什么我没有取到数据”的问题。)发送方有责任遵守这些限制。
客户端有很多理由使用流量控制。一个很现实的原因可能是,确保某个流不会阻塞其他流。也可能客户端可用的带宽和内存比较有限,强制数据以可处理的分块来加载反而可以提升效率。尽管流量控制不能关闭,把窗口最大值设定为设置 231-1 就等效于禁用它,至少对小于 2GB 的文件来说是如此。另一个需要注意的是中间代理。通常情况下,网络内容通过代理或者 CDN 来传输,也许它们就是传输的起点或终点。由于代理两端的吞吐能力可能不同,有了流量控制,代理的两端就可以密切同步,把代理的压力降到最低。
流量控制示例
在流建立的时候,窗口大小默认都是 65 535(216-1)字节。假设客户端 A 支持该默认值,它的另一端(B)发送了 10 000 字节,B 也会关注窗口大小(现在有 55 535 字节了)。现在 A 花时间处理了 5000 字节,还剩下 5000 字节,然后它会发送一个 WINDOW_UPDATE 帧,说明它现在的窗口大小是 60 535 字节。B 收到这个帧之后,开始发送一个大文件(比如 4GB 大小)。在这个场景下,在 B 等 A 准备好接收更多的数据之前,B 能发送的数据量就是当前窗口的大小,即 60 535 字节。通过这种方式,A 可以控制 B 发送数据的最大速率。
14.4.3 优先级
流的最后一个重要特性是依赖关系。现代浏览器都经过了精心设计,首先请求网页上最重要的元素,以最优的顺序获取资源,由此来优化页面性能。拿到了 HTML 之后,在渲染页面之前,浏览器通常还需要 CSS 和关键 JavaScript 这样的东西。在没有多路复用的时候,在它可以发出对新对象的请求之前,需要等待前一个响应完成。有了 h2,客户端就可以一次发出所有资源的请求,服务端也可以立即着手处理这些请求。由此带来的问题是,浏览器失去了在H1时代默认的资源请求优先级策略。假设服务器同时接收到了 100 个请求,也没有标识哪个更重要,那么它将几乎同时发送每个资源,次要元素就会影响到关键元素的传输。
H2通过流的依赖关系来解决这个问题。通过 HEADERS 帧和 PRIORITY 帧,客户端可以明确地和服务端沟通它需要什么,以及它需要这些资源的顺序。这是通过声明依赖关系树和树里的相对权重实现的。
- 依赖关系为客户端提供了一种能力,通过指明某些对象对另一些对象有依赖,告知服务器这些对象应该优先传输。
- 权重让客户端告诉服务器如何确定具有共同依赖关系的对象的优先级。
来看下面这个简单的网站:
- index.html
header.jpg
critical.js
less_critical.js
style.css
ad.js
photo.jpg
在收到主体 HTML 文件之后,客户端会解析它,并生成依赖树,然后给树里的元素分配权重。这时这棵树可能是这样的:
- index.html
style.css
critical.js
less_critical.js (weight 20)
photo.jpg (weight 8)
header.jpg (weight 8)
ad.js (weight 4)
在这个依赖树里,客户端表明它最需要的是 style.css,其次是 critical.js。没有这两个文件,它就不能接着渲染页面。等它收到了 critical.js,就可以给出其余对象的相对权重。权重表示服务一个对象时所需要花费的对应“努力”程度。这个例子中,less_critical.js 的权重为 20,而所有元素的权重之和为 40。也就是说,服务器应当花费大约一半的时间或资源用以传输 less_critical.js,其他三个占了另外一半。称职的服务器会尽最大努力确保客户端尽快获得这些对象。不过说到底,做什么以及如何处理优先级,还是得听服务器的。它仍有做它自己认为正确的事的权力。处理优先级的智能水平,可能会是决定各种支持H2的 Web 服务器性能优劣的重要因素。
14.5 服务端推送
提升单个对象性能的最佳方式,就是在它被用到之前就放到浏览器的缓存里面。这正是 HTTP/2的服务端推送的目的。推送使服务器能够主动将对象发给客户端,这可能是因为它知道客户端不久将用到该对象。如果允许服务器随意地将对象发送给客户端,可能会产生包括性能和安全在内的一系列问题,因此它不仅仅是一个如何做的问题,还是一个如何做才对的问题。
14.5.1 推送对象
如果 服务 器决 定要推送一 个对 象(RFC 中称 为“推 送响 应”),会 构造一 个 PUSH_ PROMISE 帧。这个帧有很多重要属性,列举如下。
- PUSH_PROMISE 帧首部中的流ID 用来响应相关联的请求。推送的响应一定会对应到客户端已发送的某个请求。如果浏览器请求一个主体 HTML 页面,如果要推送此页面使用的某个 JavaScript 对象,服务器将使用请求对应的流ID 构造 PUSH_PROMISE 帧。
- PUSH_PROMISE 帧的首部块与客户端请求推送对象时发送的首部块是相似的。所以客户端有办法放心检查将要发送的请求。
- 被发送的对象必须确保是可缓存的。
- :method 首部的值必须确保安全。安全的方法就是幂等的那些方法,这是一种不改变任何状态的好办法。例如,GET 请求被认为是幂等的,因为它通常只是获取对象,而 POST 请求被认为是非幂等的,因为它可能会改变服务器端的状态。
- 理想情况下,PUSH_PROMISE 帧应该更早发送,应当早于客户端接收到可能承载着推送对象的 DATA 帧。假设服务器要在发送 PUSH_PROMISE 之前发送完整的 HTML,那客户端可能在接收到 PUSH_PROMISE 之前已经发出了对这个资源的请求。H2足够健壮,可以优雅地解决这类问题,但还是会有些浪费。
- PUSH_PROMISE 帧会指示将要发送的响应所使用的流ID。
客户端会从 1 开始设置流ID,之后每新开启一个流,就会增加 2,之后一直使用奇数。服务器开启在 PUSH_PROMISE 中标明的流时,设置的流ID 从 2 开始,之后一直使用偶数。这种设计避免了客户端和服务器之间的流ID 冲突,也可以轻松地判断哪些对象是由服务端推送的。0 是保留数字,用于连接级控制消息,不能用于创建新的流。
如果客户端对 PUSH_PROMISE 的任何元素不满意,就可以按照拒收原因选择重置这个流(使用 RST_STREAM),或者发送 PROTOCOL_ERROR(在 GOAWAY 帧中)。常见的情况是缓存中已经有了这个对象。5 而 PROTOCOL_ERROR 是专门留给 PUSH_PROMISE 涉及的协议层面问题的,比如方法不安全,或者当客户端已经在 SETTINGS 帧中表明自己不接受推送时,仍然进行了推送。值得注意的是,服务器可以在 PUSH_PROMISE 发送后立即启动推送流,因此拒收正在进行的推送可能仍然无法避免推送大量资源。推送正确的资源是不够的,还需要保证只推送正确的资源,这是重要的性能优化手段。
假设客户端不拒收推送,服务端会继续进行推送流程,用 PUSH_PROMISE 中指明ID 对应的流来发送对象(如图 14-4 所示)。
图 14-4:服务端推送消息处理
14.5.2 选择要推送的资源
根据应用的不同,选择推送哪些资源的逻辑可能非常简单,也可能异常复杂。拿一个简单的 HTML 页面来说,如果服务器接收到一个页面的请求,它需要决定是推送页面上的资源还是等客户端来请求。决策的过程需要考虑到如下方面:
- 资源已经在浏览器缓存中的概率
- 从客户端看来,这些资源的优先级(参见 14.4.3 节)
- 可用的带宽,以及其他类似的会影响客户端接收推送的资源
如果服务器选择正确,那就真的有助于提升页面的整体性能,反之则会损耗页面性能。尽管 SPDY 早在 5 年前就已经引入了这个特性,但如今通用的服务端推送解决方案非常少见,原因可能就在这里。
更特殊的情况是,API 或通过H2通信的应用程序可能更容易决定近期需要什么,并知道客户端还没有缓存什么。想想服务端给原生应用推送的更新吧,这将是接下来在服务端推送上收益最大的场景。
14.6 首部压缩
3.1.3 节中“臃肿的消息首部”提到过,现代网页平均包含 140 个请求,每个HTTP请求平均有 460 字节,总数据量达到 63KB。即使在最好的环境下,这也会造成相当长的延时,如果考虑到拥挤的 WiFi 或连接不畅的蜂窝网络,那可是非常痛苦的。这些请求之间通常几乎没有新的或不同的内容,这才是真正的浪费。所以,大家迫切渴望某种类型的压缩。
一开始我们就知道,首部压缩(HPACK)将会是 HTTP/2 的关键元素之一。但是首部应该怎么压缩?浏览器的世界刚从 SPDY 的 CRIME 漏洞中恢复过来,该漏洞以创造性的方式利用 deflate 首部压缩算法来解密早期的加密帧,因此原有的方法肯定不行。我们需要的机制应当可以抵御 CRIME,同时具备和 GZIP 类似的压缩能力。
经过多次创新性的思考和讨论,人们提出了 HPACK。HPACK 是种表查找压缩方案,它利用霍夫曼编码获得接近 GZIP 的压缩率。要想了解 HPACK 的工作原理,最好的办法可能是举个简单例子。
为什么不直接用 GZIP 做首部压缩,而要使用 HPACK ?那样肯定能节省大量工作。不幸的是,CRIME 攻击告诉我们,GZIP 也有泄漏加密信息的风险。CRIME 的原理是这样的,攻击者在请求中添加数据,观察压缩加密后的数据量是否会小于预期。如果变小了,攻击者就知道注入的文本和请求中的其他内容(比如私有的会话 cookie)有重复。在很短的时间内,经过加密的数据内容就可以全部搞清楚。因此,大家放弃了已有的压缩方案,研发出 HPACK。
下载 Web 页面及其依赖的资源往往涉及大量的请求,单个 Web 页面的请求通常数以百计,而这些请求往往非常相似。以下面两个请求为例,它们看起来像是浏览器请求完整网页的会话中先后发生的。少数不同的字节用加粗字体强调。
第一个请求:
:authority: www.akamai.com
:method: GET
:path: /
:scheme: https
accept: text/html,application/xhtml+xml
accept-language: en-US,en;q=0.8
cookie: last_page=286A7F3DE
upgrade-insecure-requests: 1
user-agent: Awesome H2/1.0
第二个请求:
:authority: www.akamai.com
:method: GET
:path: /style.css
:scheme: https
accept: text/html,application/xhtml+xml
accept-language: en-US,en;q=0.8
cookie: last_page=*398AB8E8F
upgrade-insecure-requests: 1
user-agent: Awesome H2/1.0
可以看到,后者的很多数据与前者重复了。第一个请求约有 220 字节,第二个约有 230 字节,但二者只有 36 字节是不同的。如果仅仅发送这 36 字节,就可以节省约 85%的字节数。简而言之,HPACK 的原理就是这样。
下面是一个专门设计的简化的例子,来帮助你理解 HPACK 到底做了些什么。现实情况会更复杂,也没有那么理想,如果你想学习更多,应该阅读 RFC 7541,“HPACK:HTTP/2 的首部压缩”6。
假设客户端按顺序发送如下请求首部:
Header1: foo
Header2: bar
Header3: bat
当客户端发送请求时,可以在首部数据块中指示特定首部及其应该被索引的值。它会创建一张表:
索引 |
首部名称 |
值 |
62 |
Header1 |
foo |
63 |
Header2 |
bar |
64 |
Header3 |
bat |
如果服务端读到了这些请求首部,它会照样创建一张表。客户端发送下一个请求的时候,如果首部相同,它可以直接发送这样的首部块:
62 63 64
服务器会查找先前的表格,并把这些数字还原成索引对应的完整首部。
这里的首部压缩机制中每个连接都维护了自己的状态,这一点尤其值得注意,因为这在H1的协议层面中是不存在的。
HPACK 的实现比这个要复杂得多。读者若对此感兴趣,以下提供了一些线索。
- 实际上,请求端和响应端各维护了两张表格。其中之一是动态表,创建方法和上面差不多。另一张是静态表,它由 61 个最常见的首部的键值组合而成。例如 :method: GET 在静态表中索引为 2。按规定,静态表包含 61 个条目,所以上例索引编号从 62 开始。
- 关于字段如何索引,有很多控制规则,其中包含:
-
-
- 发送索引编号和文本值(如上例所示);
- 仅发送文本值,不对它们进行索引(对于一次性或敏感首部);
- 发送索引的首部名,值用文本表示,但不进行索引处理(如 :path: /foo.html,其值每次都不同);
- 发送索引过的首部名和值(如上例中的第二个请求)。
-
-
- 使用打包方案的整数压缩,以实现极高的空间效率。
- 利用霍夫曼编码表进一步压缩字符串。
实验表明,HPACK 表现非常好,尤其是针对网站有大量重复首部(比如 cookie)的情况。由于到固定网站的各个请求的大部分首部信息是重复的,HPACK 的表查找机制有效去除了通信中的重复字节。
14.7 线上传输
下面来看一个 HTTP/2 的请求和响应,并逐层解析它。再强调一次,这里我们都用文本表示,是为了方便阅读,实际在线上传输的H2信息是经过压缩的二进制数据。
一个简单的GET请求
GET 是HTTP协议中的主力。它的语义简单,名副其实,用于从服务器获得一份资源。示例 14-1 是一个到 akamai.com 的请求(为清楚起见,部分较长的行已缩略)。
示例 14-1 HTTP/2 GET 请求
:authority: www.akamai.com
:method: GET
:path: /
:scheme: https
accept: text/html,application/xhtml+xml,...
accept-language: en-US,en;q=0.8
cookie: sidebar_collapsed=0; _mkto_trk=...
upgrade-insecure-requests: 1
user-agent: Mozilla/14.0 (Macintosh;...
这个请求通过 HTTPS 的 GET 方法,从 www.akamai.com 获取首页。其响应如示例 14-2 所示。
示例 14-1 中的首部名称 :authority 可能看起来有点奇怪。为什么不是 :host 呢?原因在于,它类似于 URI 中的 Authority 段,而不是 HTTP/1.1 中的 Host 首部。Authority 段包含主机信息,可能还有端口号,这样就刚好可以替代 Host 首部的角色。读过 URI RFC7 的读者需要注意,Authority 段里的 User Information(用户信息,如用户名和密码),在H2中是明令禁止的。
示例 14-2 HTTP/2 GET 响应(只包含首部信息)
:status: 200
cache-control: max-age=600
content-encoding: gzip
content-type: text/html;charset=UTF-8
date: Tue, 31 May 2016 23:38:47 GMT
etag: "08c024491eb772547850bf157abb6c430-gzip"
expires: Tue, 31 May 2016 23:48:47 GMT
link: <https://c.go-mpulse.net>;rel=preconnect
set-cookie: ak_bmsc=8DEA673F92AC...
vary: Accept-Encoding, User-Agent
x-akamai-transformed: 9c 237807 0 pmb=mRUM,1
x-frame-options: SAMEORIGIN
<DATA Frames follow here>
在这个响应中,服务器表示请求已成功受理(状态码 200),设置了 cookie(cookie 首部),表示返回的内容使用 gzip 压缩(content-encoding 首部),还发送了需要用到的其他重要信息。
先来看看这个简单的 GET 请求背后到底发生了什么。nghttp8 是 Tatsuhiro Tsujikawa 提供的强力工具,通过它可以看到详细信息的输出,并弄清楚H2的各个细节:
$ nghttp -v -n --no-dep -w 14 -a -H "Header1: Foo" https://www.akamai.com
这条命令把窗口大小设置为 16KB(214),添加了一个没有意义的首部,并请求下载页面的一些关键资源。下面是这个命令的详细输出,并加了注解:
[ 0.047] Connected
The negotiated protocol:H2➊
[ 0.164] send SETTINGS frame <length=12, flags=0x00, stream_id=0> ➋
(niv=2)
[SETTINGS_MAX_CONCURRENT_STREAMS(0x03):100]
[SETTINGS_INITIAL_WINDOW_SIZE(0x04):16383] ➌
可以看到 nghttp 的情况如下。
❶ 成功协商建立H2连接。
❷ 按照规范,立即发送一个 SETTINGS 帧。
❸ 按命令行中的要求,将窗口大小设置为 16KB。
请注意,stream_id 0 用于连接层的信息。(你在输出中并没看到连接前奏,但它其实已经在 SETTINGS 帧之前发送过了。)
接下来是输出日志:
[ 0.164] send HEADERS frame <length=45, flags=0x05, stream_id=1>
; END_STREAM | END_HEADERS ➍
(padlen=0)
; Open new stream
:method: GET
:path: /
:scheme: https
:authority: www.akamai.com
accept: */*
accept-encoding: gzip, deflate
user-agent: nghttp2/1.9.2
header1: Foo ➎
这是请求的首部块。
❹ 注意,客户端(nghttp)发送了 END_HEADERS 和 END_STREAM 标识。这告诉服务器没有更多的首部,也没有其他数据了。如果这是 POST 请求,此时不会发送 END_STREAM 标识。
❺ 这是我们在 nghttp 命令行中添加的请求首部。
[ 0.171] recv SETTINGS frame <length=30, flags=0x00, stream_id=0> ➏
(niv=5)
[SETTINGS_HEADER_TABLE_SIZE(0x01):4096]
[SETTINGS_MAX_CONCURRENT_STREAMS(0x03):100]
[SETTINGS_INITIAL_WINDOW_SIZE(0x04):65535]
[SETTINGS_MAX_FRAME_SIZE(0x05):16384]
[SETTINGS_MAX_HEADER_LIST_SIZE(0x06):16384]
[ 0.171] send SETTINGS frame <length=0, flags=0x01, stream_id=0> ➐
; ACK
(niv=0)
[ 0.197] recv SETTINGS frame <length=0, flags=0x01, stream_id=0>
; ACK
(niv=0)
❻ nghttpd 收到了服务器的 SETTINGS 帧。
❼ 发送并接收到了 SETTINGS 帧的确认。
[ 0.278] recv (stream_id=1, sensitive) :status: 200 ➑ ➒
[ 0.279] recv (stream_id=1, sensitive) last-modified: Wed, 01 Jun 2016 ...
[ 0.279] recv (stream_id=1, sensitive) content-type: text/html;charset=UTF-8
[ 0.279] recv (stream_id=1, sensitive) etag: "0265cc232654508d14d13deb...gzip"
[ 0.279] recv (stream_id=1, sensitive) x-frame-options: SAMEORIGIN
[ 0.279] recv (stream_id=1, sensitive) vary: Accept-Encoding, User-Agent
[ 0.279] recv (stream_id=1, sensitive) x-akamai-transformed: 9 - 0 pmb=mRUM,1
[ 0.279] recv (stream_id=1, sensitive) content-encoding: gzip
[ 0.279] recv (stream_id=1, sensitive) expires: Wed, 01 Jun 2016 22:01:01 GMT
[ 0.279] recv (stream_id=1, sensitive) date: Wed, 01 Jun 2016 22:01:01 GMT
[ 0.279] recv (stream_id=1, sensitive) set-cookie: ak_bmsc=70A833EB...
[ 0.279] recv HEADERS frame <length=458, flags=0x04, stream_id=1> ➓
; END_HEADERS
(padlen=0)
; First response header
现在拿到了服务端返回的响应首部。
❽ stream_id 为 1 表明响应对应的请求(我们刚刚只发了一个请求,但生活不会总如此简单)。
❾ nghttpd 从服务器获得了 200 状态码,这表示成功了。
❿ 注意,此时并没有发送 END_STREAM,因为下面还有 DATA 帧。
[ 0.346] recv DATA frame <length=2771, flags=0x00, stream_id=1> ⓫
[ 0.346] recv DATA frame <length=4072, flags=0x00, stream_id=1>
[ 0.348] recv DATA frame <length=4072, flags=0x00, stream_id=1>
[ 0.348] send WINDOW_UPDATE frame <length=4, flags=0x00, stream_id=1>
⓫ 最后我们从流里获得了数据。这里看到 3 个 DATA 帧,之后跟了一个 WINDOW_UPDATE 帧。客户端告诉服务器,它消耗掉了 10 915 字节的 DATA 帧,并为接下来更多的数据做好了准备。注意,此时流还没有结束,客户端还有其他事情要做,正好可以依靠多路复用。
[ 0.348] send HEADERS frame <length=39, flags=0x25, stream_id=15> ⓬
:path: /styles/screen.1462424759000.css
[ 0.348] send HEADERS frame <length=31, flags=0x25, stream_id=17>
:path: /styles/fonts--full.css
[ 0.348] send HEADERS frame <length=45, flags=0x25, stream_id=19>
:path: /images/favicons/favicon.ico?v=XBBK2PxW74
⓬ 客户端已经得到了主体 HTML 的部分内容,现在可以请求页面中的资源了。现在你看到 3 个新建的流,ID 分别是 15、17 和 19,其中有两个用于 CSS,一个用于 favicon。(为了方便读者理解,这里跳过和简化了一些帧。)
[ 0.378] recv DATA frame <length=2676, flags=0x00, stream_id=1>
[ 0.378] recv DATA frame <length=4072, flags=0x00, stream_id=1>
[ 0.378] recv DATA frame <length=1445, flags=0x00, stream_id=1>
[ 0.378] send WINDOW_UPDATE frame <length=4, flags=0x00, stream_id=13>
(window_size_increment=12216)
[ 0.379] recv HEADERS frame <length=164, flags=0x04, stream_id=17> ⓭
[ 0.379] recv DATA frame <length=175, flags=0x00, stream_id=17>
[ 0.379] recv DATA frame <length=0, flags=0x01, stream_id=17>
; END_STREAM
[ 0.380] recv DATA frame <length=2627, flags=0x00, stream_id=1>
[ 0.380] recv DATA frame <length=95, flags=0x00, stream_id=1>
[ 0.385] recv HEADERS frame <length=170, flags=0x04, stream_id=19> ⓭
[ 0.387] recv DATA frame <length=1615, flags=0x00, stream_id=19>
[ 0.387] recv DATA frame <length=0, flags=0x01, stream_id=19>
; END_STREAM
[ 0.389] recv HEADERS frame <length=166, flags=0x04, stream_id=15> ⓭
[ 0.390] recv DATA frame <length=2954, flags=0x00, stream_id=15>
[ 0.390] recv DATA frame <length=1213, flags=0x00, stream_id=15>
[ 0.390] send WINDOW_UPDATE frame <length=4, flags=0x00, stream_id=0>
(window_size_increment=36114)
[ 0.390] send WINDOW_UPDATE frame <length=4, flags=0x00, stream_id=15> ⓮
(window_size_increment=11098)
[ 0.410] recv DATA frame <length=3977, flags=0x00, stream_id=1>
[ 0.410] recv DATA frame <length=4072, flags=0x00, stream_id=1>
[ 0.410] recv DATA frame <length=1589, flags=0x00, stream_id=1> ⓯
[ 0.410] recv DATA frame <length=0, flags=0x01, stream_id=1>
[ 0.410] recv DATA frame <length=0, flags=0x01, stream_id=15>
此时可以看到服务端发过来的流交织在一起。
⓭ 你可以看到ID 为 15、17 和 19 的 HEADERS 帧。
⓮ 这些对应不同的窗口更新,包含一个连接层的更新,流ID 为 0。
⓯ID 为 1 的流最后的 DATA 帧。
[ 0.457] send GOAWAY frame <length=8, flags=0x00, stream_id=0>
(last_stream_id=0, error_code=NO_ERROR(0x00), opaque_data(0)=[])
最后我们看到了 GOAWAY 帧。虽然取了这么个名字,它却是断开连接的礼貌方式。9
这个过程乍一看可能有点神秘,但是多试几次就会熟悉了。从头到尾,一切都遵从逻辑、符合规范、用途明确。在这个简单的例子中,你可以看到构成H2的许多元素,包括流量控制、多路复用,以及连接设置。你可以用 nghttp 工具多测试一些支持H2的网站,看看是否可以走通上面的流程。熟练之后,你就已经迈过了理解协议的门槛。
14.8 HTTP/2 帧详解
本节是 HTTP/2 分帧层的一份简要参考。各节分别讲解帧类型数字、帧的二进制数据格式、帧的描述,以及对应帧的标识位列表。
14.8.1 帧首部
如第 5 章所述,每个帧以相同的 9 个字节开始。
各字段的描述参见表 14-1。
14.8.2 DATA帧
DATA 类型的帧包含的字节长度不定。换言之,这些帧包含了请求和发送的对象。如果超出帧容许的最大长度,资源数据会被切分到一个或者多个帧里面去。在某些情况下,还会包含填充长度(Pad Length)字段和填充数据(Padding),以隐藏真实的消息大小(出于安全方面的考虑)。
14.8.2.1 DATA帧字段
名称 |
长度 |
描述 |
Pad Length(填充长度) |
1 字节 |
填充字节的长度;在帧首部的 PADDED 标识设置为 1 的时候才会有该字段 |
DATA(数据) |
长度可变 |
帧的内容 |
Padding(填充数据) |
长度可变 |
长度为 Pad Length 字段的值,所有的字节被设置为 0 |
14.8.2.2 DATA帧标识位
名称 |
位 |
描述 |
END_STREAM |
0x1 |
表明这是流中最后的帧(流终止) |
PADDED |
0x8 |
表明此帧添加了填充数据,要处理 Pad Length 和 Padding 字段 |
14.8.3 HEADERS帧
HEADER 帧用以创建流,并向另一端发送消息首部。
14.8.3.1 HEADERS帧字段
名称 |
长度 |
描述 |
Pad Length(填充长度) |
1 字节 |
填充字节的长度;帧首部的 PADDED 标识设置为 1 时才会有该字段 |
E |
1 位 |
表示流依赖是否为专用的;只有设置了 PRIORITY 标识才会有该字段 |
Stream Dependency(流依赖) |
31 位 |
表示当前流所依赖的流,如果有的话;只有设置了 PRIORITY 标识才会有该字段 |
Weight(权重) |
1 字节 |
当前流的相对权重;只有设置了 PRIORITY 标识才会有该字段 |
Header Block Fragment(首部块片段) |
长度可变 |
消息的首部 |
Padding(填充数据) |
长度可变 |
长度为 Pad Length 字段的值,所有的字节被设置为 0 |
14.8.3.2 HEADERS帧标识位
名称 |
位 |
描述 |
END_STREAM |
0x1 |
表明这是流中最后的帧(流终止) |
END_HEADERS |
0x4 |
表明这是流中最后一个 HEADERS 帧;如果此标识未设置,表示随后会有 CONTINUATION 帧 |
PADDED |
0x8 |
表明此帧添加了填充数据,要使用 Pad Length 和 Padding 字段 |
PRIORITY |
0x20 |
如果设置了此标识,就表示要使用 E、Stream Dependency 以及 Weight 字段 |
14.8.4 PRIORITY帧
发送 PRIORITY 帧是为了标识流的优先级。它可以多次发送,后面指定的优先级会覆盖之前的。
PRIORITY帧字段
名称 |
长度 |
描述 |
E |
1 位 |
标识当前的流是否为专用,是否不依赖其他流 |
Stream Dependency(流依赖) |
31 位 |
如果当前流依赖其他流,标识其所依赖的流 |
Weight(权重) |
1 字节 |
当前流的相对权重 |
PRIORITY 帧没有专用标识。
14.8.5 RST_STREAM帧
如果要终止一个流,可以将 RST_STREAM 加在该流的两端。这通常是为了处理某种错误。
帧里的 Error Code(错误码)字段用来标注重置的原因。关于错误码的列表,可以参考 RFC 7540 的第 7 节 11。
14.8.6 SETTINGS帧
SETTINGS 帧包含了若干有序的键(Identifier)/值(value)对。键/值对的数量等于帧长度除以单组设置的长度(共 6 字节,Identifier 的 2 字节加上 Value 的 4 字节)。
SETTINGS帧参数列表
名称 |
ID |
默认值 |
描述 |
SETTINGS_HEADER_TABLE_SIZE |
0x1 |
4096 |
重新指定 HPACK 所用的首部表的最大尺寸 |
SETTINGS_ENABLE_PUSH |
0x2 |
1 |
如果设置为 0,当前端不会发送 PUSH_PROMISE 帧 |
SETTINGS_MAX_CONCURRENT_STREAMS |
0x3 |
无限制 |
表明发送端能够并行接收的流的最大数量 |
SETTINGS_INITIAL_WINDOW_SIZE |
0x4 |
65353 |
表明发送端流量控制的初始窗口尺寸 |
SETTINGS_MAX_FRAME_SIZE |
0x5 |
16384 |
发送端希望接收的最大帧尺寸;这个值必须介于初始值和 16 777 215(224-1)之间 |
SETTINGS_MAX_HEADER_LIST_SIZE |
0x6 |
无限制 |
该设置告诉通信的另一端,本端期望接收的最大首部的尺寸 |
如果一端接收并处理了 SETTINGS 帧,就必须返回一个 SETTINGS 帧,在帧首部中带上 ACK 标识(0x1)。这是 SETTINGS 帧里定义的唯一的标识位。这样发送端就知道接收端收到了新的 SETTINGS 帧,并会遵守 SETTINGS 帧的设置。
14.8.7 PUSH_PROMISE帧
服务端发送 PUSH_PROMISE 帧来告诉客户端,它将发送一份客户端尚未明确请求的资源。PUSH_PROMISE 帧实际上是对客户端发送的 HEADERS 帧的补充。
14.8.7.1 PUSH_PROMISE帧字段
名称 |
长度 |
描述 |
Pad Length(填充长度) |
1 字节 |
填充字节的长度;在帧首部的 PADDED 标识设置为 1 时才会有该字段 |
R |
1 位 |
保留位,不必设置 |
Promised StreamID(预期的流ID) |
31 位 |
告知发送端将要使用的流ID(总是偶数,因为是由服务端发送的) |
Header Block Fragment(首部块片段) |
长度可变 |
推送的消息首部 |
Padding(填充数据) |
长度可变 |
长度为 Pad Length 字段指定的值,各字节均为 0 |
14.8.7.2 PUSH_PROMISE帧标识
名称 |
位 |
描述 |
END_HEADERS |
0x4 |
表明这是流的最后一个 HEADERS 帧;如果此标识未设置,说明随后会有 CONTINUATION 帧 |
PADDED |
0x8 |
说明此帧包含了填充数据,要设置 Pad Length 和 Padding 字段 |
14.8.8 PING帧
PING 帧用以计算两端之间的往返时间。此帧有一个标识位 ACK(0x1)。如果一端收到一个不带 ACK 的 PING 帧,它就必须返回一个 PING 帧,这个帧必须设置 ACK 标识,并且包含同样的数据内容(Opaque Data)。需要注意的是,PING 帧不属于任何一个流(它们是连接层的),因此它们的流ID 要设置为 0x0。
14.8.9 GOAWAY帧
GOAWAY 帧用以礼貌地关闭连接。这是连接层的帧,并且发送时流ID 要设置为 0x0。通过发送 GOAWAY 帧,当前端可以清晰地告诉接收端,它接收到了什么、未接收到什么,以及什么原因(如果有的话)导致了 GOAWAY。如果出了问题,错误码将被设置为 RFC 7540 第 7 节中定义的某个错误码,并且 Last StreamID(最后一个流ID)会被设置为曾经处理过的最大的流ID。如果没有错误发生,而当前端要断开连接(浏览器标签页关闭以及连接超时等情况),那就发送 NO_ERROR(0x0)这个错误码,并且 Last StreamID 被设置为 231-1。
GOAWAY帧字段
名称 |
长度 |
描述 |
R |
1 位 |
保留位 |
Last StreamID(最后的流ID) |
31 位 |
GOAWAY 的发送端接收/处理的最大的流ID;发送这个值之后,接收方可以清楚地知道发送方接收到了什么,以及没有接收到什么 |
Error Code |
4 字节 |
H2定义的错误码,或者成功关闭时的 NO_ERROR 码 |
Additional Debug Data |
长度可变 |
发送方可能发送的其他数据内容,说明当前的状态或者其他问题 |
14.8.10 WINDOW_UPDATE帧
WINDOW_UPDATE 帧用来做流量控制——发送方发送一个 WINDOW_UPDATE 帧,告诉接收方自己此时期望接收多少字节。流量控制可以应用到单个的流,也可以应用到连接承载的所有流(流ID 为 0x0)。需要注意的是,在单个流上指定的 WINDOW_UPDATE 帧也会作用于连接层的流量控制。
WINDOW_UPDATE帧字段
名称 |
长度 |
描述 |
R |
1 位 |
保留位 |
Window Size Increment(窗口大小增量) |
31 位 |
当前窗口可以增加的字节数 |
WINDOW_UPDATE 帧没有专用标识。
14.8.11 CONTINUATION帧
CONTINUATION 帧包含之前的 HEADERS、PUSH_PROMISE,或者 CONTINUATION 帧的附加首部。
14.8.11.1 CONTINUATION帧字段
名称 |
长度 |
描述 |
Header Block Fragment(首部块片段) |
长度可变 |
具体描述参见 HEADERS 帧 |
14.8.11.2 CONTINUATION帧标识位
名称 |
位 |
描述 |
END_HEADERS |
0x4 |
表明这是流中最后的 HEADERS 帧;如果此标识没有设置,说明后面还有 CONTINUATION 帧 |
14.9 小结
HTTP/2 协议的制定经过了许多年,包含了各种设计理念、决策、创新,以及妥协。本章提供了一些基础知识,让读者能够查看H2的 Wireshark 导出文件(参见 8.7 节),并了解背后的原理,甚至可以帮助读者在实际使用 HTTP/2 的时候发现潜在问题(也许是不断更改 cookie)。对想要深入研究的读者来说,最好的学习资源就是 RFC 7540 本身 10。无论是实现者、调试者,还是你内心潜伏的受虐狂人格,需要的所有细节都在这里。
1 https://tools.ietf.org/html/rfc7540
2 https://tools.ietf.org/html/rfc7838
3 因为这意味着不会有 2.1、2.2 之类的版本。——译者注
4 https://tools.ietf.org/html/rfc7230
5此时会重置。——译者注
6 https://tools.ietf.org/html/rfc7541
7 https://www.ietf.org/rfc/rfc3986.txt
8 https://github.com/nghttp2/nghttp2
9英文 go away 的意思是“滚开”。——译者注
10 https://tools.ietf.org/html/rfc7540
11 https://tools.ietf.org/html/rfc7540#section-7
0 条 查看最新 评论
没有评论