JS `HTTP/2` `Frame` 解析与二进制协议分析

各位观众老爷,大家好!今天咱们来聊聊HTTP/2的Frame解析和二进制协议分析,这玩意儿听起来高大上,其实扒开皮儿,也就是一堆0和1,外加一些精心设计的格式而已。别怕,我会尽量用大白话,加上一些代码示例,让大家都能听明白。

开场白:HTTP/2 是个啥?

HTTP/1.1用了这么多年,有些老态龙钟了。队头阻塞、头部冗余等等问题,让网页加载速度慢吞吞的。HTTP/2就是为了解决这些问题而生的。它最大的特点就是使用了二进制协议,并且引入了多路复用、头部压缩等机制。

啥是二进制协议?

简单来说,就是把原本的文本协议,变成了0和1组成的二进制流。 这样做的好处是:

  • 更高效: 计算机处理二进制数据效率更高。
  • 更紧凑: 二进制数据可以更节省空间。
  • 更易解析: 结构化的二进制数据更容易解析。

Frame:HTTP/2 的基本单位

在HTTP/2中,所有的数据都被封装在Frame中传输。 Frame就像一个个小包裹,每个包裹都有自己的类型和数据。理解Frame的结构,是理解HTTP/2的关键。

Frame 的结构

每个Frame都由一个9字节的Header和一个Payload组成。

字段 长度(字节) 描述
Length 3 Payload的长度。不包括Header的9个字节。
Type 1 Frame的类型。例如DATA、HEADERS、PRIORITY、RST_STREAM、SETTINGS、PUSH_PROMISE、PING、GOAWAY、WINDOW_UPDATE、CONTINUATION等。
Flags 1 与Frame类型相关的标志位。不同的Frame类型,Flags的含义也不同。
Stream Identifier 4 流ID。用于标识Frame所属的流。Stream ID为0的Frame用于控制连接,不属于任何流。
Payload Length 实际的数据。Payload的内容取决于Frame的类型。

Frame 类型详解

HTTP/2 定义了多种 Frame 类型,每种类型都有不同的作用。 咱们挑几个常用的讲讲:

  • DATA Frame: 用于传输HTTP消息的Body。
  • HEADERS Frame: 用于传输HTTP消息的Header。 可以包含完整的header块,也可以只包含一部分,后续用CONTINUATION Frame继续传输。
  • SETTINGS Frame: 用于协商连接级别的配置参数,例如最大并发流数、头部压缩算法等。
  • WINDOW_UPDATE Frame: 用于流量控制。
  • RST_STREAM Frame: 用于重置某个流。
  • GOAWAY Frame: 用于关闭连接。

用 JavaScript 解析 Frame

理论讲完了,咱们来点实际的。用JavaScript写一个简单的Frame解析器。

// 定义一个BufferReader类,方便读取二进制数据
class BufferReader {
  constructor(buffer) {
    this.buffer = buffer;
    this.offset = 0;
  }

  readUInt8() {
    const value = this.buffer.readUInt8(this.offset);
    this.offset += 1;
    return value;
  }

  readUInt16BE() {
    const value = this.buffer.readUInt16BE(this.offset);
    this.offset += 2;
    return value;
  }

  readUInt24BE() {
    // JavaScript没有直接读取24位整数的方法,需要手动处理
    return (this.readUInt8() << 16) | this.readUInt16BE();
  }

  readUInt32BE() {
    const value = this.buffer.readUInt32BE(this.offset);
    this.offset += 4;
    return value;
  }

  readBytes(length) {
    const value = this.buffer.slice(this.offset, this.offset + length);
    this.offset += length;
    return value;
  }

  eof() {
    return this.offset >= this.buffer.length;
  }
}

function parseFrame(buffer) {
  const reader = new BufferReader(buffer);

  const length = reader.readUInt24BE();
  const type = reader.readUInt8();
  const flags = reader.readUInt8();
  const streamIdentifier = reader.readUInt32BE();

  const payload = reader.readBytes(length);

  return {
    length,
    type,
    flags,
    streamIdentifier,
    payload,
  };
}

// 示例:解析一个简单的DATA Frame
const dataFrameBuffer = Buffer.from([
  0x00, 0x00, 0x0a, // Length: 10 bytes
  0x00, // Type: DATA (0x00)
  0x00, // Flags: 0x00
  0x00, 0x00, 0x00, 0x01, // Stream Identifier: 1
  0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x57, 0x6f, 0x72, 0x6c, // Payload: "Hello World" (10 bytes)
]);

const frame = parseFrame(dataFrameBuffer);

console.log(frame);
// 输出:
// {
//   length: 10,
//   type: 0,
//   flags: 0,
//   streamIdentifier: 1,
//   payload: <Buffer 48 65 6c 6c 6f 20 57 6f 72 6c>
// }

console.log(frame.payload.toString()); // 输出: Hello World

这段代码定义了一个 parseFrame 函数,它接收一个Buffer对象作为参数,然后按照Frame的结构,读取各个字段的值。 为了方便读取Buffer,还定义了一个BufferReader类。

更深入的解析:针对不同Frame类型的处理

上面的代码只是一个简单的框架,只能解析Frame的通用头部。 实际上,不同的Frame类型,Payload的结构也不同,需要针对不同的类型进行不同的处理。

例如,对于SETTINGS Frame,Payload包含一系列的设置参数,每个参数由一个ID和一个Value组成。

const SETTINGS_FRAME_TYPE = 0x04;

function parseSettingsFramePayload(payload) {
  const reader = new BufferReader(payload);
  const settings = [];

  while (!reader.eof()) {
    const identifier = reader.readUInt16BE();
    const value = reader.readUInt32BE();
    settings.push({ identifier, value });
  }

  return settings;
}

function parseFrame(buffer) {
  const reader = new BufferReader(buffer);

  const length = reader.readUInt24BE();
  const type = reader.readUInt8();
  const flags = reader.readUInt8();
  const streamIdentifier = reader.readUInt32BE();

  const payload = reader.readBytes(length);

  let parsedPayload = payload; // 默认情况

  if (type === SETTINGS_FRAME_TYPE) {
    parsedPayload = parseSettingsFramePayload(payload);
  }

  return {
    length,
    type,
    flags,
    streamIdentifier,
    payload: parsedPayload,
  };
}

// 示例:解析一个SETTINGS Frame
const settingsFrameBuffer = Buffer.from([
    0x00, 0x00, 0x06,  // Length: 6 bytes
    0x04,              // Type: SETTINGS (0x04)
    0x00,              // Flags: 0x00
    0x00, 0x00, 0x00, 0x00,  // Stream Identifier: 0
    0x00, 0x01, 0x00, 0x00, 0x10, 0x00 // Identifier: 1, Value: 4096
]);

const frame2 = parseFrame(settingsFrameBuffer);
console.log(frame2);
// 输出:
// {
//   length: 6,
//   type: 4,
//   flags: 0,
//   streamIdentifier: 0,
//   payload: [ { identifier: 1, value: 4096 } ]
// }

这段代码添加了一个 parseSettingsFramePayload 函数,用于解析 SETTINGS Frame 的 Payload。 在 parseFrame 函数中,根据Frame的类型,选择不同的解析函数。

HPACK:HTTP/2 的头部压缩

HTTP/2 使用 HPACK 算法来压缩头部,减少头部的大小。 HPACK 使用了静态表、动态表和Huffman编码等技术。

HPACK 的原理比较复杂,这里只简单介绍一下:

  • 静态表: 预定义了一些常用的头部字段和值,例如 :method: GET:status: 200等。
  • 动态表: 客户端和服务器端各自维护一个动态表,用于存储最近使用的头部字段和值。
  • Huffman编码: 使用Huffman编码对头部字段和值进行压缩。

解析 HPACK 编码的头部,需要使用专门的 HPACK 解码器。 这里就不写代码了,因为比较复杂,可以使用现成的库,例如 hpack.js

流量控制

HTTP/2 引入了流量控制机制,防止客户端或服务器端被大量数据淹没。 流量控制基于 Window Update 的概念。 每个流都有一个窗口大小,表示可以接收的数据量。 当接收方处理完数据后,会发送 WINDOW_UPDATE Frame,增加窗口大小。

多路复用

多路复用是 HTTP/2 最重要的特性之一。 它允许在一个TCP连接上同时传输多个HTTP请求和响应。 每个请求和响应都被分配一个唯一的 Stream ID。 客户端和服务器端可以通过 Stream ID 来区分不同的请求和响应。

总结

HTTP/2 是一个复杂的协议,但只要理解了Frame的结构、HPACK 头部压缩、流量控制和多路复用等核心概念,就能更好地理解HTTP/2的工作原理。 希望今天的讲解对大家有所帮助。

彩蛋:如何抓包分析 HTTP/2 流量

可以使用 Wireshark 等抓包工具来分析 HTTP/2 流量。 Wireshark 提供了 HTTP/2 的协议解析器,可以方便地查看 Frame 的内容。 还可以使用 Chrome 浏览器的开发者工具,查看 HTTP/2 的请求和响应头部。

今天的讲座就到这里,感谢大家的观看! 下次有机会再和大家聊聊 HTTP/3 (QUIC) 的相关知识。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注