带你写HTTP/2, 实现HTTP/2的编码

HTTP

此文章只讨论HTTP和HTTP2,不讨论TLS和HTTP3

什么是HTTP

HTTP(超文本传输协议,HyperText Transfer Protocol)是

  • OSI七层(应用层,表示层,会话层,传输层,网络层,数据链路层,物理层)
  • TCP/IP四层(应用层,传输层,网络层,数据链路层)

中的应用层协议。

为什么需要HTTP?

HTTP(超文本传输协议)的主要作用是在客户端和服务器之间传输超文本(如HTML)及其他资源。

为什么不直接用tcp传输

  1. tcp是一个面向字节流的协议,会有分包,粘包等
  2. tcp传输数据没有规定格式,无法解析数据(就是这些规定形成了应用层协议)
  3. tcp没标准化 ,没高层语义

HTTP/1.1

HTTP/1.1就是要解决上面直接用tcp的问题

HTTP/1.1 request由四个部分组成

  1. http url http_URL = "http:" "//" host [ ":" port ] [ abs_path [ "?" query ]]

  2. http 请求行 GET / HTTP/1.1

  3. http headers Content-Length: 11

  4. http body { "json":"example" }

http/1.1的三种发送方式

1. http短连接

2. http长连接

3. tcp管道

其中tcp管道已被证明难以实现因为返回时需要按发送的顺序返回。

http/1.1的缺点

  • 请求 / 响应头部(Header)未经压缩就发送,首部信息越多延迟越大。只能压缩 Body 的部分;
  • 发送冗长的首部。每次互相发送相同的首部造成的浪费较多;
  • 服务器是按请求的顺序响应的,如果服务器响应慢,会招致客户端一直请求不到数据,队头阻塞;
  • 没有请求优先级控制;
  • 请求只能从客户端开始,服务器只能被动响应。

HTTP/2

HTTP/2就是来解决HTTP/1.1的缺点的

  • 使用HPACK头部压缩
  • 使用Stream实现多路复用
  • 有优先级控制,stream dependency等设置
  • 支持服务器推送

HTTP/2的优先级,依赖,流量控制后面再实现,这里先实现http2的编解码

HTTP/2 Connection

HTTP/2 建议在客户端与服务端之间只建立一条tcp连接,因为HTTP2可以多路复用

Java 复制代码
/**
 * http2 connection
 *
 * @author qqiu
 */
@RequiredArgsConstructor
public class Connection {
    /**
     * tcp socket
     */
    final Socket socket;
    /**
     * key: stream id
     * value: stream
     */
    Map<Integer, Stream> streams=new ConcurrentHashMap<>();
    /**
     * key: index
     * value: header `name=value`
     */
    DynamicTable dynamicTable=new DynamicTable();
}

动态表在HPACK中讲解

HTTP/2 Stream

  • 是 HTTP/2 连接中的一个逻辑通道,用于传输一个 HTTP 请求和响应。
  • 每个流有一个唯一的 流标识符(Stream ID) ,用于区分不同的流。
  • 流是双向的,客户端和服务器可以在同一个流上发送和接收帧。
  • 一个流只能用于传输一个 HTTP 请求和响应,不能复用。

流的特点

  • 唯一性:每个流有一个唯一的流标识符(Stream ID),由发起流的端点分配。
  • 独立性:多个流可以在同一个连接上并行传输,互不干扰。
  • 双向性:客户端和服务器可以在同一个流上发送和接收帧。
  • 有序性:流中的帧必须按顺序发送和接收,以确保消息的完整性。

stream state

perl 复制代码
Stream States
                                +--------+
                        send PP |        | recv PP
                       ,--------|  idle  |--------.
                      /         |        |         \
                     v          +--------+          v
              +----------+          |           +----------+
              |          |          | send H /  |          |
       ,------| reserved |          | recv H    | reserved |------.
       |      | (local)  |          |           | (remote) |      |
       |      +----------+          v           +----------+      |
       |          |             +--------+             |          |
       |          |     recv ES |        | send ES     |          |
       |   send H |     ,-------|  open  |-------.     | recv H   |
       |          |    /        |        |        \    |          |
       |          v   v         +--------+         v   v          |
       |      +----------+          |           +----------+      |
       |      |   half   |          |           |   half   |      |
       |      |  closed  |          | send R /  |  closed  |      |
       |      | (remote) |          | recv R    | (local)  |      |
       |      +----------+          |           +----------+      |
       |           |                |                 |           |
       |           | send ES /      |       recv ES / |           |
       |           | send R /       v        send R / |           |
       |           | recv R     +--------+   recv R   |           |
       | send R /  `----------->|        |<-----------'  send R / |
       | recv R                 | closed |               recv R   |
       `----------------------->|        |<----------------------'
                                +--------+
idle(空闲)
  • 初始状态 :流在创建时处于 idle 状态。
  • 转换条件
    • 发送或接收 HEADERS 帧(带 END_STREAM 标志)或 PUSH_PROMISE 帧时,流会从 idle 状态转换到其他状态。
    • 发送 PUSH_PROMISE 帧时,流会进入 reserved (local) 状态。
    • 接收 PUSH_PROMISE 帧时,流会进入 reserved (remote) 状态。
reserved (local)(本地保留)
  • 状态描述 :当本地端点(客户端或服务器)发送了 PUSH_PROMISE 帧,表示它承诺将来会推送资源,流进入 reserved (local) 状态。
  • 转换条件
    • 发送 HEADERS 帧后,流会进入 half closed (remote) 状态。
    • 如果发送或接收 RST_STREAM 帧,流会进入 closed 状态。
reserved (remote)(远程保留)
  • 状态描述 :当远程端点发送了 PUSH_PROMISE 帧,表示它承诺将来会推送资源,流进入 reserved (remote) 状态。
  • 转换条件
    • 接收 HEADERS 帧后,流会进入 half closed (local) 状态。
    • 如果发送或接收 RST_STREAM 帧,流会进入 closed 状态。
open(打开)
  • 状态描述:流处于打开状态,双方都可以发送和接收数据帧。
  • 转换条件
    • 如果发送或接收 HEADERS 帧(带 END_STREAM 标志),流会进入 half closed 状态。
    • 如果发送或接收 RST_STREAM 帧,流会进入 closed 状态。
half closed (local)(本地半关闭)
  • 状态描述 :本地端点已经发送了 END_STREAM 标志,表示本地不会再发送数据,但仍然可以接收来自远程端点的数据。
  • 转换条件
    • 如果接收到 END_STREAM 标志,流会进入 closed 状态。
    • 如果发送或接收 RST_STREAM 帧,流会进入 closed 状态。
half closed (remote)(远程半关闭)
  • 状态描述 :远程端点已经发送了 END_STREAM 标志,表示远程不会再发送数据,但本地仍然可以发送数据。
  • 转换条件
    • 如果发送 END_STREAM 标志,流会进入 closed 状态。
    • 如果发送或接收 RST_STREAM 帧,流会进入 closed 状态。
closed(关闭)
  • 状态描述:流已经完全关闭,不能再发送或接收任何数据。
  • 转换条件
    • 一旦流进入 closed 状态,就不能再转换到其他状态。
    • 如果发送或接收 RST_STREAM 帧,流会进入 closed 状态。

HTTP/2 message

在 HTTP/2 中,消息(Message) 是一个逻辑概念,表示一个完整的 HTTP 请求或响应。一个 HTTP/2 消息由一个或多个 帧(Frames) 组成,这些帧通过 流(Stream) 传输。

一个 HTTP/2 消息由以下部分组成:

  1. HEADERS 帧:包含消息的头部字段(如请求方法、状态码、头部字段等)。
  2. DATA 帧(可选):包含消息的负载数据(如请求体或响应体)。
  3. 其他帧 (可选):如 PRIORITY 帧、CONTINUATION 帧等。

HTTP/2 frame

frame Name frame Feature
DATA HTTP DATA
HEADERS HTTP HEADER
PRIORITY 设置优先级
RST_STREAM 错误处理
SETTINGS 参数设置
PUSH_PROMISE 服务端推送
PING CHECK ALIVE
GOAWAY 停止接受新流
WINDOW_UPDATE 流量控制
CONTINUATION 延续HEADER
frame

所有frame共有的前缀

Java 复制代码
/**
 * http2 frame
 *
 * @author qqiu
 */
public abstract class Frame {
    /**
     * 24 bit frame length
     */
    int length;
    /**
     * 8 bit type
     */
    @Getter
    short type;
    /**
     * 8 bit flags
     */
    @Getter
    short flags;
    /**
     * 1 bit r
     */
    boolean r;
    /**
     * 31 bit streamId
     */
    @Getter
    int streamId;
    
    public abstract Frame decode(byte[] bytes);

    public abstract byte[] encode();

    public byte[] encodeFrameHead() {
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        try {
            baos.write(ByteUtils.int2bytes(length,3));
            baos.write(ByteUtils.int2bytes(type,1));
            baos.write(ByteUtils.int2bytes(flags,1));
            byte[] bytes = ByteUtils.int2bytes(streamId, 4);
            bytes[0] = ByteUtils.setBit(bytes[0], 7, r);
            baos.write(bytes);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        return baos.toByteArray();
    }
    public void decodeFrameHead(byte[] bytes) {
        Assert.isTrue(bytes.length >= 9);
        length = ByteUtils.bytes2int(bytes,0,3);
        type = (short) ByteUtils.bytes2int(bytes,3,1);
        flags = (short) ByteUtils.bytes2int(bytes,4,1);
        r = ByteUtils.byteIndex(bytes[5], 7);
        streamId = ByteUtils.bytes2int(bytes,6,4);
    }
}

后面的encode decode太占位置我就不放了

DATA Frame
Java 复制代码
/**
 * http2 data frame
 *
 * @author qiu
 */
public class DataFrame extends Frame {
    /**
     * 8bit exist when flag is PADDED(0x8)
     */
    short padLength;
    String data;
    byte[] padding;
}
HEADERS Frame
Java 复制代码
/**
 * http2 header frame
 *
 * @author qiu
 */
public class HeadersFrame extends Frame {
    /**
     * 8bit exist when flag is PADDED(0x8)
     */
    short padLength;
    /**
     * 1bit exist when flag is PRIORITY(0x20)
     * when e is true, streamId is exclusive
     * when e is false, streamId is inclusive
     */
    boolean e;
    /**
     * 31bit streamId exist when flag is PRIORITY(0x20)
     */
    int streamId;
    /**
     * 8bit exist when flag is PRIORITY(0x20)
     */
    short weight;
    @Getter
    byte[] data;
    byte[] padding;
}
PRIORITY Frame
Java 复制代码
/**
 * http2 priority frame
 *
 * @author qqiu
 */
public class PriorityFrame extends Frame {
    /**
     * 1bit
     * when e is true, streamId is exclusive
     * when e is false, streamId is inclusive
     */
    boolean e;
    /**
     * 31bit streamId  when flag is PRIORITY(0x20)
     */
    int streamId;
    /**
     * 0-256
     */
    short weight;
}
RST_STREAM
Java 复制代码
/**
 * http2 reset stream frame
 *
 * @author qqiu
 */
public class ResetStreamFrame extends Frame{
    /**
     * 32bit 错误码
     */
    int errorCode;
}
SETTINGS Frame
Java 复制代码
/**
 * http2 settings frame
 *
 * @author qqiu
 */
public class SettingsFrame extends Frame {
    public static class Setting {
        /**
         * 16 bit id
         */
        public int id;
        /**
         * 32 bit unsigned value
         */
        public long value;
    }

    List<Setting> settings;
}
PUSH_PROMISE Frame
Java 复制代码
/**
 * http2 push promise frame
 *
 * @author qqiu
 */
public class PushPromiseFrame extends Frame {
    /**
     * 8bit exist when flag is PADDED(0x8)
     */
    short padLength;
    /**
     * 1bit 保留位
     */
    boolean r;
    /**
     * 31bit promisedStreamId
     */
    int promisedStreamId;
    byte[] data;
    byte[] padding;
}
PING Frame
Java 复制代码
/**
 * http2 ping frame
 *
 * @author qqiu
 */
public class PingFrame extends Frame {
    /**
     * 64bit
     */
    String data;
}
GOAWAY Frame
Java 复制代码
/**
 * http2 GOAWAY frame
 *
 * @author qqiu
 */
public class GoAwayFrame extends Frame {
    /**
     * 1bit 保留位
     */
    boolean r;
    /**
     * 31bit 流id
     */
    int lastStreamId;
    /**
     * 32bit 错误码
     */
    int errorCode;
    byte[] data;
}
WINDOW_UPDATE Frame
Java 复制代码
/**
 * http2 window update frame
 *
 * @author qqiu
 */
public class WindowUpdateFrame extends Frame {
    /**
     * 1bit 保留位
     */
    boolean r;
    /**
     * 31bit 窗口大小增量
     */
    int windowSizeIncrement;
}
CONTINUATION Frame
Java 复制代码
/**
 * http2 CONTINUATION frame
 *
 * @author qqiu
 */
public class ContinuationFrame extends Frame{
    byte[] data;
}

Pseudo-Header Fields(伪头部字段)

在HTTP/2中没有请求行,使用伪头部字段代替,

伪头部字段特点

  • 名称以冒号开头 :伪头部字段的名称以冒号(:)开头,例如 :method:path
  • 必须出现在普通头部字段之前 :在 HTTP/2 的 HEADERS 帧中,伪头部字段必须出现在所有普通头部字段之前。
  • 不可重复:每个伪头部字段在同一个请求或响应中只能出现一次。
  • 大小写敏感:伪头部字段的名称必须是小写的。

伪头部字段的使用规则

  • 请求中必须包含的伪头部字段
    • 每个请求必须包含 :method:scheme:path 伪头部字段。
    • 如果请求的目标是权威服务器(如 HTTP/1.1 的 Host 头部字段),则必须包含 :authority 伪头部字段。
  • 响应中必须包含的伪头部字段
    • 每个响应必须包含 :status 伪头部字段。
  • 伪头部字段的顺序
    • 伪头部字段必须出现在普通头部字段之前。
    • 伪头部字段的顺序没有严格要求,但通常按照 :method:scheme:authority:path 的顺序排列。

HPACK

HPACK是Header帧中data的压缩算法,只用于Header帧的data中

HPACK使用两个表将标题字段与索引相关联。

  • 静态表是预定义的,包含公共标题字段(其中大多数字段为空值)。
  • 动态表动态的,编码器可以使用它来索引编码标题列表中重复的标题字段。

静态表

静态表由预定义的标题字段静态列表组成。

动态表

动态表由按先进先出顺序维护的标题字段列表组成。动态表中的第一个也是最新的条目位于最低索引,而动态表中最早的条目位于最高索引。

动态表最初为空。在解压每个头块时添加条目。

动态表可以包含重复的条目(即具有相同名称和相同值的条目)。因此,解码器不得将重复条目视为错误。

编码器决定如何更新动态表,因此可以控制动态表使用的内存量。为了限制解码器的内存需求,动态表大小受到严格限制。

动态表可以看作一个队列,当容量超过时,先进先出

lua 复制代码
<----------  Index Address Space ---------->
<-- Static  Table -->  <-- Dynamic Table -->
+---+-----------+---+  +---+-----------+---+
| 1 |    ...    | s |  |s+1|    ...    |s+k|
+---+-----------+---+  +---+-----------+---+
                  ^                   |
                  |                   V
           Insertion Point      Dropping Point
Java 复制代码
/**
 * @author qiu
 */
public class DynamicTable {
    private int maxSize = 4096;
    private final AtomicInteger index = new AtomicInteger(Const.STATIC_TABLE_SIZE);
    @Getter
    private final LinkedHashMap<Integer, String> table = new LinkedHashMap<>();

    public String get(int index) {
        Assert.isTrue(index >= Const.STATIC_TABLE_SIZE);
        return table.get(index);
    }

    public void put(String value) {
        table.put(index.getAndIncrement(), value);
        while (table.size() > maxSize) {
            table.pollLastEntry();
        }
    }

    public void setMaxSize(int maxSize) {
        this.maxSize = maxSize;
        while (table.size() > maxSize) {
            table.pollLastEntry();
        }
    }

    public int size() {
        return table.size();
    }
}

HPACK编码

HPACK编码使用两种基本类型:无符号变长整数和八位字节字符串。

无符号边长整数

如果整数值足够小,即严格小于2^N-1,则在N位前缀内对其进行编码。

否则,前缀的所有位都设置为1,值减少2^N-1,使用一个或多个八位字节的列表进行编码。

每个八位字节的最高有效位用作连续标志:除列表中的最后一个八位字节外,其值设置为1。八位字节的剩余位用于对减少的值进行编码。

diff 复制代码
  0   1   2   3   4   5   6   7
+---+---+---+---+---+---+---+---+
| ? | ? | ? |       Value       |
+---+---+---+-------------------+

  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      |
+---+---------------------------+
Java 复制代码
/**
 * use HPACK encode int
 * N=n
 * prefix 0
 */
private static byte[] encodeInt(int value, int n) {
    int nBitMax = 0xff >> (8 - n);
    if (value < nBitMax) {
        return new byte[]{(byte) value};
    }
    ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
    value -= nBitMax;
    while (value >= 128) {
        byteArrayOutputStream.write(value % 128 + 128);
        value /= 128;
    }
    byteArrayOutputStream.write(value);
    return byteArrayOutputStream.toByteArray();
}

/**
 * use HPACK decode int
 * N=n
 */
private static int parseInt(ByteReader br, int n) {
    int suffix = ByteUtils.byteSuffix(br, n);
    int nBitMax = 0xff >> (8 - n);
    if (suffix != nBitMax) {
        br.start += 1;
        return suffix;
    }
    int m = 0;
    do {
        br.start += 1;
        suffix = suffix + (br.bytes[br.start] & 127) * (1 << m);
        m += 7;
    } while ((br.bytes[br.start] & 128) == 128);
    return suffix;
}
字符串

标题字段名称和标题字段值可以表示为字符串文字。字符串文字被编码为八位字节序列,可以直接编码字符串文字的八位字节,也可以使用哈夫曼代码

lua 复制代码
     0   1   2   3   4   5   6   7
   +---+---+---+---+---+---+---+---+
   | H |    String Length (7+)     |
   +---+---------------------------+
   |  String Data (Length octets)  |
   +-------------------------------+

字符串文字表示法包含以下字段:

  • H:一位标志,H,指示字符串的八位字节是否为哈夫曼编码。
  • 字符串长度:用于编码字符串文字的八位字节数,编码为带有7位前缀的整数
  • 字符串数据:字符串文字的编码数据。如果H为"0",则编码数据为字符串文字的原始八位字节。如果H是"1",则编码数据是字符串文本的哈夫曼编码。

ByteUtils

Java 复制代码
/**
 * @author qiu
 */
public abstract class ByteUtils {
    /**
     * 字节的位索引:从右到左编号为 01234567。
     * 例如:
     * 00000000
     *   index
     * 76543210
     */
    public static boolean byteIndex(byte b, int index) {
        Assert.isTrue(index >= 0 && index <= 7);
        return (b & (1 << index)) != 0;
    }

    public static boolean byteIndex(ByteReader b, int index) {
        return byteIndex(b.bytes[b.start], index);
    }
    public static byte setBit(byte b, int index, boolean value) {
        Assert.isTrue(index >= 0 && index <= 7);
        if (value) {
            return (byte) (b | (1 << index));
        }
        return (byte) (b & ~(1 << index));
    }

    public static byte[] int2bytes(int i, int length) {
        Assert.isTrue(length >= 0 && length <= 4);
        byte[] bytes = new byte[length];
        for (int j = 0; j < length; j++) {
            bytes[j] = (byte) ((i >> (length - j - 1) * 8) & 0xff);
        }
        return bytes;
    }
    public static int bytes2int(byte[] bytes, int start, int length) {
        Assert.isTrue(length >= 0 && length <= 4);
        int i = 0;
        for (int j = 0; j < length; j++) {
            i += (bytes[start + j] & 0xff) << (length - j - 1) * 8;
        }
        return i;
    }

    public static byte setBytePrefix(byte b, int length, byte prefix) {
        Assert.isTrue(length >= 0 && length <= 8);
        int mask = (1 << length) - 1;
        return (byte) ((b & mask) | (prefix << (8 - length)));
    }

    public static byte byteSuffix(ByteReader b, int length) {
        return byteSuffix(b.bytes[b.start], length);
    }

    public static byte byteSuffix(byte b, int length) {
        Assert.isTrue(length >= 0 && length <= 7);
        return (byte) (b & (0xff >> (8 - length)));
    }

    private static final String[] ZEROS = {"", "0", "00", "000", "0000", "00000", "000000", "0000000", "00000000"};

    public static String toBinaryString(byte b, int length) {
        String binaryString = Integer.toBinaryString(b & 0xFF);
        if (binaryString.length() < length) {
            int diff = length - binaryString.length();
            binaryString = ZEROS[diff] + binaryString;
        }
        return binaryString;
    }
    public static byte[] concat(byte[]... bytes) {
        int length = 0;
        for (byte[] b : bytes) {
            length += b.length;
        }
        byte[] result = new byte[length];
        int index = 0;
        for (byte[] b : bytes) {
            System.arraycopy(b, 0, result, index, b.length);
            index += b.length;
        }
        return result;
    }
}

实现参考

RFC 7540: Hypertext Transfer Protocol Version 2 (HTTP/2)

RFC 7541: HPACK: Header Compression for HTTP/2

相关推荐
极客先躯1 小时前
高级java每日一道面试题-2025年3月05日-微服务篇[Eureka篇]-Eureka在微服务架构中的角色?
java·微服务·架构·服务注册·健康检查·架构服务发现
无极低码1 小时前
FLASK和GPU依赖安装
后端·python·flask
星际编程喵2 小时前
Flask实时监控:打造智能多设备在线离线检测平台(升级版)
后端·python·单片机·嵌入式硬件·物联网·flask
北漂老男孩2 小时前
IntelliJ IDEA 调试技巧指南
java·ide·intellij-idea
八股文领域大手子3 小时前
Leetcode32 最长有效括号深度解析
java·数据库·redis·sql·mysql
上官美丽3 小时前
Springboot中的@ConditionalOnBean注解:使用指南与最佳实践
java·spring boot·mybatis
Another Iso4 小时前
Windows安装Apache Maven 3.9.9
java·maven
鹏神丶明月天4 小时前
mybatis_plus的乐观锁
java·开发语言·数据库
fantasy_44 小时前
Java数据类型 Arrays VS ArraysList VS LikedList 解析
java
IT__learning4 小时前
Java通过Apache POI操作Excel
java·apache·excel