Http/2

HTTP2 协议起源于SPDY,目的是解决http1.x 如下的问题:

  • plantext 文本协议的size开销
  • 不支持复用tcp通道
  • 请求通道上限
  • 队首阻塞 http1.1 Keep alive 来保证复用一个tcp连接,但严格要求响应的顺序,响应是一个队列,如果遇到队首阻塞,则整个tcp通道就无法处理别的请求了。

http/2如果解决这些问题的?

  • 通道复用
  • 二进制帧层
  • 服务端push
  • 设置请求优先级
  • 压缩(HPACK)

帧布局

+-----------------------------------------------+  
|                Length (24)                    |  
+---------------+---------------+---------------+  
|  Type (8)     |  Flags (8)    |  
+-+-------------+---------------+-------------------------------+  
|R|                Stream Identifier (31)                       |  
+=+=============================================================+  
|                  Frame Payload (0...)                       ...  
+---------------------------------------------------------------+  

帧头为固定的9个字节((24+8+8+1+31)/8=9)呈现,变化的为帧的负载(payload),负载内容是由帧类型(Type)定义。

  • 帧长度Length:无符号的自然数,24个比特表示,仅表示帧负载所占用字节数,不包括帧头所占用的9个字节。默认大小区间为为0~16,384(2^14),一旦超过默认最大值2^14(16384),发送方将不再允许发送,除非接收到接收方定义的SETTINGS_MAX_FRAME_SIZE(一般此值区间为2^14 ~ 2^24)值的通知。
  • 帧类型Type:8个比特表示,定义了帧负载的具体格式和帧的语义,HTTP/2规范定义了10个帧类型,这里不包括实验类型帧和扩展类型帧
  • 帧的标志位Flags:8个比特表示,服务于具体帧类型,默认值为0x0。有一个小技巧需要注意,一般来讲,8个比特可以容纳8个不同的标志,比如,PADDED值为0x8,二进制表示为00001000;END_HEADERS值为0x4,二进制表示为00000100;END_STREAM值为0X1,二进制为00000001。可以同时在一个字节中传达三种标志位,二进制表示为00001101,即0x13。因此,后面的帧结构中,标志位一般会使用8个比特表示,若某位不确定,使用问号?替代,表示此处可能会被设置标志位
  • 帧保留比特为R:在HTTP/2语境下为保留的比特位,固定值为0X0
  • 流标识符Stream Identifier:无符号的31比特表示无符号自然数。0x0值表示为帧仅作用于连接,不隶属于单独的流。

(参考:http://www.blogjava.net/yongboy/archive/2015/03/20/423655.html)

帧类型:

HEADERS:帧仅包含 HTTP 标头信息。
DATA:帧包含消息的所有或部分有效负载。 (flag:END_STREAM|PADDED)
PRIORITY:指定分配给流的重要性。
RST_STREAM:错误通知:一个推送承诺遭到拒绝。终止流。
SETTINGS:指定连接配置。
PUSH_PROMISE:通知一个将资源推送到客户端的意图。
PING:检测信号和往返时间。
GOAWAY:停止为当前连接生成流的停止通知。
WINDOW_UPDATE:用于管理流的流控制。
CONTINUATION:用于延续某个标头碎片序列。

浏览器在一个通道上跟服务器交换通过二进制协议交换数据,浏览器获取到的无序的帧数据集合,根据流标识,进行合并。

数据流具有如下的状态:

  • idle
  • reserved(Local)
  • reserved(remote)
  • open
  • half_closed(local)
  • half_closed(remote)
  • closed

“新流的标识符的第一次使用隐式地关闭了所有由该对端以低值流标识符发起的处于“idle”状态的流。例如,如果客户端在流7上发送 HEADERS 帧而没有在流5上发送帧,则当流7的第一帧被发送或接收时,流5转换到“closed”状态。”

HPACK 算法

http1.x 最请求body进行gzip压缩,但是对请求头并不会压缩,现代的web请求繁多而复杂,意味着会浪费很多的流量用来传递请求头。
为此,为http/2而专门研究了一种新型压缩技术HPACK,HPACK的可以压缩头,HPACK维护了一张动态表和静态表,静态表保存固定不变的header映射,而动态表保存在一个client和server的tcp上下文中(一个client对一个server 在http/2中,一般只会创建一个tcp链接,除非客户端显示的声明)。

HPACK将static table 和 dynamic table映射到同一张index address space里面。前面0~(STATIC_LEN-1)为静态表空间,后面 STATIC_LEN~DYNAMIC_LEN为动态表空间。

如何压缩的?

首先,客户端会从索引表里面获取到已存在具体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             |               |
      | 18    | accept-ranges               |               |
      | 19    | accept                      |               |
      | 20    | access-control-allow-origin |               |
      | 21    | age                         |               |
      | 22    | allow                       |               |
      | 23    | authorization               |               |
      | 24    | cache-control               |               |
      | 25    | content-disposition         |               |
      | 26    | content-encoding            |               |
      | 27    | content-language            |               |
      | 28    | content-length              |               |
      | 29    | content-location            |               |
      | 30    | content-range               |               |

(表1,https://tools.ietf.org/html/rfc7541#appendix-B )

无符号整数编码

用途

name索引/header索引/字符串的位长度都需要一个整数来表达,整数一定在一个字节的结尾结束。一个字节的二进制布局如下:

     0   1   2   3   4   5   6   7
   +---+---+---+---+---+---+---+---+
   | ? | ? | ? |       Value       |
   +---+---+---+-------------------+

注意 0 1 2 可以被用作特殊的用途,所以value保存了具体的无符号整数的值。在上面的情况下,有5个位用来保存整数。意味着最大能保存2^ 5-1(31),超过这个值,就没有办法保存了。

那怎么办呢? 此时就需要扩展,用第二个字节的7位来保存剩余的数字。(首字节特殊用途)

比如要保存数字32,则

   0   1   2   3   4   5   6   7
   +---+---+---+---+---+---+---+---+
   | ? | ? | ? | 1   1   1   1   1 |
   +---+---+---+-------------------+
   | 0 | 0  0  0  0  0  0  1
   +---+---------------------------+

注意第二个字节的0,代表是结束的字节。解码器碰到这个标志,就不能在继续搜索了。
如果第二个字节仍旧无法表达这个数,则需要调整为:

   0   1   2   3   4   5   6   7
   +---+---+---+---+---+---+---+---+
   | ? | ? | ? | 1   1   1   1   1 |
   +---+---+---+-------------------+
   | 1 |    Value-(2^N-1) LSB      |
   +---+---------------------------+
                  ...
   +---+---------------------------+
   | 0 |    Value-(2^N-1) MSB      |
   +---+---------------------------+

伪代码如下:

编码:

  if I < 2^N - 1, encode I on N bits
   else
       encode (2^N - 1) on N bits
       I = I - (2^N - 1)
       while I >= 128
            encode (I % 128 + 128) on 8 bits
            I = I / 128
       encode I on 8 bits

解码:

decode I from the next N bits
   if I < 2^N - 1, return I
   else
       M = 0
       repeat
           B = next octet
           I = I + (B & 127) * 2^M
           M = M + 7
       while B & 128 == 128
       return I

字符串编码

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

从一个二进制序列中解码一个字符串,有几个非常重要的点:

  1. 从那一位开始
  2. 到那一位结束

因此,为了表达这个信息,在二进制的布局中,需要讲长度编码进去,注意,长度是个无符号整数,按照上面的编码规则进行编码,意味着长度一定是在一个字节的结束。

所以,字符串的编码会在新的字节开始,如上布局。在HPACK中,为了将常见的字符串进行压缩,经常会使用hufuman编码,因此表达字符串编码本身的字节序列的第一个位,不参与表达字符串本身。而仅仅表达后面的字符串编码是原始字符串还是hufuman编码后的字符串。

H:表示是否是 huffman 编码,1:是 0:不是。

length代表的是二进制位的长度,并不是字符串的长度。如果是hufuman编码的字符串。那么,String data的二进制布局是基于hufuman编码的。比如”h” 这个字符串的ascii码是104,二进制位布局为:1101000,而hufuman的布局为 100111。对于常见的web传递的header相关字符,使用hufuman编码压缩效果更好。 仅仅 详细的定义参见这里 https://httpwg.org/specs/rfc7541.html#huffman.code

二进制格式的表达

在HPACK中,解码器通过搜索二进制位,执行不同解析逻辑。

  • 已被索引的header[1]
  0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| 1 |        Index (7+)         |
+---+---------------------------+
  • name在索引,value不再索引且允许索引 [0 1]
  0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| 0 | 1 |      Index (6+)       |
+---+---+-----------------------+
| H |     Value Length (7+)     |
+---+---------------------------+
| Value String (Length octets)  |
+-------------------------------+
  • name,value都没被索引且允许索引[0100 0000]
  0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| 0 | 1 |           0           |
+---+---+-----------------------+
| H |     Name Length (7+)      |
+---+---------------------------+
|  Name String (Length octets)  |
+---+---------------------------+
| H |     Value Length (7+)     |
+---+---------------------------+
| Value String (Length octets)  |
+-------------------------------+
  • name被索引,value未索引且不保存[0000]
  0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| 0 | 0 | 0 | 0 |  Index (4+)   |
+---+---+-----------------------+
| H |     Value Length (7+)     |
+---+---------------------------+
| Value String (Length octets)  |
+-------------------------------+
  • name,value都未被索引且不保存[0000 0000]
    0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| 0 | 0 | 0 | 0 |       0       |
+---+---+-----------------------+
| H |     Name Length (7+)      |
+---+---------------------------+
|  Name String (Length octets)  |
+---+---------------------------+
| H |     Value Length (7+)     |
+---+---------------------------+
| Value String (Length octets)  |
+-------------------------------+
  • name在索引,value不再且绝对不能被保存索引[0001]
0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| 0 | 0 | 0 | 1 |  Index (4+)   |
+---+---+-----------------------+
| H |     Value Length (7+)     |
+---+---------------------------+
| Value String (Length octets)  |
+-------------------------------+
  • name,value都不在且不允许被保存索引[0001 0000]
0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| 0 | 0 | 0 | 1 |       0       |
+---+---+-----------------------+
| H |     Name Length (7+)      |
+---+---------------------------+
|  Name String (Length octets)  |
+---+---------------------------+
| H |     Value Length (7+)     |
+---+---------------------------+
| Value String (Length octets)  |
+-------------------------------+
  • 修改动态表的大小[001 ] 意味着动态表的上限是(2^5-1)
  0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| 0 | 0 | 1 |   Max size (5+)   |
+---+---------------------------+

客户端进行二进制编码header的时候,第一个 字节 (注意不是位)是用来特殊用途的,用以表示客户端对于服务端对于索引的指示信息,服务端通过解析第一个字节获取到必要配置信息。

比如是否服务端需要更新该header到动态表。 二进制编码在不同的前缀标记下有不同的含义。比如 一个请求的header二进制编码以001,那么该请求仅仅是用来修改服务端接受的最大动态表的值。

如果是01前缀,则代表剩余的6位了name的索引,如01 10 0001 ,剩余的6位就是10 0001 其中,第一位是1,代表是hufuman编码,代表0 0001 是一个hufuman编码,需要从索引表查询。在这个例子中,代表 :authority。(见表1)。

LSB & MSB

The Huffman code for the symbol represented as a base-2 integer, aligned on the most significant bit (MSB)

The Huffman code for the symbol, represented as a hexadecimal integer, aligned on the least significant bit (LSB).

Demo

   :method: GET
   :scheme: http
   :path: /
   :authority: www.example.com
   Hex dump of encoded data:
   8286 8441 0f77 7777 2e65 7861 6d70 6c65 | ...A.www.example
   2e63 6f6d                               | .com

   Decoding process:
   82                                      | == Indexed - Add ==
                                           |   idx = 2
                                           | -> :method: GET
   86                                      | == Indexed - Add ==
                                           |   idx = 6
                                           | -> :scheme: http
   84                                      | == Indexed - Add ==
                                           |   idx = 4
                                           | -> :path: /
   41                                      | == Literal indexed ==
                                           |   Indexed name (idx = 1)
                                           |     :authority
   0f                                      |   Literal value (len = 15)
   7777 772e 6578 616d 706c 652e 636f 6d   | www.example.com
                                           | -> :authority:
                                           |   www.example.com

注意这里的41,是这样布局的0100 0001

代表,name被索引,但value未被索引,且允许索引。(参见上表)。因此是0 1,:authority0001,因此补全为 0100 0001 根据0100 0001 的规则,接下来就需要跟着一个value的长度,这里是0f,接着是字符串的十六进制编码 7777 772e 6578 616d 706c 652e 636f 6d

对一个字符串进行编码的步骤 如 :my_header hello

步骤1:

这个字符串并非是静态表里面的,所以前缀应该是 0100 0000(代表都没被索引且允许索引)。

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

二进制布局如下:

  0   1   2   3   4   5   6   7
+---+---+-----------------------+
| 0| 1 | 0   0  0  0  0  0
+---+---------------------------+

需要将:my_header编码,此处不使用hufuman编码,因此name的二进制布局如下:


  0   1   2   3   4   5   6   7
+---+---+-----------------------+
| 0|  Value-(2^N-1) MSB   
+---+---------------------------+

在我们的例子中,:my_header的N值为:

3a 6d 79 5f 68 65 61 64 65 72

综上,

第一个字节为,0100 0000 ,代表name和value都未被索引,但可以保存索引

第二个字节为,0 000 1010 代表name :my_header字符串的长度为10

接下来是 3a6d 795f 6865 6164 6572:my_header 这个字符串本身的编码

然后是 0 000 0101 代表接下来的做value(hello)的长度为5

最后是 68 65 6c 6c 6f,是hello 这个字符串本身的编码

https://httpwg.org/specs/rfc7541.html 点击这里可以了解到详细的技术细节。

升级

http1.x->http2 平滑升级

目前大多数app都支持http1.x,要完成http1.x到http/2,需要进行客户端发起升级协议,来询问和确认服务端对http/2的支持。

具体而言,是客户端发起一个OPTIONS请求,携带代码如下:

GET /default.htm HTTP/1.1
Host: server.example.com
Connection: Upgrade, HTTP2-Settings
Upgrade: h2c
HTTP2-Settings: <base64url encoding of HTTP/2 SETTINGS payload>

注意有个 upgrade:h2c 的header. 服务器在接收到这个header之后,响应101(TLS_ALPN)来接受升级。(不支持http/2的,可以返回一个不包含upgrade的响应)

服务端首次发送的帧和客户端首次发送的帧,必须包含设置帧。意味着,客户端在接收到101的响应后,要发送一个包含 设置帧 的请求。

h2c协议标识符一定不能被客户端发送或被服务器选择,服务器必须忽略。

客户端和服务端必须发送Connection Preface作为最终确认,具体表现为,客户端需要在接收到101之后,立即发送一个客户端 以”PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n” 开始,并且紧跟着配置帧。

工具(js)

在浏览器快速获取ascii码

str=":my_header";
len=str.length;
arr=[];
for(var i=0;i<len;i++){
arr.push(str.charCodeAt(i).toString(16));
}
console.log(arr.join(" "));

参考

https://tools.ietf.org/html/rfc7541#appendix-B

https://developers.google.com/web/fundamentals/performance/http2/?hl=zh-cn

https://github.com/ye11ow/http2-explained-chinese/blob/master/part6.md

https://github.com/ye11ow/http2-explained-chinese/blob/master/part13.md

https://http2-explained.haxx.se/content/zh/part1.html

https://skyao.io/learning-http2/stream/identifier.html

http://joescott.coding.me/blog/2017/03/10/http2-frame-stream/