HTTP/2 头部压缩技术介绍

总结

在进行 HTTP/2
网站性能优化时很重要一点是「使用尽可能少的连接数」,本文提到的头部压缩是其中一个很重要的原因:同一个连接上产生的请求和响应越多,动态字典积累得越全,头部压缩效果也就越好。所以,针对
HTTP/2 网站,最佳实践是不要合并资源,不要散列域名。

默认情况下,浏览器会针对这些情况使用同一个连接:

  • 同一域名下的资源;
  • 不同域名下的资源,但是满足两个条件:1)解析到同一个
    IP;2)使用同一个证书;

上面第一点容易理解,第二点则很容易被忽略。实际上 Google
已经这么做了,Google 一系列网站都共用了同一个证书,可以这样验证:

JavaScript

$ openssl s_client -connect google.com:443 |openssl x509 -noout -text |
grep DNS depth=2 C = US, O = GeoTrust Inc., CN = GeoTrust Global CA
verify error:num=20:unable to get local issuer certificate verify
return:0 DNS:*.google.com, DNS:*.android.com,
DNS:*.appengine.google.com, DNS:*.cloud.google.com,
DNS:*.google-analytics.com, DNS:*.google.ca, DNS:*.google.cl,
DNS:*.google.co.in, DNS:*.google.co.jp, DNS:*.google.co.uk,
DNS:*.google.com.ar, DNS:*.google.com.au, DNS:*.google.com.br,
DNS:*.google.com.co, DNS:*.google.com.mx, DNS:*.google.com.tr,
DNS:*.google.com.vn, DNS:*.google.de, DNS:*.google.es,
DNS:*.google.fr, DNS:*.google.hu, DNS:*.google.it, DNS:*.google.nl,
DNS:*.google.pl, DNS:*.google.pt, DNS:*.googleadapis.com,
DNS:*.googleapis.cn, DNS:*.googlecommerce.com, DNS:*.googlevideo.com,
DNS:*.gstatic.cn, DNS:*.gstatic.com, DNS:*.gvt1.com, DNS:*.gvt2.com,
DNS:*.metric.gstatic.com, DNS:*.urchin.com, DNS:*.url.google.com,
DNS:*.youtube-nocookie.com, DNS:*.youtube.com,
DNS:*.youtubeeducation.com, DNS:*.ytimg.com, DNS:android.com,
DNS:g.co, DNS:goo.gl, DNS:google-analytics.com, DNS:google.com,
DNS:googlecommerce.com, DNS:urchin.com, DNS:youtu.be, DNS:youtube.com,
DNS:youtubeeducation.com

1
2
3
4
5
6
7
$ openssl s_client -connect google.com:443 |openssl x509 -noout -text | grep DNS
 
depth=2 C = US, O = GeoTrust Inc., CN = GeoTrust Global CA
verify error:num=20:unable to get local issuer certificate
verify return:0
                DNS:*.google.com, DNS:*.android.com, DNS:*.appengine.google.com, DNS:*.cloud.google.com, DNS:*.google-analytics.com, DNS:*.google.ca, DNS:*.google.cl, DNS:*.google.co.in, DNS:*.google.co.jp, DNS:*.google.co.uk, DNS:*.google.com.ar, DNS:*.google.com.au, DNS:*.google.com.br, DNS:*.google.com.co, DNS:*.google.com.mx, DNS:*.google.com.tr, DNS:*.google.com.vn, DNS:*.google.de, DNS:*.google.es, DNS:*.google.fr, DNS:*.google.hu, DNS:*.google.it, DNS:*.google.nl, DNS:*.google.pl, DNS:*.google.pt, DNS:*.googleadapis.com, DNS:*.googleapis.cn, DNS:*.googlecommerce.com, DNS:*.googlevideo.com, DNS:*.gstatic.cn, DNS:*.gstatic.com, DNS:*.gvt1.com, DNS:*.gvt2.com, DNS:*.metric.gstatic.com, DNS:*.urchin.com, DNS:*.url.google.com, DNS:*.youtube-nocookie.com, DNS:*.youtube.com, DNS:*.youtubeeducation.com, DNS:*.ytimg.com, DNS:android.com, DNS:g.co, DNS:goo.gl, DNS:google-analytics.com, DNS:google.com, DNS:googlecommerce.com, DNS:urchin.com, DNS:youtu.be, DNS:youtube.com, DNS:youtubeeducation.com
 

使用多域名加上相同的 IP 和证书部署 Web 服务有特殊的意义:让支持 HTTP/2
的终端只建立一个连接,用上 HTTP/2 协议带来的各种好处;而只支持 HTTP/1.1
的终端则会建立多个连接,达到同时更多并发请求的目的。这在 HTTP/2
完全普及前也是一个不错的选择。

本文就写到这里,希望能给对 HTTP/2
感兴趣的同学带来帮助,也欢迎大家继续关注本博客的「HTTP/2
专题」。

打赏支持我写出更多好文章,谢谢!

打赏作者

总结

在进行 HTTP/2
网站性能优化时很重要一点是「使用尽可能少的连接数」,本文提到的头部压缩是其中一个很重要的原因:同一个连接上产生的请求和响应越多,动态字典积累得越全,头部压缩效果也就越好。所以,针对
HTTP/2 网站,最佳实践是不要合并资源,不要散列域名。

默认情况下,浏览器会针对这些情况使用同一个连接:

  • 同一域名下的资源;
  • 不同域名下的资源,但是满足两个条件:1)解析到同一个
    IP;2)使用同一个证书;

上面第一点容易理解,第二点则很容易被忽略。实际上 Google
已经这么做了,Google 一系列网站都共用了同一个证书,可以这样验证:

$ openssl s_client -connect google.com:443 |openssl x509 -noout -text |
grep DNS depth=2 C = US, O = GeoTrust Inc., CN = GeoTrust Global CA
verify error:num=20:unable to get local issuer certificate verify
return:0 DNS:*.google.com, DNS:*.android.com,
DNS:*.appengine.google.com, DNS:*.cloud.google.com,
DNS:*.google-analytics.com, DNS:*.google.ca, DNS:*.google.cl,
DNS:*.google.co.in, DNS:*.google.co.jp, DNS:*.google.co.uk,
DNS:*.google.com.ar, DNS:*.google.com.au, DNS:*.google.com.br,
DNS:*.google.com.co, DNS:*.google.com.mx, DNS:*.google.com.tr,
DNS:*.google.com.vn, DNS:*.google.de, DNS:*HTTP/2 头部压缩技术介绍。.google.es,
DNS:*.google.fr, DNS:*.google.hu, DNS:*.google.it, DNS:*.google.nl,
DNS:*.google.pl, DNS:*.google.pt, DNS:*.googleadapis.com,
DNS:*.googleapis.cn, DNS:*.googlecommerce.com, DNS:*.googlevideo.com,
DNS:*.gstatic.cn, DNS:*.gstatic.com, DNS:*.gvt1.com, DNS:*.gvt2.com,
DNS:*.metric.gstatic.com, DNS:*.urchin.com, DNS:*.url.google.com,
DNS:*.youtube-nocookie.com, DNS:*.youtube.com,
DNS:*.youtubeeducation.com, DNS:*.ytimg.com, DNS:android.com,
DNS:g.co, DNS:goo.gl, DNS:google-analytics.com, DNS:google.com,
DNS:googlecommerce.com, DNS:urchin.com, DNS:youtu.be, DNS:youtube.com,
DNS:youtubeeducation.com

1
2
3
4
5
6
$ openssl s_client -connect google.com:443 |openssl x509 -noout -text | grep DNS
 
depth=2 C = US, O = GeoTrust Inc., CN = GeoTrust Global CA
verify error:num=20:unable to get local issuer certificate
verify return:0
                DNS:*.google.com, DNS:*.android.com, DNS:*.appengine.google.com, DNS:*.cloud.google.com, DNS:*.google-analytics.com, DNS:*.google.ca, DNS:*.google.cl, DNS:*.google.co.in, DNS:*.google.co.jp, DNS:*.google.co.uk, DNS:*.google.com.ar, DNS:*.google.com.au, DNS:*.google.com.br, DNS:*.google.com.co, DNS:*.google.com.mx, DNS:*.google.com.tr, DNS:*.google.com.vn, DNS:*.google.de, DNS:*.google.es, DNS:*.google.fr, DNS:*.google.hu, DNS:*.google.it, DNS:*.google.nl, DNS:*.google.pl, DNS:*.google.pt, DNS:*.googleadapis.com, DNS:*.googleapis.cn, DNS:*.googlecommerce.com, DNS:*.googlevideo.com, DNS:*.gstatic.cn, DNS:*.gstatic.com, DNS:*.gvt1.com, DNS:*.gvt2.com, DNS:*.metric.gstatic.com, DNS:*.urchin.com, DNS:*.url.google.com, DNS:*.youtube-nocookie.com, DNS:*.youtube.com, DNS:*.youtubeeducation.com, DNS:*.ytimg.com, DNS:android.com, DNS:g.co, DNS:goo.gl, DNS:google-analytics.com, DNS:google.com, DNS:googlecommerce.com, DNS:urchin.com, DNS:youtu.be, DNS:youtube.com, DNS:youtubeeducation.com

使用多域名加上相同的 IP 和证书部署 Web 服务有特殊的意义:让支持 HTTP/2
的终端只建立一个连接,用上 HTTP/2 协议带来的各种好处;而只支持 HTTP/1.1
的终端则会建立多个连接,达到同时更多并发请求的目的。这在 HTTP/2
完全普及前也是一个不错的选择。

1 赞 收藏
评论

永利酒店赌场 1

后面我们将通过几个方面来说说HTTP 2.0 和 HTTP1.1
区别,并且和你解释下其中的原理。

首部表示

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

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

三、元素分布

永利酒店赌场 2

s)         资源类型统计:css,html,image,js,other(请求数,大小)

t)          资源域名统计:请求域名个数及次数

 

四、视图分析

永利酒店赌场 3

将整个网页生成的过程以胶片视图、视频、截屏的形式展现出来,并提供详细的状态栏加载日志。  

 

技术原理

下面这张截图,取自 Google 的性能专家 Ilya Grigorik 在 Velocity 2015 • SC
会议中分享的「HTTP/2 is here, let’s
optimize!」,非常直观地描述了
HTTP/2 中头部压缩的原理:

永利酒店赌场 4

我再用通俗的语言解释下,头部压缩需要在支持 HTTP/2 的浏览器和服务端之间:

  • 维护一份相同的静态字典(Static
    Table),包含常见的头部名称,以及特别常见的头部名称与值的组合;
  • 维护一份相同的动态字典(Dynamic Table),可以动态地添加内容;
  • 支持基于静态哈夫曼码表的哈夫曼编码(Huffman Coding);

静态字典的作用有两个:1)对于完全匹配的头部键值对,例如
:method: GET,可以直接使用一个字符表示;2)对于头部名称可以匹配的键值对,例如
cookie: xxxxxxx,可以将名称使用一个字符表示。HTTP/2
中的静态字典如下(以下只截取了部分,完整表格在这里):

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
32 cookie
60 via
61 www-authenticate

同时,浏览器可以告知服务端,将 cookie: xxxxxxx
添加到动态字典中,这样后续整个键值对就可以使用一个字符表示了。类似的,服务端也可以更新对方的动态字典。需要注意的是,动态字典上下文有关,需要为每个
HTTP/2 连接维护不同的字典。

使用字典可以极大地提升压缩效果,其中静态字典在首次请求中就可以使用。对于静态、动态字典中不存在的内容,还可以使用哈夫曼编码来减小体积。HTTP/2
使用了一份静态哈夫曼码表(详见),也需要内置在客户端和服务端之中。

这里顺便说一下,HTTP/1 的状态行信息(Method、Path、Status 等),在
HTTP/2
中被拆成键值对放入头部(冒号开头的那些),同样可以享受到字典和哈夫曼压缩。另外,HTTP/2
中所有头部名称必须小写。

技术原理

下面这张截图,取自 Google 的性能专家 Ilya Grigorik 在 Velocity 2015 • SC
会议中分享的「HTTP/2 is here, let’s
optimize!」,非常直观地描述了
HTTP/2 中头部压缩的原理:

永利酒店赌场 5

我再用通俗的语言解释下,头部压缩需要在支持 HTTP/2 的浏览器和服务端之间:

  • 维护一份相同的静态字典(Static
    Table),包含常见的头部名称,以及特别常见的头部名称与值的组合;
  • 维护一份相同的动态字典(Dynamic Table),可以动态的添加内容;
  • 支持基于静态哈夫曼码表的哈夫曼编码(Huffman Coding);

静态字典的作用有两个:1)对于完全匹配的头部键值对,例如 :
method :GET
,可以直接使用一个字符表示;2)对于头部名称可以匹配的键值对,例如 cookie :xxxxxxx,可以将名称使用一个字符表示。HTTP/2
中的静态字典如下(以下只截取了部分,完整表格在这里):

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
32 cookie
60 via
61 www-authenticate

同时,浏览器可以告知服务端,将 cookie :xxxxxxx 添加到动态字典中,这样后续整个键值对就可以使用一个字符表示了。类似的,服务端也可以更新对方的动态字典。需要注意的是,动态字典上下文有关,需要为每个
HTTP/2 连接维护不同的字典。

使用字典可以极大地提升压缩效果,其中静态字典在首次请求中就可以使用。对于静态、动态字典中不存在的内容,还可以使用哈夫曼编码来减小体积。HTTP/2
使用了一份静态哈夫曼码表(详见),也需要内置在客户端和服务端之中。

这里顺便说一下,HTTP/1 的状态行信息(Method、Path、Status 等),在
HTTP/2
中被拆成键值对放入头部(冒号开头的那些),同样可以享受到字典和哈夫曼压缩。另外,HTTP/2
中所有头部名称必须小写。

永利酒店赌场 6img

请求间首部字段内容的复用

HPACK中,最重要的优化就是消除请求间冗余的首部字段。在实现上,主要有两个方面,一是前面看到的首部字段的索引表示,另一方面则是动态表的维护。

HTTP/2中数据发送方向和数据接收方向各有一个动态表。通信的双方,一端发送方向的动态表需要与另一端接收方向的动态表保持一致,反之亦然。

HTTP/2的连接复用及请求并发执行指的是逻辑上的并发。由于底层传输还是用的TCP协议,因而,发送方发送数据的顺序,与接收方接收数据的顺序是一致的。

数据发送方在发送一个请求的首部数据时会顺便维护自己的动态表,接收方在收到首部数据时,也需要立马维护自己接收方向的动态表,然后将解码之后的首部字段列表dispatch出去。

如果通信双方同时在进行2个HTTP请求,分别称为Req1和Req2,假设在发送方Req1的头部字段列表先发送,Req2的头部字段后发送。接收方必然先收到Req1的头部字段列表,然后是Req2的。如果接收方在收到Req1的头部字段列表后,没有立即解码,而是等Req2的首部字段列表接收并处理完成之后,再来处理Req1的,则两端的动态表必然是不一致的。

这里来看一下OkHttp3中的动态表维护。

发送方向的动态表,在 okhttp3.internal.framed.Hpack.Writer
中维护。在HTTP/2中,动态表的最大大小在连接建立的初期会进行协商,后面在数据收发过程中也会进行更新。

在编码头部字段列表的 writeHeaders(List<Header> headerBlock)
中,会在需要的时候,将头部字段插入动态表,具体来说,就是在头部字段的名字不在静态表中,同时
名-值对不在动态表中的情况。

将头部字段插入动态表的过程如下:

    private void clearDynamicTable() {
      Arrays.fill(dynamicTable, null);
      nextHeaderIndex = dynamicTable.length - 1;
      headerCount = 0;
      dynamicTableByteCount = 0;
    }

    /** Returns the count of entries evicted. */
    private int evictToRecoverBytes(int bytesToRecover) {
      int entriesToEvict = 0;
      if (bytesToRecover > 0) {
        // determine how many headers need to be evicted.
        for (int j = dynamicTable.length - 1; j >= nextHeaderIndex && bytesToRecover > 0; j--) {
          bytesToRecover -= dynamicTable[j].hpackSize;
          dynamicTableByteCount -= dynamicTable[j].hpackSize;
          headerCount--;
          entriesToEvict++;
        }
        System.arraycopy(dynamicTable, nextHeaderIndex + 1, dynamicTable,
            nextHeaderIndex + 1 + entriesToEvict, headerCount);
        Arrays.fill(dynamicTable, nextHeaderIndex + 1, nextHeaderIndex + 1 + entriesToEvict, null);
        nextHeaderIndex += entriesToEvict;
      }
      return entriesToEvict;
    }

    private void insertIntoDynamicTable(Header entry) {
      int delta = entry.hpackSize;

      // if the new or replacement header is too big, drop all entries.
      if (delta > maxDynamicTableByteCount) {
        clearDynamicTable();
        return;
      }

      // Evict headers to the required length.
      int bytesToRecover = (dynamicTableByteCount + delta) - maxDynamicTableByteCount;
      evictToRecoverBytes(bytesToRecover);

      if (headerCount + 1 > dynamicTable.length) { // Need to grow the dynamic table.
        Header[] doubled = new Header[dynamicTable.length * 2];
        System.arraycopy(dynamicTable, 0, doubled, dynamicTable.length, dynamicTable.length);
        nextHeaderIndex = dynamicTable.length - 1;
        dynamicTable = doubled;
      }
      int index = nextHeaderIndex--;
      dynamicTable[index] = entry;
      headerCount++;
      dynamicTableByteCount += delta;
    }

动态表占用的空间超出限制时,老的头部字段将被移除。在OkHttp3中,动态表是一个自后向前生长的表。

在数据的接收防线,okhttp3.internal.http2.Http2Reader 的
nextFrame(Handler handler) 会不停从网络读取一帧帧的数据:

  public boolean nextFrame(Handler handler) throws IOException {
    try {
      source.require(9); // Frame header size
    } catch (IOException e) {
      return false; // This might be a normal socket close.
    }

      /*  0                   1                   2                   3
       *  0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
       * +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
       * |                 Length (24)                   |
       * +---------------+---------------+---------------+
       * |   Type (8)    |   Flags (8)   |
       * +-+-+-----------+---------------+-------------------------------+
       * |R|                 Stream Identifier (31)                      |
       * +=+=============================================================+
       * |                   Frame Payload (0...)                      ...
       * +---------------------------------------------------------------+
       */
    int length = readMedium(source);
    if (length < 0 || length > INITIAL_MAX_FRAME_SIZE) {
      throw ioException("FRAME_SIZE_ERROR: %s", length);
    }
    byte type = (byte) (source.readByte() & 0xff);
    byte flags = (byte) (source.readByte() & 0xff);
    int streamId = (source.readInt() & 0x7fffffff); // Ignore reserved bit.
    if (logger.isLoggable(FINE)) logger.fine(frameLog(true, streamId, length, type, flags));

    switch (type) {
      case TYPE_DATA:
        readData(handler, length, flags, streamId);
        break;

      case TYPE_HEADERS:
        readHeaders(handler, length, flags, streamId);
        break;

读到头部块时,会立即维护本地接收方向的动态表:

  private void readHeaders(Handler handler, int length, byte flags, int streamId)
      throws IOException {
    if (streamId == 0) throw ioException("PROTOCOL_ERROR: TYPE_HEADERS streamId == 0");

    boolean endStream = (flags & FLAG_END_STREAM) != 0;

    short padding = (flags & FLAG_PADDED) != 0 ? (short) (source.readByte() & 0xff) : 0;

    if ((flags & FLAG_PRIORITY) != 0) {
      readPriority(handler, streamId);
      length -= 5; // account for above read.
    }

    length = lengthWithoutPadding(length, flags, padding);

    List<Header> headerBlock = readHeaderBlock(length, padding, flags, streamId);

    handler.headers(endStream, streamId, -1, headerBlock);
  }

  private List<Header> readHeaderBlock(int length, short padding, byte flags, int streamId)
      throws IOException {
    continuation.length = continuation.left = length;
    continuation.padding = padding;
    continuation.flags = flags;
    continuation.streamId = streamId;

    // TODO: Concat multi-value headers with 0x0, except COOKIE, which uses 0x3B, 0x20.
    // http://tools.ietf.org/html/draft-ietf-httpbis-http2-17#section-8.1.2.5
    hpackReader.readHeaders();
    return hpackReader.getAndResetHeaderList();
  }

okhttp3.internal.http2.Hpack.Reader的readHeaders()如下:

  static final class Reader {

    private final List<Header> headerList = new ArrayList<>();
    private final BufferedSource source;

    private final int headerTableSizeSetting;
    private int maxDynamicTableByteCount;

    // Visible for testing.
    Header[] dynamicTable = new Header[8];
    // Array is populated back to front, so new entries always have lowest index.
    int nextHeaderIndex = dynamicTable.length - 1;
    int headerCount = 0;
    int dynamicTableByteCount = 0;

    Reader(int headerTableSizeSetting, Source source) {
      this(headerTableSizeSetting, headerTableSizeSetting, source);
    }

    Reader(int headerTableSizeSetting, int maxDynamicTableByteCount, Source source) {
      this.headerTableSizeSetting = headerTableSizeSetting;
      this.maxDynamicTableByteCount = maxDynamicTableByteCount;
      this.source = Okio.buffer(source);
    }

    int maxDynamicTableByteCount() {
      return maxDynamicTableByteCount;
    }

    private void adjustDynamicTableByteCount() {
      if (maxDynamicTableByteCount < dynamicTableByteCount) {
        if (maxDynamicTableByteCount == 0) {
          clearDynamicTable();
        } else {
          evictToRecoverBytes(dynamicTableByteCount - maxDynamicTableByteCount);
        }
      }
    }

    private void clearDynamicTable() {
      headerList.clear();
      Arrays.fill(dynamicTable, null);
      nextHeaderIndex = dynamicTable.length - 1;
      headerCount = 0;
      dynamicTableByteCount = 0;
    }

    /** Returns the count of entries evicted. */
    private int evictToRecoverBytes(int bytesToRecover) {
      int entriesToEvict = 0;
      if (bytesToRecover > 0) {
        // determine how many headers need to be evicted.
        for (int j = dynamicTable.length - 1; j >= nextHeaderIndex && bytesToRecover > 0; j--) {
          bytesToRecover -= dynamicTable[j].hpackSize;
          dynamicTableByteCount -= dynamicTable[j].hpackSize;
          headerCount--;
          entriesToEvict++;
        }
        System.arraycopy(dynamicTable, nextHeaderIndex + 1, dynamicTable,
            nextHeaderIndex + 1 + entriesToEvict, headerCount);
        nextHeaderIndex += entriesToEvict;
      }
      return entriesToEvict;
    }

    /**
     * Read {@code byteCount} bytes of headers from the source stream. This implementation does not
     * propagate the never indexed flag of a header.
     */
    void readHeaders() throws IOException {
      while (!source.exhausted()) {
        int b = source.readByte() & 0xff;
        if (b == 0x80) { // 10000000
          throw new IOException("index == 0");
        } else if ((b & 0x80) == 0x80) { // 1NNNNNNN
          int index = readInt(b, PREFIX_7_BITS);
          readIndexedHeader(index - 1);
        } else if (b == 0x40) { // 01000000
          readLiteralHeaderWithIncrementalIndexingNewName();
        } else if ((b & 0x40) == 0x40) {  // 01NNNNNN
          int index = readInt(b, PREFIX_6_BITS);
          readLiteralHeaderWithIncrementalIndexingIndexedName(index - 1);
        } else if ((b & 0x20) == 0x20) {  // 001NNNNN
          maxDynamicTableByteCount = readInt(b, PREFIX_5_BITS);
          if (maxDynamicTableByteCount < 0
              || maxDynamicTableByteCount > headerTableSizeSetting) {
            throw new IOException("Invalid dynamic table size update " + maxDynamicTableByteCount);
          }
          adjustDynamicTableByteCount();
        } else if (b == 0x10 || b == 0) { // 000?0000 - Ignore never indexed bit.
          readLiteralHeaderWithoutIndexingNewName();
        } else { // 000?NNNN - Ignore never indexed bit.
          int index = readInt(b, PREFIX_4_BITS);
          readLiteralHeaderWithoutIndexingIndexedName(index - 1);
        }
      }
    }

    public List<Header> getAndResetHeaderList() {
      List<Header> result = new ArrayList<>(headerList);
      headerList.clear();
      return result;
    }

    private void readIndexedHeader(int index) throws IOException {
      if (isStaticHeader(index)) {
        Header staticEntry = STATIC_HEADER_TABLE[index];
        headerList.add(staticEntry);
      } else {
        int dynamicTableIndex = dynamicTableIndex(index - STATIC_HEADER_TABLE.length);
        if (dynamicTableIndex < 0 || dynamicTableIndex > dynamicTable.length - 1) {
          throw new IOException("Header index too large " + (index + 1));
        }
        headerList.add(dynamicTable[dynamicTableIndex]);
      }
    }

    // referencedHeaders is relative to nextHeaderIndex + 1.
    private int dynamicTableIndex(int index) {
      return nextHeaderIndex + 1 + index;
    }

    private void readLiteralHeaderWithoutIndexingIndexedName(int index) throws IOException {
      ByteString name = getName(index);
      ByteString value = readByteString();
      headerList.add(new Header(name, value));
    }

    private void readLiteralHeaderWithoutIndexingNewName() throws IOException {
      ByteString name = checkLowercase(readByteString());
      ByteString value = readByteString();
      headerList.add(new Header(name, value));
    }

    private void readLiteralHeaderWithIncrementalIndexingIndexedName(int nameIndex)
        throws IOException {
      ByteString name = getName(nameIndex);
      ByteString value = readByteString();
      insertIntoDynamicTable(-1, new Header(name, value));
    }

    private void readLiteralHeaderWithIncrementalIndexingNewName() throws IOException {
      ByteString name = checkLowercase(readByteString());
      ByteString value = readByteString();
      insertIntoDynamicTable(-1, new Header(name, value));
    }

    private ByteString getName(int index) {
      if (isStaticHeader(index)) {
        return STATIC_HEADER_TABLE[index].name;
      } else {
        return dynamicTable[dynamicTableIndex(index - STATIC_HEADER_TABLE.length)].name;
      }
    }

    private boolean isStaticHeader(int index) {
      return index >= 0 && index <= STATIC_HEADER_TABLE.length - 1;
    }

    /** index == -1 when new. */
    private void insertIntoDynamicTable(int index, Header entry) {
      headerList.add(entry);

      int delta = entry.hpackSize;
      if (index != -1) { // Index -1 == new header.
        delta -= dynamicTable[dynamicTableIndex(index)].hpackSize;
      }

      // if the new or replacement header is too big, drop all entries.
      if (delta > maxDynamicTableByteCount) {
        clearDynamicTable();
        return;
      }

      // Evict headers to the required length.
      int bytesToRecover = (dynamicTableByteCount + delta) - maxDynamicTableByteCount;
      int entriesEvicted = evictToRecoverBytes(bytesToRecover);

      if (index == -1) { // Adding a value to the dynamic table.
        if (headerCount + 1 > dynamicTable.length) { // Need to grow the dynamic table.
          Header[] doubled = new Header[dynamicTable.length * 2];
          System.arraycopy(dynamicTable, 0, doubled, dynamicTable.length, dynamicTable.length);
          nextHeaderIndex = dynamicTable.length - 1;
          dynamicTable = doubled;
        }
        index = nextHeaderIndex--;
        dynamicTable[index] = entry;
        headerCount++;
      } else { // Replace value at same position.
        index += dynamicTableIndex(index) + entriesEvicted;
        dynamicTable[index] = entry;
      }
      dynamicTableByteCount += delta;
    }

HTTP/2中数据收发两端的动态表一致性主要是依赖TCP来实现的。

Done。

二、详情分析

永利酒店赌场 7

i)          
首次探测(首次探测会清空DNS缓存和浏览器缓存),重复探测(保留首次探测的缓存,进行再次探测)。

j)           页面加载时间:从页面开始加载到页面onload事件触发的时间。

k)         首字节时间:从开始加载到收到服务器返回数据的第一字节的时间。

l)          
开始渲染时间:从开始加载到浏览器开始渲染第一个html元素的时间。

m)       Speed index:

n)         元素个数:页面中包含的所有DOM节点个数

o)        
页面加载(包括加载时间,请求数,下载总计):从页面开始加载到onload事件触发这个时间段内的统计数据,一般来说onload触发代表着直接通过HTML引用的CSS,JS,图片资源已经完全加载完毕。

p)        
完全加载:随着ajax应用的流行,很多资源都会通过JS脚步异步加载,所以onload事件并不意味着完全加载,onload之后js可能依然在异步加载资源。完全加载的定义是:页面onload后2秒内不再有网络请求时刻。

q)         元素瀑布图:通过元素瀑布图可以很直观得到以下信息。

                         i.              资源的加载顺序。

                       ii.              每个资源的排队延迟,加载过程。

                      iii.              加载过程中CPU和贷款的变化曲线。

                      iv.             
统计出出错请求、大图片请求、onload之后的请求、开始渲染之前的请求、首字节较慢的请求及DNS解析较慢的请求个数。

r)         
连接视图展现了页面加载过程中创建的(keep-alive)连接,以及通过每个连接所加载的资源。

 

压缩后的效果

接下来我将使用访问本博客的抓包记录来说明 HTTP/2
头部压缩带来的变化。如何使用 Wireshark 对 HTTPS
网站进行抓包并解密,请看我的这篇文章。

首先直接上图。下图选中的 Stream 是首次访问本站,浏览器发出的请求头:

永利酒店赌场 8

从图片中可以看到这个 HEADERS 流的长度是 206 个字节,而解码后的头部长度有
451 个字节。由此可见,压缩后的头部大小减少了一半多。

然而这就是全部吗?再上一张图。下图选中的 Stream
是点击本站链接后,浏览器发出的请求头:

永利酒店赌场 9

可以看到这一次,HEADERS 流的长度只有 49 个字节,但是解码后的头部长度却有
470 个字节。这一次,压缩后的头部大小几乎只有原始大小的 1/10。

为什么前后两次差距这么大呢?我们把两次的头部信息展开,查看同一个字段两次传输所占用的字节数:

永利酒店赌场 10

永利酒店赌场 11

对比后可以发现,第二次的请求头部之所以非常小,是因为大部分键值对只占用了一个字节。尤其是
UserAgent、Cookie
这样的头部,首次请求中需要占用很多字节,后续请求中都只需要一个字节。

为什么要压缩

在 HTTP/1 中,HTTP 请求和响应都是由「状态行、请求 /
响应头部、消息主体」三部分组成。一般而言,消息主体都会经过 gzip
压缩,或者本身传输的就是压缩过后的二进制文件(例如图片、音频),但状态行和头部却没有经过任何压缩,直接以纯文本传输。

随着 Web 功能越来越复杂,每个页面产生的请求数也越来越多,根据 HTTP
Archive 的统计,当前平均每个页面都会产生上百个请求。越来越多的请求导致消耗在头部的流量越来越多,尤其是每次都要传输
UserAgent、Cookie 这类不会频繁变动的内容,完全是一种浪费。

以下是我随手打开的一个页面的抓包结果。可以看到,传输头部的网络开销超过
100kb,比 HTML 还多:

永利酒店赌场 12

下面是其中一个请求的明细。可以看到,为了获得 58
字节的数据,在头部传输上花费了好几倍的流量:

永利酒店赌场 13

HTTP/1
时代,为了减少头部消耗的流量,有很多优化方案可以尝试,例如合并请求、启用
Cookie-Free
域名等等,但是这些方案或多或少会引入一些新的问题,这里不展开讨论。

区别二:首部压缩

为什么要压缩?在 HTTP/1 中,HTTP 请求和响应都是由「状态行、请求 /
响应头部、消息主体」三部分组成。一般而言,消息主体都会经过 gzip
压缩,或者本身传输的就是压缩过后的二进制文件,但状态行和头部却没有经过任何压缩,直接以纯文本传输。

随着 Web
功能越来越复杂,每个页面产生的请求数也越来越多,导致消耗在头部的流量越来越多,尤其是每次都要传输
UserAgent、Cookie 这类不会频繁变动的内容,完全是一种浪费。掌握这 11
个方法论,搞定一场完美技术面试!

我们再用通俗的语言解释下,压缩的原理。头部压缩需要在支持 HTTP/2
的浏览器和服务端之间。

  • 维护一份相同的静态字典(Static
    Table),包含常见的头部名称,以及特别常见的头部名称与值的组合;
  • 维护一份相同的动态字典(Dynamic Table),可以动态的添加内容;
  • 支持基于静态哈夫曼码表的哈夫曼编码(Huffman Coding);

静态字典的作用有两个:

1)对于完全匹配的头部键值对,例如 “:method
:GET”,可以直接使用一个字符表示;

2)对于头部名称可以匹配的键值对,例如 “cookie
:xxxxxxx”,可以将名称使用一个字符表示。

HTTP/2 中的静态字典如下(以下只截取了部分,完整表格在这里):

永利酒店赌场 14img

同时,浏览器和服务端都可以向动态字典中添加键值对,之后这个键值对就可以使用一个字符表示了。需要注意的是,动态字典上下文有关,需要为每个
HTTP/2
连接维护不同的字典。在传输过程中使用,使用字符代替键值对大大减少传输的数据量。

用字面量表示头部字段

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

评分等级指标:

1.       确保少量的HTTP请求(合并JS,CSS图片等)

2.       使用内容分发CDN

3.       设置过期的HTTP Header.设置Expires Header可以将脚本, 样式表,
图片, Flash等缓存在浏览器的Cache中。

4.       使用gzip压缩

5.       将CSS放置html头部

6.       将JavaScript放置底部

7.       Avoid CSS expressions

8.       使用外部引用JavaScript与CSS

9.       减少DNS解析

10.   压缩JavaScript和CSS

11.   避免URL重定向。URL redirects are made using HTTP status codes 301
and 302. They tell the browser to go to another location.

12.   删除重复JavaScript和CSS

13.   设置ETags

 

来源:

HTTP/2 头部压缩技术介绍

2016/04/13 · 基础技术 ·
HTTP/2

本文作者: 伯乐在线 –
JerryQu
。未经作者许可,禁止转载!
欢迎加入伯乐在线 专栏作者。

我们知道,HTTP/2 协议由两个 RFC 组成:一个是 RFC
7540,描述了 HTTP/2
协议本身;一个是 RFC
7541,描述了 HTTP/2
协议中使用的头部压缩技术。本文将通过实际案例带领大家详细地认识 HTTP/2
头部压缩这门技术。

HTTP/2 头部压缩技术介绍

2015/11/03 · HTML5 ·
HTTP/2

原文出处:
imququ(@屈光宇)   

我们知道,HTTP/2 协议由两个 RFC 组成:一个是 RFC
7540,描述了 HTTP/2
协议本身;一个是 RFC
7541,描述了 HTTP/2
协议中使用的头部压缩技术。本文将通过实际案例带领大家详细地认识 HTTP/2
头部压缩这门技术。

区别三:HTTP2支持服务器推送

服务端推送是一种在客户端请求之前发送数据的机制。当代网页使用了许多资源:HTML、样式表、脚本、图片等等。在HTTP/1.x中这些资源每一个都必须明确地请求。这可能是一个很慢的过程。浏览器从获取HTML开始,然后在它解析和评估页面的时候,增量地获取更多的资源。因为服务器必须等待浏览器做每一个请求,网络经常是空闲的和未充分使用的。

为了改善延迟,HTTP/2引入了server
push,它允许服务端推送资源给浏览器,在浏览器明确地请求之前。一个服务器经常知道一个页面需要很多附加资源,在它响应浏览器第一个请求的时候,可以开始推送这些资源。这允许服务端去完全充分地利用一个可能空闲的网络,改善页面加载时间。

永利酒店赌场 15img

无索引的字面量头部字段

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

  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位模式开始,其它方面与
增量索引的字面量表示 类似。

互联网现有工具

基于网页分析工具:

1.       阿里测

2.   百度应用性能检测中心

2.       Web PageTest

3.       PingDom Tools

4.       GTmetrix

 

基于浏览器分析工具:

1.       Chrome自带工具F12

2.       Firefox插件:YSlow(Yahoo工具)

3.       Page Speed(google)

 

(以下以分析博客园网站为例www.cnblogs.com)

实现细节

了解了 HTTP/2 头部压缩的基本原理,最后我们来看一下具体的实现细节。HTTP/2
的头部键值对有以下这些情况:

1)整个头部键值对都在字典中

JavaScript

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

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

这是最简单的情况,使用一个字节就可以表示这个头部了,最左一位固定为
1,之后七位存放键值对在静态或动态字典中的索引。例如下图中,头部索引值为
2(0000010),在静态字典中查询可得 :method: GET

永利酒店赌场 16

2)头部名称在字典中,更新动态字典

JavaScript

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

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

对于这种情况,首先需要使用一个字节表示头部名称:左两位固定为
01,之后六位存放头部名称在静态或动态字典中的索引。接下来的一个字节第一位
H 表示头部值是否使用了哈夫曼编码,剩余七位表示头部值的长度 L,后续 L
个字节就是头部值的具体内容了。例如下图中索引值为
32(100000),在静态字典中查询可得
cookie;头部值使用了哈夫曼编码(1),长度是 28(0011100);接下来的 28
个字节是 cookie 的值,将其进行哈夫曼解码就能得到具体内容。

永利酒店赌场 17

客户端或服务端看到这种格式的头部键值对,会将其添加到自己的动态字典中。后续传输这样的内容,就符合第
1 种情况了。

3)头部名称不在字典中,更新动态字典

JavaScript

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

1
2
3
4
5
6
7
8
9
10
11
12
13
  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)  |
+——————————-+
 

这种情况与第 2
种情况类似,只是由于头部名称不在字典中,所以第一个字节固定为
01000000;接着申明名称是否使用哈夫曼编码及长度,并放上名称的具体内容;再申明值是否使用哈夫曼编码及长度,最后放上值的具体内容。例如下图中名称的长度是
5(0000101),值的长度是
6(0000110)。对其具体内容进行哈夫曼解码后,可得 pragma: no-cache

永利酒店赌场 18

客户端或服务端看到这种格式的头部键值对,会将其添加到自己的动态字典中。后续传输这样的内容,就符合第
1 种情况了。

4)头部名称在字典中,不允许更新动态字典

JavaScript

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

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

这种情况与第 2 种情况非常类似,唯一不同之处是:第一个字节左四位固定为
0001,只剩下四位来存放索引了,如下图:

永利酒店赌场 19

这里需要介绍另外一个知识点:对整数的解码。上图中第一个字节为
00011111,并不代表头部名称的索引为 15(1111)。第一个字节去掉固定的
0001,只剩四位可用,将位数用 N 表示,它只能用来表示小于「2 ^ N – 1 =
15」的整数 I。对于 I,需要按照以下规则求值(RFC 7541
中的伪代码,via):

JavaScript

if I < 2 ^ N – 1, return I # I 小于 2 ^ N – 1 时,直接返回 else M =
0 repeat B = next octet # 让 B 等于下一个八位 I = I + (B & 127) *
2 ^ M # I = I + (B 低七位 * 2 ^ M) M = M + 7 while B & 128 == 128
# B 最高位 = 1 时继续,否则返回 I return I

1
2
3
4
5
6
7
8
9
10
if I &lt; 2 ^ N – 1, return I         # I 小于 2 ^ N – 1 时,直接返回
else
    M = 0
    repeat
        B = next octet             # 让 B 等于下一个八位
        I = I + (B &amp; 127) * 2 ^ M  # I = I + (B 低七位 * 2 ^ M)
        M = M + 7
    while B &amp; 128 == 128           # B 最高位 = 1 时继续,否则返回 I
    return I
 

对于上图中的数据,按照这个规则算出索引值为 32(00011111 00010001,15 +
17),代表 cookie。需要注意的是,协议中所有写成(N+)的数字,例如
Index (4+)、Name Length (7+),都需要按照这个规则来编码和解码。

这种格式的头部键值对,不允许被添加到动态字典中(但可以使用哈夫曼编码)。对于一些非常敏感的头部,比如用来认证的
Cookie,这么做可以提高安全性。

5)头部名称不在字典中,不允许更新动态字典

JavaScript

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

1
2
3
4
5
6
7
8
9
10
11
12
13
  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)  |
+——————————-+
 

这种情况与第 3 种情况非常类似,唯一不同之处是:第一个字节固定为
00010000。这种情况比较少见,没有截图,各位可以脑补。同样,这种格式的头部键值对,也不允许被添加到动态字典中,只能使用哈夫曼编码来减少体积。

实际上,协议中还规定了与 4、5 非常类似的另外两种格式:将 4、5
格式中的第一个字节第四位由 1 改为 0
即可。它表示「本次不更新动态词典」,而 4、5
表示「绝对不允许更新动态词典」。区别不是很大,这里略过。

明白了头部压缩的技术细节,理论上可以很轻松写出 HTTP/2
头部解码工具了。我比较懒,直接找来 node-http2 中的
compressor.js
验证一下:

JavaScript

var Decompressor = require(‘./compressor’).Decompressor; var testLog =
require(‘bunyan’).createLogger({name: ‘test’}); var decompressor = new
Decompressor(testLog, ‘REQUEST’); var buffer = new
Buffer(‘820481634188353daded6ae43d3f877abdd07f66a281b0dae053fad0321aa49d13fda992a49685340c8a6adca7e28102e10fda9677b8d05707f6a62293a9d810020004015309ac2ca7f2c3415c1f53b0497ca589d34d1f43aeba0c41a4c7a98f33a69a3fdf9a68fa1d75d0620d263d4c79a68fbed00177febe58f9fbed00177b518b2d4b70ddf45abefb4005db901f1184ef034eff609cb60725034f48e1561c8469669f081678ae3eb3afba465f7cb234db9f4085aec1cd48ff86a8eb10649cbf’,
‘hex’); console.log(decompressor.decompress(buffer));
decompressor._table.forEach(function(row, index) { console.log(index +
1, row[0], row[1]); });

1
2
3
4
5
6
7
8
9
10
11
12
13
var Decompressor = require(‘./compressor’).Decompressor;
 
var testLog = require(‘bunyan’).createLogger({name: ‘test’});
var decompressor = new Decompressor(testLog, ‘REQUEST’);
 
var buffer = new Buffer(‘820481634188353daded6ae43d3f877abdd07f66a281b0dae053fad0321aa49d13fda992a49685340c8a6adca7e28102e10fda9677b8d05707f6a62293a9d810020004015309ac2ca7f2c3415c1f53b0497ca589d34d1f43aeba0c41a4c7a98f33a69a3fdf9a68fa1d75d0620d263d4c79a68fbed00177febe58f9fbed00177b518b2d4b70ddf45abefb4005db901f1184ef034eff609cb60725034f48e1561c8469669f081678ae3eb3afba465f7cb234db9f4085aec1cd48ff86a8eb10649cbf’, ‘hex’);
 
console.log(decompressor.decompress(buffer));
 
decompressor._table.forEach(function(row, index) {
    console.log(index + 1, row[0], row[1]);
});
 

头部原始数据来自于本文第三张截图,运行结果如下(静态字典只截取了一部分):

JavaScript

{ ‘:method’: ‘GET’, ‘:path’: ‘/’, ‘:authority’: ‘imququ.com’, ‘:scheme’:
‘https’, ‘user-agent’: ‘Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11;
rv:41.0) Gecko/20100101 Firefox/41.0’, accept:
‘text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8’,
‘accept-language’: ‘en-US,en;q=0.5’, ‘accept-encoding’: ‘gzip, deflate’,
cookie: ‘v=47; u=6f048d6e-adc4-4910-8e69-797c399ed456’, pragma:
‘no-cache’ } 1 ‘:authority’ ” 2 ‘:method’ ‘GET’ 3 ‘:method’ ‘POST’ 4
‘:path’ ‘/’ 5 ‘:path’ ‘/index.html’ 6 ‘:scheme’ ‘http’ 7 ‘:scheme’
‘https’ 8 ‘:status’ ‘200’ … … 32 ‘cookie’ ” … … 60 ‘via’ ” 61
‘www-authenticate’ ” 62 ‘pragma’ ‘no-cache’ 63 ‘cookie’
‘u=6f048d6e-adc4-4910-8e69-797c399ed456’ 64 ‘accept-language’
‘en-US,en;q=0.5’ 65 ‘accept’
‘text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8’ 66
‘user-agent’ ‘Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11; rv:41.0)
Gecko/20100101 Firefox/41.0’ 67 ‘:authority’ ‘imququ.com’

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
{ ‘:method’: ‘GET’,
  ‘:path’: ‘/’,
  ‘:authority’: ‘imququ.com’,
  ‘:scheme’: ‘https’,
  ‘user-agent’: ‘Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11; rv:41.0) Gecko/20100101 Firefox/41.0’,
  accept: ‘text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8’,
  ‘accept-language’: ‘en-US,en;q=0.5’,
  ‘accept-encoding’: ‘gzip, deflate’,
  cookie: ‘v=47; u=6f048d6e-adc4-4910-8e69-797c399ed456’,
  pragma: ‘no-cache’ }
1 ‘:authority’ ”
2 ‘:method’ ‘GET’
3 ‘:method’ ‘POST’
4 ‘:path’ ‘/’
5 ‘:path’ ‘/index.html’
6 ‘:scheme’ ‘http’
7 ‘:scheme’ ‘https’
8 ‘:status’ ‘200’
… …
32 ‘cookie’ ”
… …
60 ‘via’ ”
61 ‘www-authenticate’ ”
62 ‘pragma’ ‘no-cache’
63 ‘cookie’ ‘u=6f048d6e-adc4-4910-8e69-797c399ed456’
64 ‘accept-language’ ‘en-US,en;q=0.5’
65 ‘accept’ ‘text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8’
66 ‘user-agent’ ‘Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11; rv:41.0) Gecko/20100101 Firefox/41.0’
67 ‘:authority’ ‘imququ.com’
 

可以看到,这段从 Wireshark
拷出来的头部数据可以正常解码,动态字典也得到了更新(62 – 67)。

实现细节

了解了 HTTP/2 头部压缩的基本原理,最后我们来看一下具体的实现细节。HTTP/2
的头部键值对有以下这些情况:

1)整个头部键值对都在字典中

JavaScript

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

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

这是最简单的情况,使用一个字节就可以表示这个头部了,最左一位固定为
1,之后七位存放键值对在静态或动态字典中的索引。例如下图中,头部索引值为
2(0000010),在静态字典中查询可得 :
method :GET

永利酒店赌场 20

2)头部名称在字典中,更新动态字典

JavaScript

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

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

对于这种情况,首先需要使用一个字节表示头部名称:左两位固定为
01,之后六位存放头部名称在静态或动态字典中的索引。接下来的一个字节第一位
H 表示头部值是否使用了哈夫曼编码,剩余七位表示头部值的长度 L,后续 L
个字节就是头部值的具体内容了。例如下图中索引值为
32(100000),在静态字典中查询可得  cookie ;头部值使用了哈夫曼编码(1),长度是
28(0011100);接下来的 28
个字节是 cookie 的值,将其进行哈夫曼解码就能得到具体内容。

永利酒店赌场 21

客户端或服务端看到这种格式的头部键值对,会将其添加到自己的动态字典中。后续传输这样的内容,就符合第
1 种情况了。

3)头部名称不在字典中,更新动态字典

JavaScript

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

1
2
3
4
5
6
7
8
9
10
11
12
13
  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)  |
+——————————-+
 

这种情况与第 2
种情况类似,只是由于头部名称不在字典中,所以第一个字节固定为
01000000;接着申明名称是否使用哈夫曼编码及长度,并放上名称的具体内容;再申明值是否使用哈夫曼编码及长度,最后放上值的具体内容。例如下图中名称的长度是
5(0000101),值的长度是
6(0000110)。对其具体内容进行哈夫曼解码后,可得 pragma: no-cache 。

永利酒店赌场 22

客户端或服务端看到这种格式的头部键值对,会将其添加到自己的动态字典中。后续传输这样的内容,就符合第
1 种情况了。

4)头部名称在字典中,不允许更新动态字典

JavaScript

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

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

这种情况与第 2 种情况非常类似,唯一不同之处是:第一个字节左四位固定为
0001,只剩下四位来存放索引了,如下图:

永利酒店赌场 23

这里需要介绍另外一个知识点:对整数的解码。上图中第一个字节为
00011111,并不代表头部名称的索引为 15(1111)。第一个字节去掉固定的
0001,只剩四位可用,将位数用 N 表示,它只能用来表示小于「2 ^ N – 1 =
15」的整数 I。对于 I,需要按照以下规则求值(RFC 7541
中的伪代码,via):

Python

if I < 2 ^ N – 1, return I # I 小于 2 ^ N – 1 时,直接返回 else M =
0 repeat B = next octet # 让 B 等于下一个八位 I = I + (B & 127) * 2 ^
M # I = I + (B 低七位 * 2 ^ M) M = M + 7 while B & 128 == 128 # B
最高位 = 1 时继续,否则返回 I return I

1
2
3
4
5
6
7
8
9
if I < 2 ^ N – 1, return I         # I 小于 2 ^ N – 1 时,直接返回
else
    M = 0
    repeat
        B = next octet             # 让 B 等于下一个八位
        I = I + (B & 127) * 2 ^ M  # I = I + (B 低七位 * 2 ^ M)
        M = M + 7
    while B & 128 == 128           # B 最高位 = 1 时继续,否则返回 I
    return I

对于上图中的数据,按照这个规则算出索引值为 32(00011111 00010001,15 +
17),代表  cookie 。需要注意的是,协议中所有写成(N+)的数字,例如
Index (4+)、Name Length (7+),都需要按照这个规则来编码和解码。

这种格式的头部键值对,不允许被添加到动态字典中(但可以使用哈夫曼编码)。对于一些非常敏感的头部,比如用来认证的
Cookie,这么做可以提高安全性。

5)头部名称不在字典中,不允许更新动态字典

JavaScript

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

1
2
3
4
5
6
7
8
9
10
11
12
13
  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)  |
+——————————-+
 

这种情况与第 3 种情况非常类似,唯一不同之处是:第一个字节固定为
00010000。这种情况比较少见,没有截图,各位可以脑补。同样,这种格式的头部键值对,也不允许被添加到动态字典中,只能使用哈夫曼编码来减少体积。

实际上,协议中还规定了与 4、5 非常类似的另外两种格式:将 4、5
格式中的第一个字节第四位由 1 改为 0
即可。它表示「本次不更新动态词典」,而 4、5
表示「绝对不允许更新动态词典」。区别不是很大,这里略过。

明白了头部压缩的技术细节,理论上可以很轻松写出 HTTP/2
头部解码工具了。我比较懒,直接找来 node-http2
中的 compressor.js 验证一下:

JavaScript

var Decompressor = require(‘./compressor’).Decompressor; var testLog =
require(‘bunyan’).createLogger({name: ‘test’}); var decompressor = new
Decompressor(testLog, ‘REQUEST’); var buffer = new
Buffer(‘820481634188353daded6ae43d3f877abdd07f66a281b0dae053fad0321aa49d13fda992a49685340c8a6adca7e28102e10fda9677b8d05707f6a62293a9d810020004015309ac2ca7f2c3415c1f53b0497ca589d34d1f43aeba0c41a4c7a98f33a69a3fdf9a68fa1d75d0620d263d4c79a68fbed00177febe58f9fbed00177b518b2d4b70ddf45abefb4005db901f1184ef034eff609cb60725034f48e1561c8469669f081678ae3eb3afba465f7cb234db9f4085aec1cd48ff86a8eb10649cbf’,
‘hex’); console.log(decompressor.decompress(buffer));
decompressor._table.forEach(function(row, index) { console.log(index +
1, row[0], row[1]); });

1
2
3
4
5
6
7
8
9
10
11
12
var Decompressor = require(‘./compressor’).Decompressor;
 
var testLog = require(‘bunyan’).createLogger({name: ‘test’});
var decompressor = new Decompressor(testLog, ‘REQUEST’);
 
var buffer = new Buffer(‘820481634188353daded6ae43d3f877abdd07f66a281b0dae053fad0321aa49d13fda992a49685340c8a6adca7e28102e10fda9677b8d05707f6a62293a9d810020004015309ac2ca7f2c3415c1f53b0497ca589d34d1f43aeba0c41a4c7a98f33a69a3fdf9a68fa1d75d0620d263d4c79a68fbed00177febe58f9fbed00177b518b2d4b70ddf45abefb4005db901f1184ef034eff609cb60725034f48e1561c8469669f081678ae3eb3afba465f7cb234db9f4085aec1cd48ff86a8eb10649cbf’, ‘hex’);
 
console.log(decompressor.decompress(buffer));
 
decompressor._table.forEach(function(row, index) {
    console.log(index + 1, row[0], row[1]);
});

头部原始数据来自于本文第三张截图,运行结果如下(静态字典只截取了一部分):

{ ‘:method’: ‘GET’, ‘:path’: ‘/’, ‘:authority’: ‘imququ.com’, ‘:scheme’:
‘https’, ‘user-agent’: ‘Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11;
rv:41.0) Gecko/20100101 Firefox/41.0’, accept:
‘text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8’,
‘accept-language’: ‘en-US,en;q=0.5’, ‘accept-encoding’: ‘gzip, deflate’,
cookie: ‘v=47; u=6f048d6e-adc4-4910-8e69-797c399ed456’, pragma:
‘no-cache’ } 1 ‘:authority’ ” 2 ‘:method’ ‘GET’ 3 ‘:method’ ‘POST’ 4
‘:path’ ‘/’ 5 ‘:path’ ‘/index.html’ 6 ‘:scheme’ ‘http’ 7 ‘:scheme’
‘https’ 8 ‘:status’ ‘200’ … … 32 ‘cookie’ ” … … 60 ‘via’ ” 61
‘www-authenticate’ ” 62 ‘pragma’ ‘no-cache’ 63 ‘cookie’
‘u=6f048d6e-adc4-4910-8e69-797c399ed456’ 64 ‘accept-language’
‘en-US,en;q=0.5’ 65 ‘accept’
‘text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8’ 66
‘user-agent’ ‘Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11; rv:41.0)
Gecko/20100101 Firefox/41.0’ 67 ‘:authority’ ‘imququ.com’

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
{ ‘:method’: ‘GET’,
  ‘:path’: ‘/’,
  ‘:authority’: ‘imququ.com’,
  ‘:scheme’: ‘https’,
  ‘user-agent’: ‘Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11; rv:41.0) Gecko/20100101 Firefox/41.0’,
  accept: ‘text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8’,
  ‘accept-language’: ‘en-US,en;q=0.5’,
  ‘accept-encoding’: ‘gzip, deflate’,
  cookie: ‘v=47; u=6f048d6e-adc4-4910-8e69-797c399ed456’,
  pragma: ‘no-cache’ }
1 ‘:authority’ ”
2 ‘:method’ ‘GET’
3 ‘:method’ ‘POST’
4 ‘:path’ ‘/’
5 ‘:path’ ‘/index.html’
6 ‘:scheme’ ‘http’
7 ‘:scheme’ ‘https’
8 ‘:status’ ‘200’
… …
32 ‘cookie’ ”
… …
60 ‘via’ ”
61 ‘www-authenticate’ ”
62 ‘pragma’ ‘no-cache’
63 ‘cookie’ ‘u=6f048d6e-adc4-4910-8e69-797c399ed456’
64 ‘accept-language’ ‘en-US,en;q=0.5’
65 ‘accept’ ‘text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8’
66 ‘user-agent’ ‘Mozilla/5.0 (Macintosh; Intel Mac OS X 10.11; rv:41.0) Gecko/20100101 Firefox/41.0’
67 ‘:authority’ ‘imququ.com’

可以看到,这段从 Wireshark
拷出来的头部数据可以正常解码,动态字典也得到了更新(62 – 67)。

HTTP 2.0 的出现,相比于 HTTP 1.x ,大幅度的提升了 web 性能。

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

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

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

做Web开发,难免要对自己开发的页面进行性能检测,自己写工具检测,工作量太大。网上有几款比较成熟的检测工具,以下就介绍一下,与大家分享。

关于作者:JerryQu

永利酒店赌场 24

专注 Web 开发,关注 Web
性能优化与安全。
个人主页 ·
我的文章 ·
2 ·
  

永利酒店赌场 1

压缩后的效果

接下来我将使用访问本博客的抓包记录来说明 HTTP/2
头部压缩带来的变化。如何使用 Wireshark 对 HTTPS
网站进行抓包并解密,请看我的这篇文章。本文使用的抓包文件,可以点这里下载。

首先直接上图。下图选中的 Stream 是首次访问本站,浏览器发出的请求头:

永利酒店赌场 26

从图片中可以看到这个 HEADERS 流的长度是 206 个字节,而解码后的头部长度有
451 个字节。由此可见,压缩后的头部大小减少了一半多。

然而这就是全部吗?再上一张图。下图选中的 Stream
是点击本站链接后,浏览器发出的请求头:

永利酒店赌场 27

可以看到这一次,HEADERS 流的长度只有 49 个字节,但是解码后的头部长度却有
470 个字节。这一次,压缩后的头部大小几乎只有原始大小的 1/10。

为什么前后两次差距这么大呢?我们把两次的头部信息展开,查看同一个字段两次传输所占用的字节数:

永利酒店赌场 28

永利酒店赌场 29

对比后可以发现,第二次的请求头部之所以非常小,是因为大部分键值对只占用了一个字节。尤其是
UserAgent、Cookie
这样的头部,首次请求中需要占用很多字节,后续请求中都只需要一个字节。

HTTP协议(HyperTextTransferProtocol,超文本传输协议)是用于从WWW服务器传输超文本到本地浏览器的传输协议。

整数的表示

在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的整数表示方法可以表示无限大的数,但实际的实现中并不会将整数当做无限大的整数来处理。

阿里测:

http://www.alibench.com

首页:

永利酒店赌场 30

为什么要压缩

在 HTTP/1 中,HTTP 请求和响应都是由「状态行、请求 /
响应头部、消息主体」三部分组成。一般而言,消息主体都会经过 gzip
压缩,或者本身传输的就是压缩过后的二进制文件(例如图片、音频),但状态行和头部却没有经过任何压缩,直接以纯文本传输。

随着 Web 功能越来越复杂,每个页面产生的请求数也越来越多,根据 HTTP
Archive
的统计,当前平均每个页面都会产生上百个请求。越来越多的请求导致消耗在头部的流量越来越多,尤其是每次都要传输
UserAgent、Cookie 这类不会频繁变动的内容,完全是一种浪费。

以下是我随手打开的一个页面的抓包结果。可以看到,传输头部的网络开销超过
100kb,比 HTML 还多:

永利酒店赌场 31

下面是其中一个请求的明细。可以看到,为了获得 58
字节的数据,在头部传输上花费了好几倍的流量:

永利酒店赌场 32

HTTP/1
时代,为了减少头部消耗的流量,有很多优化方案可以尝试,例如合并请求、启用
Cookie-Free
域名等等,但是这些方案或多或少会引入一些新的问题,这里不展开讨论。

这是 Akamai 公司建立的一个官方的演示,用以说明 HTTP/2 相比于之前的
HTTP/1.1 在性能上的大幅度提升。 同时请求 379 张图片,从Load time
的对比可以看出 HTTP/2 在速度上的优势。

字符串字面量的编码

头部字段名和头部字段值可使用字符串字面量表示。字符串字面量有两种表示方式,一种是直接用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)树和哈夫曼编码

一、性能打分

永利酒店赌场 33

a)         首字节时间

指标解释:浏览器开始收到服务器响应数据的时间(后台处理时间+重定向时间)
评估方法:达标时间=DNS解析时间+创建连接时间+SSL认证时间+100ms.
比达标时间每慢10ms减1分.

b)         使用长连接(keep alive)

指标解释:
服务器开启长连接后针对同一域名的多个页面元素将会复用同一下载连接(socket)
评估方法:服务器是否返回了”Connection:
keep-alive”HTTP响应头,或者浏览器通过同一连接下载了多个对象

c)         开启GZIP压缩

指标解释:仅检查文本类型(“text/*”,”*javascript*”)
评估方法:服务器是否返回了”Transfer-encoding:
gzip”响应头。假如全部压缩就是满分,否则:得分=满分x(100%-全部gzip后节省的比例%)

d)         图片压缩

评估方法: 对于GIF – 略过 对于PNG – 必须是8位或更低 对于JPEG –
对比使用photoshop质量选择50后的图片,尺寸超出10%以内及格,10%-50%警告,50%以上不达标
得分=满分x(100%-图片重新压缩后可以节省的比例%)

e)         设置静态内容缓存时间

指标解释:css,js,图片资源都应该明确的指定一个缓存时间
评估标准:如果有静态文件的过期时间设置小于30天,将会得到警告

f)          合并css和js文件

指标解释:合并js和css文件可以减少连接数
评估方法:每多一个css文件减5分,每多一个js文件减10分

g)         压缩JS

指标解释:除了开启gzip,使用js压缩工具可以进行代码级的压缩
评估方法:js文件会通过jsmin压缩.如果原始文件gzip过,jsmin处理过的文件也会gzip后再进行对比.如果能节省>5KB或者%10的尺寸,评估失败.如果能节省>1KB同样会收到警告.

h)         合理使用cookie

指标解释:cookie越小越好,而且对于静态文件需要避免设置cookie
评估方法:只要对静态文件域设置了cookie,评估失败.
对于其他请求,cookie尺寸过大会得到警告.

打赏支持我写出更多好文章,谢谢!

任选一种支付方式

永利酒店赌场 34
永利酒店赌场 35

1 赞 3 收藏
评论

永利酒店赌场 36img

永利酒店赌场 37

YSlow:

火狐插件(自行安装)

区别一:多路复用

多路复用允许单一的 HTTP/2 连接同时发起多重的请求-响应消息。看个例子:

永利酒店赌场 38img

整个访问流程第一次请求index.html页面,之后浏览器会去请求style.css和scripts.js的文件。左边的图是顺序加载两个个文件的,右边则是并行加载两个文件。

我们知道HTTP底层其实依赖的是TCP协议,那问题是在同一个连接里面同时发生两个请求响应着是怎么做到的?

首先你要知道,TCP连接相当于两根管道(一个用于服务器到客户端,一个用于客户端到服务器),管道里面数据传输是通过字节码传输,传输是有序的,每个字节都是一个一个来传输。

例如客户端要向服务器发送Hello、World两个单词,只能是先发送Hello再发送World,没办法同时发送这两个单词。不然服务器收到的可能就是HWeolrllod(注意是穿插着发过去了,但是顺序还是不会乱)。这样服务器就懵b了。

接上面的问题,能否同时发送Hello和World两个单词能,当然也是可以的,可以将数据拆成包,给每个包打上标签。发的时候是这样的①H
②W ①e ②o ①l ②r ①l ②l ①o
②d。这样到了服务器,服务器根据标签把两个单词区分开来。实际的发送效果如下图:

永利酒店赌场 39img

要实现上面的效果我们引入一个新的概念就是:二进制分帧。

二进制分帧层 在 应用层和传输层(TCP or
UDP)之间。HTTP/2并没有去修改TCP协议而是尽可能的利用TCP的特性。

永利酒店赌场 40img

在二进制分帧层中, HTTP/2
会将所有传输的信息分割为帧,并对它们采用二进制格式的编码 ,其中
首部信息会被封装到 HEADER frame,而相应的 Request Body 则封装到 DATA
frame 里面。

HTTP 性能优化的关键并不在于高带宽,而是低延迟。TCP
连接会随着时间进行自我「调谐」,起初会限制连接的最大速度,如果数据成功传输,会随着时间的推移提高传输的速度。这种调谐则被称为
TCP 慢启动。由于这种原因,让原本就具有突发性和短时性的 HTTP
连接变的十分低效。

HTTP/2 通过让所有数据流共用同一个连接,可以更有效地使用 TCP
连接,让高带宽也能真正的服务于 HTTP 的性能提升。

通过下面两张图,我们可以更加深入的认识多路复用:

永利酒店赌场 41img

HTTP/1

永利酒店赌场 42img

HTTP/2

总结下:多路复用技术:单连接多资源的方式,减少服务端的链接压力,内存占用更少,连接吞吐量更大;由于减少TCP
慢启动时间,提高传输的速度

首部字段及首部块的表示

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

说到用索引表示首部字段,就不能不提一下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

永利酒店赌场,首部压缩正是为了解决这样的问题而设计。

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。

Header 2

Header 1

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

增量索引的字面量表示

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

  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位索引,其后是头部字段名。

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

哈夫曼树的构造

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编码解码的效率。用一个图来表示,将是下面这个样子的:

永利酒店赌场 43

Huffman Tree

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

首部列表的表示

各个首部字段表示合并起来形成首部列表。在
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中一个非常重要的特性,它大大减少了网络中HTTP请求/响应头部传输所需的带宽。HTTP/2的首部压缩,主要从两个方面实现,一是首部表示,二是请求间首部字段内容的复用。

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编码算法做更多了解。

永利酒店赌场 44

用索引表示头部字段

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

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

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

网站地图xml地图