HTTP/2 in Go(二)

女主宣言

上一篇文章中介绍了HTTP/2的二进制分帧和多路复用的特性,这次来介绍下头部压缩和服务端推送。 本文来自公众号“ 360搜索技术团队 ”的投稿,作者付坤。

PS:丰富的一线技术、多元化的表现形式,尽在“ 360云计算 ”,点关注哦!

HTTP/2 in GO(一)

上一篇文章中介绍了HTTP/2的二进制分帧和多路复用的特性,这次来介绍下头部压缩和服务端推送。

HTTP/2新增特性

  • 二进制分帧(HTTP Frames)

  • 多路复用

  • 头部压缩

  • 服务端推送(Server Push)

1

头部压缩

在HTTP/1.x中,每次HTTP请求都会携带需要的header信息,这些信息以纯文本形式传递,所以每次的请求和响应,都会浪费一些带宽,如果header信息中包含cookie等之类的信息,那么浪费的带宽就更可观了。为了减少带宽开销和提升性能,HTTP/2 使用  HPACK  压缩格式压缩请求和响应标头元数据,这种格式采用两种简单但是强大的技术:

  • 这种格式支持通过静态 Huffman 编码 对传输的header字段进行编码,从而减小了传输的大小。

  • 这种格式要求客户端和服务器同时维护和更新一个包含之前见过的header字段的索引列表(换句话说,它可以建立一个共享的压缩上下文),此列表随后会用作参考,对之前传输的值进行有效编码。

利用 Huffman 编码 ,可以在传输时对各个值进行压缩,而利用之前传输值的索引列表,我们可以通过传输索引值的方式对重复值进行编码,索引值可用于有效查询和重构完整的标头键值对。

客户端和服务端都有一个内置的静态表,部分内容如下:

静态表
+-------+-----------------------------+---------------+
| Index | Header Name | Header Value |
+-------+-----------------------------+---------------+
| 1 | :authority | |
| 2 | :method | GET |
| 3 | :method | POST |
| 4 | :path | / |
| 5 | :path | /index.html |
| 6 | :scheme | http |
| 7 | :scheme | https |
| 8 | :status | 200 |
| 9 | :status | 204 |
| 10 | :status | 206 |
| 11 | :status | 304 |
| 12 | :status | 400 |
| 13 | :status | 404 |
| 14 | :status | 500 |
| 15 | accept-charset | |
| 16 | accept-encoding | gzip, deflate |
| 17 | accept-language | |
...
| 58 | user-agent | |
| 59 | vary | |
| 60 | via | |
| 61 | www-authenticate | |
+-------+-----------------------------+---------------+

可以看到,部分静态表已经包含了value,比如 Index=2 的  :methodGET ,当客户端发起请求时,如果发起的是GET请求,那么只需要在Header信息中携带一个Index=2的索引即可,服务端收到通过静态表即可查出对应的请求头信息。 

在静态表中传输的 Header Block 是这种格式的:

     0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| 1 | Index (7+) |
+---+---------------------------+

从图中可以看到,只需要8-bit即可实现一个method的Header传输:

with index

对于静态表中不存在value的值,或者value的值跟想传递的值不一样时,就不能只传递简单的Index了;比如对于 :path 的头信息,如果要请求的path不在静态表里,就需要用到  Huffman 编码 了。 

假设 :path 的值为 /post/20180811-http2_in_go_1.html

     0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| 0 | 0 | 0 | 0 | Index (4+) |
+---+---+-----------------------+
| H | Value Length (7+) |
+---+---------------------------+
|
Value String (Length octets) |
+-------------------------------+

从下图中能看到,占用了25个Byte来传递 :path=/post/20180811-http2_in_go_1.html 的信息,这个数值比传输明文字符串要节省空间;当然是由于这些字符串普遍在  Huffman 编码 的压缩比较高的字典里,经过编码后会占用较小的空间,如果要传输的都是一个比较奇怪的字符,那么也有可能出现编码后占用的空间比之前还要高。

那如果我们应用要传输的就是一些奇怪的字符串,难道我们要每次传输比直接更大的值么,其实不然。除了 静态表HPACK 算法还提供了一个  动态表 ,双方针对每个connection共同维护这个表,这样对于之前未出现过的Header信息,只要传输一次,那么下次大家就都了解了。

比如下边这个 user-agent ,第一次传输时,Index是从本地 静态表 获取,传输给服务端后,会把 Header Name + Header Value 同时更新到本地的 动态表 里;这样本地和服务端都同时存在一个相同id的 动态表 了,这里大家都追加到了 Index=76动态表 ,再次传递时,只要跟静态表的结构一样即可。占用1Byte就ok。

第一次传输

第二次传输

最后总结下,用Roberto Peon(HPACK的设计者之一)的话说:

HPACK 旨在提供一个一致性的实现使信息量的损失尽可能少,使编解码快速而方便,使接收方能控制压缩文本的大小,允许代理重新建立索引(如,通过代理在前后端共享状态),以及对哈夫曼编码串的更快速比较”

2

服务端推送(Server Push)

Server Push 指的是服务端主动向客户端推送数据,相当于对客户端的一次请求,服务端可以主动返回多次结果。这个功能打破了严格的请求---响应的语义,对客户端和服务端双方通信的互动上,开启了一个崭新的可能性。但是这个推送跟 websocket 中的推送功能不是一回事, Server Push 的存在不是为了解决 websocket 推送的这种需求。

对我们的web应用来说,举个最简单的例子,有一个index.html页面:

<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="push-style.css">
</head>
<body>
<h1>hello, server push</h1>
<img src="push-image.png">
</body>
</html>
  • 在HTTP/1.x里,为了展示这个页面,客户端会先发起一次 GET /index.html 的请求,拿到返回结果进行分析后,再发起两个资源的请求,一共是三次请求, 并且有串行的请求存在。

  • 在HTTP/2里,当客户端发起 GET /index.html 的请求后,如果服务端进行了 Server Push 的支持,那么会直接把客户端需要的/index.html和另外两份文件资源一起返回,避免了串行和多次请求的发送。

大家可以看看Go官方给的这个 Server Push 的例子:

这个功能的实现,主要就依赖于上一篇文章提到的 PUSH_PROMISE Frame ,所有的推送请求,都是有  PUSH_PROMISE 来发起,服务端通过向客户端在返回正常的Response前,优先发送  PUSH_PROMISE ,来表达自己即将为客户端推送的资源,当客户端收到请求后,针对这些资源,就不会再向服务端发起请求。

+---------------+
|Pad Length? (8)|
+-+-------------+-----------------------------------------------+
|R| Promised Stream ID (31) |
+-+-----------------------------+-------------------------------+
| Header Block Fragment (*) ...
+---------------------------------------------------------------+
| Padding (*) ...
+---------------------------------------------------------------+

PUSH_PROMISE 中,包含了一个 Promised Stream ID ,这个是服务端承诺向客户端推送相关数据时使用的 Stream IDHeader Block 中包含资源链接等相关内容。 

客户端收到 PUSH_PROMISE 后,可以选择接受服务器推送的资源,如果客户端发现本地缓存已经存在,不需要服务端再推送,也可以向对应的 Stream ID 发送 RST_STREAM 帧,来阻止服务端发送Push.

下边看几张用h2c(不是ClearText的h2c,是一个HTTP/2 Command-Line Client)模拟http2请求的图来看效果,还是访问的上边Go官方给的 Server Push 的网页:

在发起 GET /serverpush 的请求后,收到了服务端发送的 PUSH_PROMISE ,承诺在2、4、6、8等stream ID的流中给发送相应的资源信息:

然后可以看到,在 Stream Id 为2、4、6、8的流上开始给客户端发送 Header 帧和 Data 帧的数据,顺序不定,同时也在向 Get /serverpush 这个 stream ID=1 的流上返回相关的页面信息。

最后,就是各个 Stream 传输自己的 Data 数据,直到数据传输完毕,打上 END_STREAM 的Flag表明流传输结束。

Stream 状态机

说完这两个HTTP/2的特性,对整体概念应该有所了解了,最后说下 Stream 状态机 ,就容易理解了。 

这个状态图从客户端和服务端两方面分别来展示的,大家可以先自己看下,图下方有发送标记的解释:

                             +--------+
send PP | | recv PP
,--------| idle |--------.
/ | | \
v +--------+ v
+----------+ | +----------+
|
| | send H / | |
,------| reserved | | recv H | reserved |------.
|
| (local) | | | (remote) | |
| +----------+ v +----------+ |
| | +--------+ | |
| | recv ES | | send ES | |
| send H | ,-------| open |-------. | recv H |
| | / | | \ | |
| v v +--------+ v v |
| +----------+ | +----------+ |
|
| half | | | half | |
| | closed | | send R / | closed | |
|
| (remote) | | recv R | (local) | |
| +----------+ | +----------+ |
|
| | | |
| | send ES / | recv ES / | |
|
| send R / v send R / | |
|
| recv R +--------+ recv R | |
|
send R / `----------->| |<-----------' send R / |
| recv R | closed | recv R |
`
----------------------->| |<----------------------'
+--------+

send: endpoint sends this frame
recv: endpoint receives this frame

H: HEADERS frame (with implied CONTINUATIONs)
PP: PUSH_PROMISE frame (with implied CONTINUATIONs)
ES: END_STREAM flag
R: RST_STREAM frame

其中, half closed 状态,就是进入 Server Push 后的状态,有一方,其实就是客户端,进入了 半开闭 状态,这时候它不能通过这个Stream再发送请求的相关数据,只能接受数据,或者选择结束链接。 half closed 状态可以由 idle 状态经过两条路径到达:

  • 服务端发送 PUSH_PROMISE 后发送 Header 帧信息,使客户端进入 半开闭

  • 客户端通过 Header 帧向服务端发送数据,并在 Header 标记 END_STREAM 的Flag,表明自己期望结束Stream,不再向服务端发送 HeaderData 的数据,这个时候服务端还没同意关闭Stream,所以服务端是可以向客户端发送数据的。

这里边比较奇怪的就是 reservedhalf closed 两个状态,看起来没有什么区别,通过一个无关紧要的 Header 帧来触发状态转换。 

其实,在Go语言里,对HTTP/2的 Stream State 的实现,就是把 reservedhalf closed 当做一个状态给合并了。

那么为什么会有这两个状态呢,其实是出于 Stream Concurrency 并发的限制,在并发限制里, reserved 状态不计入活跃状态,不进行限制。这样能达到的一个效果就是,即使 Stream 并发数达到限制以后,服务端仍然是能向客户端发送 PUSH_PROMISE 的,能够一定程度的防止 PUSH_PROMISE 不能发送而导致的客户端竞争请求。

这块我也是简单介绍下,想了解更仔细的话,可以参考这篇文章RFC7540 笔记(四)——More on Stream States。

好了,本次就说这些。下次开始介绍HTTP/2在Go语言中的一些实现和用法。

最后再提供一个HTTP/2开发相关的工具介绍,方便调试和开发:

  • HTTP/2相关tools

页面内对一些技术点都有标记链接,如果希望可以深入了解,可以点查看原文进行查看。

相关文章

  • Rfc-7541: https://tools.ietf.org/html/rfc7541

  • laike9m's blog-RFC7540 笔记(四)——More on Stream States: https://laike9m.com/blog/rfc7540-bi-ji-si-more-on-stream-states,105/

360云计算

由360云平台团队打造的技术分享公众号,内容涉及 数据库、大数据、微服务、容器、AIOps、IoT 等众多技术领域,通过夯实的技术积累和丰富的一线实战经验,为你带来最有料的技术分享

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章