HTTP/2首部压缩的OkHttp3实现(上篇)

达芬奇密码2018-08-17 12:53

当前网络环境中,同一个页面发出几十个HTTP请求已经是司空见惯的事情了。在HTTP/1.1中,请求之间完全相互独立,使得请求中冗余的首部字段不必要地浪费了大量的网络带宽,并增加了网络延时。以对某站点的一次页面访问为例:

如上图,同一个页面中对不同资源的请求,请求中的头部字段绝大部分是完全相同的。特别是 "User-Agent" 等头部字段通常还会消耗大量的带宽。

HTTP/2的首部压缩正是为了解决这个问题而设计。

首部压缩是HTTP/2中一个非常重要的特性,它大大减少了网络中HTTP请求/响应头部传输所需的带宽。HTTP/2的首部压缩,主要从两个方面实现,一是首部表示,二是请求间首部字段内容的复用。

首部表示

在HTTP中,首部字段是一个名值对,所有的首部字段组成首部字段列表。在HTTP/1.x中,首部字段都被表示为字符串,一行一行的首部字段字符串组成首部字段列表。而在HTTP/2的首部压缩HPACK算法中,则有着不同的表示方法。

HPACK算法表示的对象,主要有原始数据类型的整型值和字符串,头部字段,以及头部字段列表。

整数的表示

在HPACK中,整数用于表示 头部字段的名字的索引头部字段索引字符串长度。整数的表示可在字节内的任何位置开始。但为了处理上的优化,整数的表示总是在字节的结尾处结束。

整数由两部分表示:填满当前字节的前缀,以及在前缀不足以表示整数时的一个可选字节列表。如果整数值足够小,比如,小于2^N-1,那么把它编码进前缀即可,而不需要额外的空间。如:

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

在这个图中,前缀有5位,而要表示的数足够小,因此无需更多空间就可以表示整数了。

当前缀不足以表示整数时,前缀的所有位被置为1,再将值减去2^N-1之后用一个或多个字节编码。每个字节的最高有效位被用作连续标记:除列表的最后一个字节外,该位的值都被设为1。字节中剩余的位被用于编码减小后的值。

  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      |
+---+---------------------------+

要由字节列表解码出整数值,首先需要将列表中的字节顺序反过来。然后,移除每个字节的最高有效位。连接字节的剩余位,再将结果加2^N-1获得整数值。

前缀大小N,总是在1到8之间。从字节边界处开始编码的整数值其前缀为8位。

这种整数表示法允许编码无限大的值。

表示整数I的伪代码如下:

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

encode (I % 128 + 128) on 8 bits 一行中,加上128的意思是,最高有效位是1。如果要编码的整数值等于 (2^N - 1),则用前缀和紧跟在前缀后面的值位0的一个字节来表示。

OkHttp中,这个算法的实现在 okhttp3.internal.http2.Hpack.Writer 中:

    // http://tools.ietf.org/html/draft-ietf-httpbis-header-compression-12#section-4.1.1
    void writeInt(int value, int prefixMask, int bits) {
      // Write the raw value for a single byte value.
      if (value < prefixMask) {
        out.writeByte(bits | value);
        return;
      }

      // Write the mask to start a multibyte value.
      out.writeByte(bits | prefixMask);
      value -= prefixMask;

      // Write 7 bits at a time 'til we're done.
      while (value >= 0x80) {
        int b = value & 0x7f;
        out.writeByte(b | 0x80);
        value >>>= 7;
      }
      out.writeByte(value);
    }

这里给最高有效位置 1 的方法就不是加上128,而是与0x80执行或操作。

解码整数I的伪代码如下:

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

decode I from the next N bits 这一行等价于一个赋值语句 *I = byteValue & (2^N - 1)

OkHttp中,这个算法的实现在 okhttp3.internal.http2.Hpack.Reader

    int readInt(int firstByte, int prefixMask) throws IOException {
      int prefix = firstByte & prefixMask;
      if (prefix < prefixMask) {
        return prefix; // This was a single byte value.
      }

      // This is a multibyte value. Read 7 bits at a time.
      int result = prefixMask;
      int shift = 0;
      while (true) {
        int b = readByte();
        if ((b & 0x80) != 0) { // Equivalent to (b >= 128) since b is in [0..255].
          result += (b & 0x7f) << shift;
          shift += 7;
        } else {
          result += b << shift; // Last byte.
          break;
        }
      }
      return result;
    }

尽管HPACK的整数表示方法可以表示无限大的数,但实际的实现中并不会将整数当做无限大的整数来处理。

字符串字面量的编码

头部字段名和头部字段值可使用字符串字面量表示。字符串字面量有两种表示方式,一种是直接用UTF-8这样的字符串编码方式表示,另一种是将字符串编码用Huffman 码表示。 字符串表示的格式如下:

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

先是标记位 H + 字符串长度,然后是字符串的实际数据。各部分说明如下:

  • H: 一位的标记,指示字符串的字节是否为Huffman编码。
  • 字符串长度: 编码字符串字面量的字节数,一个整数,编码方式可以参考前面 整数的表示 的部分,一个7位前缀的整数编码。
  • 字符串数据: 字符串的实际数据。如果H是'0',则数据是字符串字面量的原始字节。如果H是'1',则数据是字符串字面量的Huffman编码。

在OkHttp3中,总是会使用直接的字符串编码,而不是Huffman编码, okhttp3.internal.http2.Hpack.Writer 中编码字符串的过程如下:

    void writeByteString(ByteString data) throws IOException {
      writeInt(data.size(), PREFIX_7_BITS, 0);
      out.write(data);
    }

OkHttp中,解码字符串在 okhttp3.internal.http2.Hpack.Reader 中实现:

    /** Reads a potentially Huffman encoded byte string. */
    ByteString readByteString() throws IOException {
      int firstByte = readByte();
      boolean huffmanDecode = (firstByte & 0x80) == 0x80; // 1NNNNNNN
      int length = readInt(firstByte, PREFIX_7_BITS);

      if (huffmanDecode) {
        return ByteString.of(Huffman.get().decode(source.readByteArray(length)));
      } else {
        return source.readByteString(length);
      }
    }

字符串编码没有使用Huffman编码时,解码过程比较简单,而使用了Huffman编码时会借助于Huffman类来解码。

Huffman编码是一种变长字节编码,对于使用频率高的字节,使用更少的位数,对于使用频率低的字节则使用更多的位数。每个字节的Huffman码是根据统计经验值分配的。为每个字节分配Huffman码的方法可以参考 哈夫曼(huffman)树和哈夫曼编码

哈夫曼树的构造

Huffman 类被设计为一个单例类。对象在创建时构造一个哈夫曼树以用于编码和解码操作。

  private static final Huffman INSTANCE = new Huffman();

  public static Huffman get() {
    return INSTANCE;
  }

  private final Node root = new Node();

  private Huffman() {
    buildTree();
  }
......

  private void buildTree() {
    for (int i = 0; i < CODE_LENGTHS.length; i++) {
      addCode(i, CODES[i], CODE_LENGTHS[i]);
    }
  }

  private void addCode(int sym, int code, byte len) {
    Node terminal = new Node(sym, len);

    Node current = root;
    while (len > 8) {
      len -= 8;
      int i = ((code >>> len) & 0xFF);
      if (current.children == null) {
        throw new IllegalStateException("invalid dictionary: prefix not unique");
      }
      if (current.children[i] == null) {
        current.children[i] = new Node();
      }
      current = current.children[i];
    }

    int shift = 8 - len;
    int start = (code << shift) & 0xFF;
    int end = 1 << shift;
    for (int i = start; i < start + end; i++) {
      current.children[i] = terminal;
    }
  }
......

  private static final class Node {

    // Null if terminal.
    private final Node[] children;

    // Terminal nodes have a symbol.
    private final int symbol;

    // Number of bits represented in the terminal node.
    private final int terminalBits;

    /** Construct an internal node. */
    Node() {
      this.children = new Node[256];
      this.symbol = 0; // Not read.
      this.terminalBits = 0; // Not read.
    }

    /**
     * Construct a terminal node.
     *
     * @param symbol symbol the node represents
     * @param bits length of Huffman code in bits
     */
    Node(int symbol, int bits) {
      this.children = null;
      this.symbol = symbol;
      int b = bits & 0x07;
      this.terminalBits = b == 0 ? 8 : b;
    }
  }

OkHttp3中的 哈夫曼树 并不是一个二叉树,它的每个节点最多都可以有256个字节点。OkHttp3用这种方式来优化Huffman编码解码的效率。用一个图来表示,将是下面这个样子的:

Huffman 编码

  void encode(byte[] data, OutputStream out) throws IOException {
    long current = 0;
    int n = 0;

    for (int i = 0; i < data.length; i++) {
      int b = data[i] & 0xFF;
      int code = CODES[b];
      int nbits = CODE_LENGTHS[b];

      current <<= nbits;
      current |= code;
      n += nbits;

      while (n >= 8) {
        n -= 8;
        out.write(((int) (current >> n)));
      }
    }

    if (n > 0) {
      current <<= (8 - n);
      current |= (0xFF >>> n);
      out.write((int) current);
    }
  }

逐个字节地编码数据。编码的最后一个字节没有字节对齐时,会在低位填充1。

Huffman 解码


  byte[] decode(byte[] buf) {
    ByteArrayOutputStream baos = new ByteArrayOutputStream();
    Node node = root;
    int current = 0;
    int nbits = 0;
    for (int i = 0; i < buf.length; i++) {
      int b = buf[i] & 0xFF;
      current = (current << 8) | b;
      nbits += 8;
      while (nbits >= 8) {
        int c = (current >>> (nbits - 8)) & 0xFF;
        node = node.children[c];
        if (node.children == null) {
          // terminal node
          baos.write(node.symbol);
          nbits -= node.terminalBits;
          node = root;
        } else {
          // non-terminal node
          nbits -= 8;
        }
      }
    }

    while (nbits > 0) {
      int c = (current << (8 - nbits)) & 0xFF;
      node = node.children[c];
      if (node.children != null || node.terminalBits > nbits) {
        break;
      }
      baos.write(node.symbol);
      nbits -= node.terminalBits;
      node = root;
    }

    return baos.toByteArray();
  }

配合Huffman树的构造过程,分几种情况来看。Huffman码自己对齐时;Huffman码没有字节对齐,最后一个字节的最低有效位包含了数据流中下一个Huffman码的最高有效位;Huffman码没有字节对齐,最后一个字节的最低有效位包含了填充的1。

有兴趣的可以参考其它文档对Huffman编码算法做更多了解。

首部字段及首部块的表示

首部字段主要有两种表示方法,分别是索引表示和字面量表示。字面量表示又分为首部字段的名字用索引表示值用字面量表示和名字及值都用字面量表示等方法。

说到用索引表示首部字段,就不能不提一下HPACK的动态表和静态表。

HPACK使用两个表将 头部字段 与 索引 关联起来。 静态表 是预定义的,它包含了常见的头部字段(其中的大多数值为空)。 动态表 是动态的,它可被编码器用于编码重复的头部字段。

静态表由一个预定义的头部字段静态列表组成。它的条目在 HPACK规范的 附录 A 中定义。

动态表由以先进先出顺序维护的 头部字段列表 组成。动态表中第一个且最新的条目索引值最低,动态表最旧的条目索引值最高。

动态表最初是空的。条目随着每个头部块的解压而添加。

静态表和动态表被组合为统一的索引地址空间。

在 (1 ~ 静态表的长度(包含)) 之间的索引值指向静态表中的元素。

大于静态表长度的索引值指向动态表中的元素。通过将头部字段的索引减去静态表的长度来查找指向动态表的索引。

对于静态表大小为 s,动态表大小为 k 的情况,下图展示了完整的有效索引地址空间。

        <----------  Index Address Space ---------->
        <-- Static  Table -->  <-- Dynamic Table -->
        +---+-----------+---+  +---+-----------+---+
        | 1 |    ...    | s |  |s+1|    ...    |s+k|
        +---+-----------+---+  +---+-----------+---+
                               ^                   |
                               |                   V
                        Insertion Point      Dropping Point

用索引表示头部字段

当一个头部字段的名-值已经包含在了静态表或动态表中时,就可以用一个指向静态表或动态表的索引来表示它了。表示方法如下:

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

头部字段表示的最高有效位置1,然后用前面看到的表示整数的方法表示索引,即索引是一个7位前缀编码的整数。

用字面量表示头部字段

在这种表示法中,头部字段的值是用字面量表示的,但头部字段的名字则不一定。根据名字的表示方法的差异,以及是否将头部字段加进动态表等,而分为多种情况。

增量索引的字面量表示

以这种方法表示的头部字段需要被 加进动态表中。在这种表示方法下,头部字段的值用索引表示时,头部字段的表示如下:

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

头部字段的名字和值都用字面量表示时,表示如下:

  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)  |
+-------------------------------+

增量索引的字面量头部字段表示以'01' 的2位模式开始。

如果头部字段名与静态表或动态表中存储的条目的头部字段名匹配,则头部字段名称可用那个条目的索引表示。在这种情况下,条目的索引以一个具有6位前缀的整数 表示。这个值总是非0。否则,头部字段名由一个字符串字面量 表示,使用0值代替6位索引,其后是头部字段名。

两种形式的 头部字段名表示 之后是字符串字面量表示的头部字段值。

无索引的字面量头部字段

这种表示方法不改变动态表。头部字段名用索引表示时的头部字段表示如下:

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

头部字段名不用索引表示时的头部字段表示如下:

  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)  |
+-------------------------------+

无索引的字面量头部字段表示以'0000' 的4位模式开始,其它方面与 增量索引的字面量表示 类似。

从不索引的字面量头部字段

这种表示方法与 无索引的字面量头部字段 类似,但它主要影响网络中的中间节点。头部字段名用索引表示时的头部字段如:

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

头部字段名不用索引表示时的头部字段如:

  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)  |
+-------------------------------+

首部列表的表示

各个首部字段表示合并起来形成首部列表。在 okhttp3.internal.framed.Hpack.Writer 的writeHeaders() 中完成编码首部块的动作:

    /** This does not use "never indexed" semantics for sensitive headers. */
    // http://tools.ietf.org/html/draft-ietf-httpbis-header-compression-12#section-6.2.3
    void writeHeaders(List<Header> headerBlock) throws IOException {
      if (emitDynamicTableSizeUpdate) {
        if (smallestHeaderTableSizeSetting < maxDynamicTableByteCount) {
          // Multiple dynamic table size updates!
          writeInt(smallestHeaderTableSizeSetting, PREFIX_5_BITS, 0x20);
        }
        emitDynamicTableSizeUpdate = false;
        smallestHeaderTableSizeSetting = Integer.MAX_VALUE;
        writeInt(maxDynamicTableByteCount, PREFIX_5_BITS, 0x20);
      }
      // TODO: implement index tracking
      for (int i = 0, size = headerBlock.size(); i < size; i++) {
        Header header = headerBlock.get(i);
        ByteString name = header.name.toAsciiLowercase();
        ByteString value = header.value;
        Integer staticIndex = NAME_TO_FIRST_INDEX.get(name);
        if (staticIndex != null) {
          // Literal Header Field without Indexing - Indexed Name.
          writeInt(staticIndex + 1, PREFIX_4_BITS, 0);
          writeByteString(value);
        } else {
          int dynamicIndex = Util.indexOf(dynamicTable, header);
          if (dynamicIndex != -1) {
            // Indexed Header.
            writeInt(dynamicIndex - nextHeaderIndex + STATIC_HEADER_TABLE.length, PREFIX_7_BITS,
                0x80);
          } else {
            // Literal Header Field with Incremental Indexing - New Name
            out.writeByte(0x40);
            writeByteString(name);
            writeByteString(value);
            insertIntoDynamicTable(header);
          }
        }
      }
    }

HPACK的规范描述了多种头部字段的表示方法,但并没有指明各个表示方法的适用场景。


在OkHttp3中,实现了3种表示头部字段的表示方法:

  1. 头部字段名在静态表中,头部字段名用指向静态表的索引表示,值用字面量表示。头部字段无需加入动态表。
  2. 头部字段的 名-值 对在动态表中,用指向动态表的索引表示头部字段。
  3. 其它情况,用字面量表示头部字段名和值,头部字段需要加入动态表。

如果头部字段的 名-值 对在静态表中,OkHttp3也不会用索引表示。


相关阅读:HTTP/2首部压缩的OkHttp3实现(上篇)

HTTP/2首部压缩的OkHttp3实现(下篇)

网易云新用户大礼包:https://www.163yun.com/gift

本文来自网易实践者社区,经作者韩鹏飞授权发布。