http3客户端实现

继上一篇实现HTTP长连接的HTTP/1.1连接池实现已经过去半年

这一段时间发现了HTTP/1.1连接池无法解决的致命缺点:

在突发高并发场景下,客户端由于需要额外建连很容易退化成短连接

  • 在超高突增并发下和短链接几乎无异,我司由于业务特性就是这种情况

  • 即使突增并发不高,只要并发持续增长超过一定比例后,由于新建连接的tcp握手+ssl握手需要耗费大量的cpu,造成客户端和服务端的cpu不稳定,叠加新建连接的tcp慢启动因素带来了更多的超时。

    而HTTP/1.1一旦超时就需要断开连接重新建连,更多的建连带来了更多的超时,又带来了更多的建连,就造成了雪崩效应

HTTP/1.1压测

以下是一次模拟业务的简单压测,请求包使用了业务10K左右的post请求,响应包就只是“hello world”,qps缓慢增长以测试极限处理性能,可以发现在达到某个点以后,qps骤降,耗时突增

并且多次压测的结果均不一致(有时候3000qps就会过载),共同点是一旦发生过载,就立刻雪崩断崖下跌,并且大量耗费服务端(下图的go_server)的CPU

服务端不停地刷日志,提示TLS建连失败 reset by peer,因为我设置了客户端超时不进行四次挥手,而是直接reset以减少timewait

基于ngtcp2和nghttp3的实现

实际上在前文实现HTTP长连接中已经对HTTP的已知版本进行过调研了

当时的结论是,在客户端和服务端需要通过外网的长链接方案,要么简单使用HTTP/1.1,要么一步到位使用HTTP/3

当时对HTTP/1.1的雪崩其实有所预计,但是由于某个业务对接其他公司服务端的一些额外限制(单连接最多使用100次),更早的恶化了雪崩情况

因此HTTP/3的开发也就排入日程中了,根据http3的wiki,由于22年6月份才正式标准化HTTP/3,因此能选择的基础库余地也不多,最终选定了ngtcp2+nghttp3的方案

该方案通用性最好,并且在https://github.com/ngtcp2/ngtcp2/blob/release-1.9/examples/client.cc中有一个功能相当完备的实现,只需要在这个基础上修改一下即可

简化demo

由于这个demo近万行,核心代码文件client.cc也有3000行,我实现了一个简化demo在https://github.com/tedcy/nghttp3_simple_demo

编译步骤在README.md中

  • 在https://github.com/tedcy/nghttp3_simple_demo/tree/master的主分支下

    我提供了一个最简单实现,优化了一半的代码,去除了例如连接迁移等功能,并且大幅度的精简了配置项,从50项精简到了10多项核心配置

  • 在https://github.com/tedcy/nghttp3_simple_demo/tree/epoll_dev的分支下

    实现去除libev依赖

  • 在https://github.com/tedcy/nghttp3_simple_demo/tree/epoll_with_taf_http_dev的分支下

    • 对接taf_http这个请求序列化和响应反序列化的库

      为接入我司项目进行准备

    • 复用连接来投递请求

      demo是写死的Client类在连接成功后就发出全部请求,然后退出,这是一次性的,没有复用连接

      参考When can the client open new streams?中创建quic流的指导

      我在Client类中引入请求队列,来复用连接通过查询请求队列持续的发起请求,详见check_pushed_requests函数

BoringSSL和OpenSSL的冲突问题

ngtcp2是依赖BoringSSL来实现TLS1.3的,而我司C++的taf框架是基于业界标准OpenSSL的

虽然BoringSSL是OpenSSL的超集,对外暴露的符号都是兼容的,在简单的编译场景下可以直接替换,但是私有符号不同还是会造成冲突:

taf框架提供给业务的是静态库例如libtaf_by_openssl.a,里面包含了OpenSSL的私有符号

业务部门会依赖taf框架去编译静态库或者动态库对内或对外使用,比如某个编译产物libxxx.so库,它依赖的私有符号也是基于OpenSSL的,如果和libtaf_by_boringssl.a一起编译,会大量报错undefine reference

graph LR
    libtaf_by_openssl.a[原库:libtaf_by_openssl.a] --> OpenSSL[含有OpenSSL私有符号]
    libtaf_by_boringssl.a[替换为新库:libtaf_by_boringssl.a]
    libtaf_by_boringssl.a --> BoringSSL[含有BoringSSL私有符号]
    基于libtaf_by_openssl.a编译的libxxx.so -->|依赖OpenSSL私有符号| 报错[编译错误:Undefined Reference]

基于我所知的编译和链接原理,只能使用动态库隔离解决这个问题

直接依赖的动态库隔离

ngtcp2在链接BoringSSL生成libngtcp2.so动态库的时候,很贴心的使用了-fvisibility=hidden隐藏了全部的符号,并且使用gcc宏导出了所需的符号,例如

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>

// 导出符号,即使全局隐藏也要让此符号可见
extern "C" void __attribute__((visibility("default"))) exported_function() {
printf("This function is exported.\n");
}

// 不导出(默认被隐藏)
extern "C" void hidden_function() {
printf("This function is hidden.\n");
}

编译并查看符号:

1
2
3
4
5
6
7
8
9
10
~ g++ -shared -fPIC -fvisibility=hidden -o libexample.so example.cpp
~ readelf -Ws libexample.so
readelf -Ws libexample.so|grep "function\|Symbol table\|Num:"
Symbol table '.dynsym' contains 10 entries:
Num: Value Size Type Bind Vis Ndx Name
9: 0000000000001150 19 FUNC GLOBAL DEFAULT 12 exported_function
Symbol table '.symtab' contains 30 entries:
Num: Value Size Type Bind Vis Ndx Name
20: 0000000000001163 19 FUNC LOCAL DEFAULT 12 hidden_function
24: 0000000000001150 19 FUNC GLOBAL DEFAULT 12 exported_function

hidden_function符号在动态库被加载的时候不会被引入到全局符号表中,甚至使用dlsym拿都无法提取

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>

int main() {
void* handle = dlopen("./libexample.so", RTLD_LAZY);
if (!handle) {
fprintf(stderr, "Error: %s\n", dlerror());
return 1;
}

// 使用 dlsym 获取符号指针
void (*function_ptr)() = (void (*)())dlsym(handle, "hidden_function");
if (!function_ptr) {
fprintf(stderr, "Error: %s\n", dlerror());
dlclose(handle);
return 1;
}

dlclose(handle);

return 0;
}

编译并运行:

1
2
3
~ gcc dlsym.c -ldl -lexample -L.
~ ./a.out
Error: ./libexample.so: undefined symbol: hidden_function

但是这种方式会让业务的二进制强制新增一个动态库依赖,在一些特殊场景下,业务的二进制无法额外依赖某些动态库(该场景也不需要http3功能),因此我最终使用了可插拔的方案

基于dlopen的可插拔动态库隔离

核心思想是只导出需要使用的函数:

  • initHttp3:用于初始化整个http3库,传入一些配置项
  • createHttp3Conn:用于创建http连接

基于C++的动态库可以全程使用接口方式来定义需要导出的符号,和需要导入给动态库使用的符号,初版实现在https://github.com/tedcy/nghttp3_simple_demo/tree/epoll_with_taf_http_dev_dynamic_build_hide_boringssl

大致的核心代码如下:

demo接口头文件

在这个接口头文件中,只声名了接口,供动态库和主程序一起使用

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
31
32
33
34
35
// http3_interface.h

#pragma once

#include <string>
#include <memory>

struct Http3Config {
// 配置参数
};

//用于主程序传入给动态库,依赖反转来反向调用主程序的日志库
struct TC_Http3LoggerInterface {
virtual ~TC_Http3LoggerInterface() = default;
virtual void log_debug(const char *file, int line, const std::string &msg) = 0;
virtual void log_error(const char *file, int line, const std::string &msg) = 0;
};

//用于主程序传入给动态库,依赖反转来反向调用主程序的Http序列化库
struct Http3RequestInterface {
virtual ~Http3RequestInterface() = default;
// 请求数据结构
virtual std::string getReqStr() = 0;
};

//用于动态库传出给主程序,使得主程序可以使用创建的Http3连接
struct Http3Connection {
virtual ~Http3Connection() = default;
virtual void push_request(const std::shared_ptr<Http3RequestInterface>& req) = 0;
using Ptr = std::shared_ptr<Http3Connection>;
};

// 定义导出函数的类型
using InitHttp3Func = void(*)(TC_Http3LoggerInterface*, const Http3Config&);
using CreateHttp3ConnFunc = Http3Connection::Ptr(*)(const std::string& host, uint32_t port);
demo动态库

动态库实现了Http3连接,可以把请求投递到连接中进行处理

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
31
32
33
34
35
36
37
38
39
40
// http3_library.cpp

#include "http3_interface.h"
#include <iostream>
#include <memory>

class Http3ConnectionImpl : public Http3Connection {
public:
Http3ConnectionImpl(TC_Http3LoggerInterface* logger)
: logger_(logger) {}

void push_request(const std::shared_ptr<Http3RequestInterface>& req) override {
logger_->log_debug(__FILE__, __LINE__, "处理HTTP/3请求:" + req->getReqStr());
// 实现请求处理逻辑
logger_->log_debug(__FILE__, __LINE__, "请求处理完成");
}

private:
TC_Http3LoggerInterface* logger_;
};

static TC_Http3LoggerInterface* global_logger = nullptr;

extern "C" {

__attribute__((visibility("default"))) void initHttp3(TC_Http3LoggerInterface* logger, const Http3Config& config) {
std::cout << "初始化HTTP/3库" << std::endl;
global_logger = logger;
global_logger->log_debug(__FILE__, __LINE__, "HTTP/3库已初始化");
}

__attribute__((visibility("default"))) Http3Connection::Ptr createHttp3Conn(const std::string& host, uint32_t port) {
std::cout << "创建HTTP/3连接到 " << host << ":" << port << std::endl;
if (global_logger) {
global_logger->log_debug(__FILE__, __LINE__, "创建HTTP/3连接实例");
}
return std::make_shared<Http3ConnectionImpl>(global_logger);
}

}
demo主程序
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
// http3_client.cpp

#include "http3_interface.h"
#include <dlfcn.h>
#include <iostream>

#define HTTP3LibCallFunc(name, type, ...) \
do { \
using FuncType = type; \
auto _func = (FuncType)dlsym(HTTP3Lib::getHandle(), #name); \
if (!_func) { \
std::cerr << "获取" << #name << "失败: " << dlerror() << std::endl;\
abort(); \
} \
return _func(__VA_ARGS__); \
} while (0)

class TC_Http3Logger : public TC_Http3LoggerInterface {
public:
void log_debug(const char *file, int line, const std::string &msg) override {
std::cout << "[DEBUG] " << file << ":" << line << " " << msg << std::endl;
}
void log_error(const char *file, int line, const std::string &msg) override {
std::cerr << "[ERROR] " << file << ":" << line << " " << msg << std::endl;
}
};

class HTTP3Lib {
public:
static void* getHandle() {
static HTTP3Lib instance;
return instance.handle_;
}
private:
HTTP3Lib() {
handle_ = dlopen("./libhttp3.so", RTLD_NOW | RTLD_LOCAL);
if (!handle_) {
std::cerr << "加载libhttp3.so失败: " << dlerror() << std::endl;
abort();
}
auto initFunc = (InitHttp3Func)dlsym(handle_, "initHttp3");
if (!initFunc) {
std::cerr << "获取initHttp3失败: " << dlerror() << std::endl;
abort();
}
Http3Config config;
static TC_Http3Logger logger;
initFunc(&logger, config);
}
~HTTP3Lib() {
if (handle_) {
dlclose(handle_);
}
}
void* handle_ = nullptr;
};

static Http3Connection::Ptr createHttp3Conn(const std::string& host, uint32_t port) {
HTTP3LibCallFunc(createHttp3Conn, CreateHttp3ConnFunc, host, port);
}

struct Http3Request : public Http3RequestInterface {
std::string getReqStr() override {
return "Hello World";
}
};

int main() {
// 创建HTTP/3连接
auto conn = createHttp3Conn("example.com", 443);
if (conn) {
// 创建请求
auto request = std::make_shared<Http3Request>();
// 发送请求
conn->push_request(request);
}
return 0;
}

编译执行:

1
2
3
4
5
6
7
8
9
~ g++ -std=c++11 -shared -fPIC http3_library.cpp -o libhttp3.so
~ g++ -std=c++11 -shared -fPIC -fvisibility=hidden http3_library.cpp -o libhttp3.so
~ ./main
[DEBUG] http3_library.cpp:27 初始化HTTP/3库
[DEBUG] http3_library.cpp:29 HTTP/3库已初始化
创建HTTP/3连接到 example.com:443
[DEBUG] http3_library.cpp:35 创建HTTP/3连接实例
[DEBUG] http3_library.cpp:13 处理HTTP/3请求:Hello World
[DEBUG] http3_library.cpp:15 请求处理完成
core问题

后续实现多连接方案的时候,遇到了ucontext_x86协程库,C++单例模式多协程使用dlopen时的coredump问题

这个问题查了整1周才发现,从现象上看疑似爆栈写坏内存,无法解决,只能规避

最后不得已,把dlopen部分放到服务器的初始化线程中解决,也就是HTTP3Lib类不能随时初始化,必须预先初始化,再进行使用

小结

基于dlopen的可插拔动态库隔离,相对于直接依赖的方式,主程序对动态库的耦合更少,在动态库缺失的时候,依然可以降级到不支持HTTP/3的版本

再通过共享接口头文件的技术,可以让C++的主程序和动态库的互相调用相对于C没有那么痛苦,需要一个一个dlsym导出各个符号

perHost连接池实现

每个Host对应一个连接池,这在HTTP/1.1是很容易理解的事情

但是在HTTP/3,由于QUIC已经抽象出Stream来连接复用,并且不存在HTTP/2.0的TCP队头阻塞问题,因此应该只需要一条QUIC连接即可

主流实现方案

大部分的HTTP/3实现也是这样的,以下是各语言的选连接核心逻辑:

  • java:https://github.com/ptrd/flupke/blob/master/src/main/java/tech/kwik/flupke/impl/Http3ConnectionFactory.java#L38

  • go:https://github.com/quic-go/quic-go/blob/master/http3/transport.go#L269

  • python:https://github.com/aiortc/aioquic/blob/main/examples/http3_client.py#L388

那么为什么我还要实现perHost连接池呢?

这是因为我的HTTP客户端遵循taf框架历史包袱的语义,Client是一个单例,使用以下方式发起请求

1
Http::getInstance()->get("www.baidu.com");

而主流的HTTP实现都是可以自己创建Client(go的话是可以指定非默认Transport),在复杂问题上可以自己创建多个Client自己管理

1
2
3
4
//以下代码每个Client各能创建一条quic连接
Client c1, c2;
c1.get("www.baidu.com");
c2.get("www.baidu.com");

我的实现方案

作为单例实现,我必须提前于业务同学考虑,对于单个Host,是否有必要创建多条连接?

答案是有必要的,这有两个好处:

  • 假设对端单个Host有非常多IP的时候(实际业务有遇到过DNS解析出100多个IP的情况)

    多开连接有助于负载均衡

  • QUIC的服务端对单条QUIC会有各种各样的限制

    在握手阶段,通过TLS扩展的EncryptedExtensions消息发送,完整定义在https://www.iana.org/assignments/quic/quic.xhtml#quic-transport

    例如initial_max_streams_bidi指定了双向流的最大并发数,在大部分的服务端实现中默认为100,这显然算不上高并发

由于没有什么作业可以抄了,我的设计思路是遵循HTTP/1.1连接池的核心思想:LRU,最近使用的连接是最活跃的,不会因为空闲断开连接,优先使用

实际实现上可以大量复用HTTP/1.1连接池代码,抽象出连接类,把HTTP1.1的连接看作是并发为1的连接,把HTTP3.0的连接看作是并发为N的连接

  • 每次取连接从队头取

  • 取到连接后当并发耗尽时,将其从连接池中取出不再使用

  • 每个请求处理完毕后,将其重新加入连接池队头

每个连接上已有并发的定义是:已经打开的stream + 进入这条连接待处理队列的req总和

压测结果

模拟业务场景300ms后端处理时间(go的服务处理逻辑sleep 300ms,限制单条连接100并发限制),调用链路:

1
压测服务-》Http3Proxy服务-》go服务

压测模型:

  • 初版单服务只单连接(100并发限制):

    • Http3Proxy服务超过300并发以后Proxy服务队列积压满,过载,几乎没有成功的qps

    • Http3Proxy服务主调go服务成功率21%

      这是一个20秒为粒度的统计图,因此均值qps大约是,\(6715/20 = 335.75\),后端耗时300ms,因此并发是\(335.75 * 0.3 = 100.725\),和预计大致相当

  • 新版单服务可用多连接(无并发限制):

    • Http3Proxy服务被调9700的qps

    • Http3Proxy服务主调go服务成功率100%

      最大并发\(10000 * 0.3 = 3000\),大约开启了30条连接

后续优化方案

当go服务不设置延迟返回300ms的时候,单连接版本实际上可以达到11000的qps,比多连接的版本性能更好

这是因为多连接的实现方案还不够好,从日志观察到,最大并发3000的压测,预期拉起30条100并发的连接,实际上拉起了大概100条连接,造成了性能损失

这是因为对并发的定义:已经打开的stream + 进入这条连接待处理队列的req总和

这会造成大量并发同时进入客户端后,由于连接还未建立,大量的req积压,造成创建大量新连接,实际上当连接创建以后,req可以很快消化完毕,不需要那么多连接

可以引入探测性扩容的思想:

  • 限制每秒的新建连接数

    例如固定基数10条新连接 + 百分比20%的比例

    固定基数用于冷启动的时候快速拉起,百分比在较大基数时起作用

  • 每秒检查扩容效果,当扩容无效时,禁止一段时间的扩容

那么在被限制时,每个连接的待处理队列会进入更多的req

可以使用两个并发定义池子,严格定义的池子以已经打开的stream + 进入这条连接待处理队列的req总和来找连接,宽松定义的池子用已经打开的stream

伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//streams + reqs是更严格的池子,连接更少
ok = getConn() //by streams + reqs < max_streams
if (ok) {
if (streams + reqs >= max_streams) {
pop()
}
return
}
ok = tryCreateNew() //限流器看是否允许创建新的
if (ok) {
return
}
//streams 是宽松的池子,连接更多
ok = getConnForce() //by streams < max_streams
if (ok) {
if (streams >= max_streams) {
pop()
}
return
}
//返回失败

两个细节:

  • 宽松的池子显然不能再用MRU来取连接,否则最近使用的连接会塞满请求,这里要轮询
  • 限流器要用基于时间戳计数的版本,并且通过传入不同的rate从而计算新的可用时间戳,来控制限流器qps的平滑更新

深入理解QUIC

QUIC基础

QUIC的基本功能和位置

image-20250422213428610

QUIC层结构

UDP以UDP四元组(源IP地址、源端口号、目的IP地址、目的端口号)标识,其中IP地址是为了在网络中传递时寻址,端口号是为了在主机上复用时派发。

连接(Connection)是最基本的QUIC实例,一个连接代表客户端和服务器之间的单次会话。一个QUIC连接可以使用多个连接标识识别。

流(Stream)是QUIC提供给应用层的有序字节流抽象,在QUIC协议内部以流标识(Stream ID)区分,在QUIC报文中封装为STREAM帧。

image-20250422225923399

PMTU探测

在互联网中,客户端和服务器之间的网络路径一般会穿越多个设备,设备接口的MTU(Maximum Transmission Unit,最大传输单元)可能各不相同,如果报文超过了设备接口的MTU值,就会被IP层分片或者丢弃。

封装QUIC的IP数据包需要设置DF(Don't Fragment,不分片)位,这是为了避免分片带来的传输效率和传输可靠性问题。

对于QUIC来说,一个IP数据包包含一个UDP数据报,一个UDP数据报包含一到多个QUIC报文,选择一个UDP数据报载荷的大小对于传输来说很重要。如果使用过小的UDP数据报长度,就会浪费网络资源,无法达到最优吞吐量;如果使用过大的UDP数据报长度,会增加丢包的可能性,也增加了重传的压力,甚至可能导致头部阻塞。

PMTU探测有两种:

  • PMTUD(Path MTU Discovery):依赖于ICMP的PTB(Packet Too Big,数据包过大)消息来确定路径的MTU。
  • DPLPMTUD(Datagram Packetization Layer Path MTU Discovery):更通用,主要依赖于黑洞探测(也就是发包以后超时丢失),也可以选择依赖ICMP的PTB消息。
PMTUD

中间路由器收到带有DF位的IP数据包,如果IP数据包的大小大于路由器出口的MTU,则生成ICMP PTB消息。在PTB消息中包含了下一跳MTU和原始报文的引用。发送方收到PTB消息后,验证原始报文确实是自己发送的报文,且原始报文已确认丢失,则确认是合法PTB报文。然后根据下一跳MTU调整探测报文的大小,直到收到对端的确认。

PMTUD的例子下图所示,发送方C先以本地出接口MTU大小5000发送一个探测报文。路径MTU值是取所有段上最小的MTU值,所以不可能超过本地出接口的MTU。

R1收到该报文后发现下一跳MTU是4000,所以生成一个ICMP PTB消息,指示下一跳的MTU值。

发送方C收到PTB报文后更改探测报文大小为4000字节后发送。以此类推,直到确定该路径的最小MTU值2000。

image-20250411003459933

DPLPMTUD

DPLPMTUD的例子发送方C从BASE_PLPMTU大小的报文开始探测,这个探测报文得到了接收方S的确认,PLPMTU即设置为1200;

然后使用下一个MTU得出下一个PROBE_SIZE为2000,这个探测报文仍然没有超过路径上任何一个MTU,也得到了确认,PLPMTU即设置为2000;

继续增加探测报文大小到下一个PROBE_SIZE值3000,这个探测报文因为超过了R1的下一跳MTU,R1返回了PTB报文。所以,最终探测到的PLPMTU为2000。

image-20250411003640639

ngtcp2的实现

实现细节都在https://github.com/ngtcp2/ngtcp2/blob/v1.11.0/lib/ngtcp2_pmtud.c中了

  • 预定义探测序列

    代码中硬编码了分层探测的MTU值列表(已扣除48字节头部开销):

    1
    2
    3
    4
    5
    6
    static size_t mtu_probes[] = {
    1454 - 48, // 日本光纤常见MTU
    1390 - 48, // 典型隧道MTU
    1280 - 48, // IPv6最小MTU
    1492 - 48 // PPPoE MTU
    };

    这些值按从大到小排列,作为探测目标。

  • 初始化筛选

    ngtcp2_pmtud_new()中:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 跳过所有小于当前max_udp_payload_size 或超过hard_max的值
    for (; pmtud->mtu_idx < NGTCP2_MTU_PROBESLEN; ++pmtud->mtu_idx) {
    if (mtu_probes[pmtud->mtu_idx] > pmtud->hard_max_udp_payload_size) {
    continue;
    }
    if (mtu_probes[pmtud->mtu_idx] > pmtud->max_udp_payload_size) {
    break; // 找到第一个有效探测目标
    }
    }

​ 初始化时直接跳过无效值,确保首次探测值既在允许范围内,又比当前MTU大。

  • 探测推进策略

    pmtud_next_probe()中实现核心调整逻辑:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    ++pmtud->mtu_idx; // 指向下一个候选MTU
    for (; pmtud->mtu_idx < NGTCP2_MTU_PROBESLEN; ++pmtud->mtu_idx) {
    // 条件1: 必须大于当前有效MTU
    // 条件2: 不能超过hard上限
    // 条件3: 必须小于已记录的最小失败MTU(若有)
    if (mtu_probes[pmtud->mtu_idx] <= pmtud->max_udp_payload_size ||
    mtu_probes[pmtud->mtu_idx] > pmtud->hard_max_udp_payload_size) {
    continue;
    }
    if (mtu_probes[pmtud->mtu_idx] < pmtud->min_fail_udp_payload_size) {
    break; // 找到有效候选
    }
    }

    该循环会跳过以下情况:

    • 候选MTU ≤ 当前已确认的最大有效MTU
    • 候选MTU超过协议规定的绝对上限 (hard_max)
    • 候选MTU ≥ 之前记录的最小失败MTU值(避免无效重试)
  • 探测反馈处理

    • 成功时 (ngtcp2_pmtud_probe_success())

      1
      2
      3
      4
      5
      // 提升当前最大有效MTU
      pmtud->max_udp_payload_size =
      ngtcp2_max(pmtud->max_udp_payload_size, payloadlen);
      // 推进到下一个更大的候选MTU
      pmtud_next_probe(pmtud);

      若探测包成功到达,立即更新最大有效值,并尝试更大的MTU。

    • 超时失败时(ngtcp2_pmtud_handle_expiry())

      1
      2
      3
      4
      5
      // 记录当前探测值为最小失败MTU
      pmtud->min_fail_udp_payload_size =
      ngtcp2_min(pmtud->min_fail_udp_payload_size, mtu_probes[pmtud->mtu_idx]);
      // 推进到更小的候选MTU
      pmtud_next_probe(pmtud);

      当连续发送3个探测包(NGTCP2_PMTUD_PROBE_NUM_MAX)均超时,标记该MTU不可用,并尝试更小的值。

  • 终止条件

    当遍历完所有预定义的mtu_probes值时,探测结束:

    1
    2
    3
    int ngtcp2_pmtud_finished(ngtcp2_pmtud *pmtud) {
    return pmtud->mtu_idx >= NGTCP2_MTU_PROBESLEN;
    }
ngtcp2的日志验证

编译https://github.com/tedcy/nghttp3_simple_demo/tree/epoll_with_taf_http_dev_dynamic_build_hide_boringssl

并运行访问本地的go_server

1
2
3
4
5
6
~ ./test localhost:4433 2>&1|grep -i 'mtu\|udp\|QUIC handshake'
I00000014 0x0c0a06116c30a8b072e02a9a9f4c6b4753 cry remote transport_parameters max_udp_payload_size=65527
QUIC handshake has completed
I00000015 0x0c0a06116c30a8b072e02a9a9f4c6b4753 con sending PMTUD probe packet len=1406
QUIC handshake has been confirmed
I00000026 0x0c0a06116c30a8b072e02a9a9f4c6b4753 con sending PMTUD probe packet len=1444

可以看到,quic在建连以后继续发包验证MTU,最后定到了1444,也就是mtu_probes的最后一项1492 - 48

QUIC连接

握手

TLS1.3 首次握手

参考这个:https://zhuanlan.zhihu.com/p/686461033,我进行简单总结:

TLS1.2虽然足够安全,但是需要四次握手,主要是为了协商算法,并且多次交互用证书协商密钥

因此出现了TLS1.3:只支持唯一的 DH 算法协定密钥,不再需要协商算法,密钥由一个随机数和单次交互的签名算法生成

因此服务端在发送了证书链Certificate报文后还需要发送Certificate Verify报文来校验密钥,总流程如下(其中红色箭头表示传输的都是加密数据):

简单来说:

  • ClientHello和Server Hello包含了各自的签名所需随机数

  • EncryptedExtensions消息携带可以加密的扩展

  • Certificate消息包含服务器的证书或者证书链

  • CertificateVerify消息中是签名的算法和签名的内容

    简化了密钥协商后,TLS 握手阶段,服务端需给客户端发送证书(含公钥),但服务器的证书是“公开”信息,任何人都能拿到。

    光有证书根本不足以证明对方真的拥有对应证书的私钥,也就无法证明该方“真的是”证书所描述的目标服务器。

    为此TLS 协议设计了Certificate Verify报文。

    要求服务端使用自己服务器私钥对指定内容签名。客户端接收后,用证书里的公钥解密、核实。

    若解密校验通过,即证明该服务端真的拥有证书相应的私钥,从而确认服务端身份确实为该证书的合法拥有者。

  • Finished消息用于校验握手内容完整性和有效性,确保双方握手数据一致

    使用传输秘钥(握手过程中生成的密钥)对之前所有握手消息的Hash进行HMAC计算。

    1
    Finished = HMAC(handshake_traffic_secret, Hash(握手过程中所有之前的消息))
QUIC 首次握手

总览如下:

image-20250422232948255

细节如下:

客户端初始报文

客户端首先发送一个初始报文给服务器,请求建立连接。初始报文中,首字节中第一位是首部格式位,长首部报文为1;第二位是QUIC固定位,值固定为1;第三位和第四位是长首部报文类型位,初始报文类型是00;第五位和第六位是保留位,固定为0;第七位和第八位是报文编号长度字段,因为这是初始报文编号空间的第一个报文,所以报文编号为0,这两位就是00。综上所述,首字节的值为二进制11000000,对应十六进制0xc0,注意这是明文值,后四位会被首部保护变成密文。接下来的32位是版本字段,版本1使用值0x1;再往下8位是目标连接标识长度(以字节为单位),目标连接标识是客户端随机选择的值,简单起见这里记为x,用来生成加密密钥和验证服务器的回复;再后面是8位的源连接标识长度(以字节为单位),这是源连接标识的字节长度,源连接标识是客户端为该连接选定的标识,为简单起见这里记为c1;首次建立连接没有令牌信息,所以令牌长度经过变长编码后占8位,值为0;接下来的报文长度字段指明了这个初始报文剩余部分的字节长度,包含报文编号和负载加密后的长度;因为最后的报文编号是初始报文编号空间的第一个报文,所以经过变长编码后占8位,值为0。

客户端的第一个初始报文包含一个CRYPTO帧,该帧的偏移从0开始,内容是TLS提供的ClientHello消息,其中包含了QUIC的传输参数,传输参数initial_source_connection_id设置为c1。服务器收到客户端的初始报文后,需要按照限制放大攻击的长度回复。为了防止服务器没有足够的长度回复初始报文,另外也为了探测路径是否支持QUIC最低的MTU要求,这个初始报文需要填充至1200字节,所以增加了PADDING帧。CRYPTO帧和PADDING帧作为报文的负载受到AEAD的保护。

这个初始报文使用客户端初始报文的目的连接标识

服务端初始报文

收到客户端的初始报文后,服务器回复一个初始报文和数个握手报文,还有可能有一个1-RTT报文,但在对客户端一无所知的情况下发送1-RTT报文是不安全的,所以本例中只有一个初始报文和一个握手报文。

服务器的初始报文中首字节仍然是0xc0:版本号也是1;目的连接标识是客户端初始报文中的源连接标识c1;源连接标识则是服务器选择的值s1;服务器不能在初始报文中发送令牌,所以这里的令牌长度必须是0;接下来是这个初始报文剩下部分的字节长度;作为服务器在初始报文编号空间发送的第一个初始报文,这里的报文编号是0;负载部分包含CRYPTO帧和ACK帧,CRYPTO帧携带了TLS的ServerHello消息,偏移从0开始,ACK帧确认了客户端的初始报文。

这个服务器初始报文使用客户端初始报文的目的连接标识x衍生的密钥保护。

服务器握手报文

服务器的握手报文首字节是0xf0,跟初始报文的首字节只有报文类型不同;版本号也是1;目的连接标识也是c1,源连接标识也是服务器选择的值s1;长度也是报文剩余字节长度;作为握手编号空间的第一个报文,报文编号也是0。

报文负载中的CRYPTO帧携带了TLS的几个消息:EE指的是EncryptedExtensions消息,CERT指的是Certificate消息,CV指的是CertificateVerify消息,FIN指的是Finished消息。QUIC将自己的传输参数传递给TLS, TLS将其包含在EncryptedExtensions消息中,这其中包含了用于验证服务器初始源连接标识的传输参数initial_source_connection_id,以及用于验证客户端初始目的连接标识的传输参数original_destination_connection_id。

这个握手报文使用TLS协商的握手密钥衍生的密钥保护。

客户端确认初始报文

客户端收到服务器的初始报文后,还需要回复一个初始报文以确认服务器的初始报文。这个初始报文受第一个初始报文一样的密钥的保护,但目的连接标识使用服务器选择的值s1,报文编号是1,首部中其余字段跟第一个初始报文一样。这个初始报文携带了一个ACK帧,确认了服务器报文编号为0的初始报文。

客户端握手报文和1-RTT报文

客户端收到服务器的握手报文后,验证完服务器的证书和Finished消息,就会发送握手报文,其中携带了TLS提供的Finished消息。除此之外,这个握手报文中还要确认服务器的握手报文,所以还包含一个ACK帧,确认了服务器报文编号为0的握手报文。

此时客户端已经从初始报文中得到加密参数并计算出密钥,通过握手报文中Certificate消息和CertificateVerify消息验证了服务器的身份,且通过握手报文中的Finished消息验证了整个握手过程,可以发送1-RTT报文了。

服务器握手报文和1-RTT报文

服务器收到客户端的握手报文后,需要确认该握手报文,所以还需要回复一个报文编号为1的握手报文,携带了确认报文编号为0的ACK帧。服务器验证完客户端的握手报文,即客户端的Finished消息,认为握手完成并且已确认,在1-RTT报文中发送HANDSHAKE_DONE帧,通知客户端握手流程已经结束。

断开连接

空闲超时

如果一个连接上长时间没有网络活动,既没有发送报文的需求,也没有接收到对端的报文,连接就会因为空闲超时而关闭。

空闲超时时间是由连接建立时QUIC传输参数max_idle_timeout确定的,空闲超时时间是两端选择的max_idle_timeout的最小值。

image-20250423093526990

立即关闭

发送CONNECTION_CLOSE帧立即关闭连接。

  • 端点发送CONNECTION_CLOSE帧后就进入了关闭状态

    关闭状态下不能再发送出CONNECTION_CLOSE帧以外的其他帧,如果收到了对端的报文,就使用包含CONNECTION_CLOSE帧的报文回应。

  • 端点接收到CONNECTION_CLOSE帧后就进入了排空状态

    不能再发送任何报文,也不能回复任何报文,可以删除1-RTT密钥了。被动关闭的端点不经过关闭状态,直接进入排空状态。

image-20250423093909343

无状态重置

连接建立后,分发新的连接标识时,在NEW_CONNECTION_ID帧中携带连接标识对应的无状态重置令牌,连接标识c2对应无状态重置令牌token_c2,连接标识s2对应无状态重置令牌token_s2。

image-20250423100106225

如果连接的一端因为某些原因丢失了连接状态,收到对端发送的报文就可以回复状态重置报文。这可以让对端尽快清理旧的连接、建立新的连接。

image-20250423100124539

实现

ngtcp2的example里面,ngtcp2_callbacks的recv_stateless_reset默认是不填的,也就是不实现无状态重置报文

quic-go的quic协议栈是支持无状态重置的,https://github.com/quic-go/quic-go/blob/v0.49.0/transport.go#L83

1
2
3
4
5
6
7
8
9
10
11
12
type Transport struct {
...省略

// The StatelessResetKey is used to generate stateless reset tokens.
// If no key is configured, sending of stateless resets is disabled.
// It is highly recommended to configure a stateless reset key, as stateless resets
// allow the peer to quickly recover from crashes and reboots of this node.
// See section 10.3 of RFC 9000 for details.
StatelessResetKey *StatelessResetKey

...省略
}

但是github.com/quic-go/quic-go/http3创建http3客户端的时候只能操作quic.Config,无法操作quic.Transport,因此需要直接修改quic-go源码才能加上无状态重置

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
func main() {
// 设置一个简单的 HTTP Handler
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(time.Second * 2)
fmt.Fprintf(w, "Hello, World")
})

// 配置 TLS 证书(HTTP/3 必须使用 TLS)
certFile := "server.crt" // 替换为实际的证书文件路径
keyFile := "server.key" // 替换为实际的私钥文件路径

// 定义一个自定义的 http3.Server
server := &http3.Server{
Addr: ":4433",
Handler: http.DefaultServeMux, // 指定处理器
QUICConfig: &quic.Config{
MaxIdleTimeout: time.Second * 3,
MaxIncomingStreams: 50,
},
}

// 启动 HTTP/3 服务器
log.Println("Starting HTTP/3 server on :4433...")
err := server.ListenAndServeTLS(certFile, keyFile)
if err != nil {
log.Fatalf("Failed to start HTTP/3 server: %v", err)
}
}

QUIC收发包

流控

流控是保护端点,而拥塞控制则是保护网络。

在发送方发送太快的情况下,接收方的缓存可能被占满,接收方只能丢弃后续收到的报文,白白浪费了网络资源,这一般是通过流控解决的。流控还能防止发送方过量消耗接收方的缓存,影响其他网络连接,甚至导致接收方崩溃。

TCP的流控机制是用滑动窗口实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
No.| Direction   | Flags      | Window(RV) | Payload | Note
---|------------ |------------|------------|---------|-----------------------------------------------------|
1 | C → S | ACK | 8192 | 0 | 初始窗口8192字节
2 | S → C | PSH,ACK | 65535 | 2000 | 服务器发2000字节数据
3 | C → S | ACK | 6192 | 0 | 客户端窗口8192-2000=6192
4 | S → C | PSH,ACK | 65535 | 6000 | 服务器再发6000字节 (此时客户端缓冲区将满)
5 | C → S | ACK | 192 | 0 | 客户端窗口6192-6000=192
6 | S → C | PSH,ACK | 65535 | 192 | 服务器发送192字节,恰好填满客户端缓冲区
7 | C → S | ACK | 0 | 0 | Client收满,返回窗口=0,服务器停止发送 (零窗口)
8 | C → S | ACK | 0 | 0 | 零窗口持续
9 | S → C | ACK(ZWP) | 65535 | 1 | 此时发送方TCP停止正常数据发送,偶尔发起零窗口探测(ZWP: Zero Window Probe)的数据包(1字节数据或空ACK包来“探测”)
10 | C → S | ACK | 0 | 0 | Client回复确认 窗口仍为0
11 | C → S | ACK | 4000 | 0 | 客户端上层应用将数据读走,窗口重新开放4000字节
12 | S → C | PSH,ACK | 65535 | 1000 | 服务器收到窗口开放信号,继续发送后续数据
···| ··· | ··· | ··· | ··· | 后续继续正常收发

不同于TCP的滑动窗口流控机制,QUIC是基于offset限制的流控,由接收方发送总字节数的限制,发送方遵循该限制发送流数据。

假设初始状态:

  • 接收方的流最大接收数据上限为 5000 字节;
  • 发送方目前已发送数据:4000 字节
步骤 方向 帧类型 offset值 数据长度(len) 当前流控上限 状态说明
1 发送方→接收方 STREAM 4000 1000 5000 发送数据后达到流控上限,后续数据暂停发送
2 发送方→接收方 STREAM_DATA_BLOCKED 5000 - 5000 告知对端发送方已达到流控上限,无法再发送数据
3 接收方→发送方 (暂无动作) - - 5000 应用暂未读取数据,缓冲区未释放
4 发送方→接收方 STREAM_DATA_BLOCKED (周期发送/重传) 5000 - 5000 周期性告知对方仍处于流控阻塞状态,避免连接超时
5 - 接收方应用读取数据 - - 5000→8000 应用层读取已接收的数据,释放3000字节缓冲区空间
6 接收方→发送方 MAX_STREAM_DATA (+ACK帧合并发送) - - 8000 告知发送方流控上限提高,可以继续发送数据
7 发送方→接收方 STREAM 5000 2000 8000 流控限制解除,发送方继续发送后续数据
8 发送方→接收方 STREAM 7000 1000 8000 继续发送更多数据,逐步接近新流控上限
9 接收方→发送方 ACK - - 8000 确认数据已收到,告知发送方继续正常通信

连接的流控也是同理,但是使用MAX_DATADATA_BLOCKED

并发数流控

初次以外,quic还存在最大并发数的流控

连接级别的流控在连接建立期间,通过传输参数initial_max_data通知对端初始连接级别流控限制,通过传输参数initial_max_streams_bidi(接收方创建的双向流总数)、initial_max_streams_uni(接收方创建的单向流总数)通知对端可以创建的相应流的总数。

在连接建立后,通过MAX_STREAMS帧增加打开流的总数量限制。单向流和双向流的总数分别控制,单向流使用帧类型为0x13的MAX_STREAMS帧,而双向流使用帧类型为0x12的MAX_STREAMS帧。

因为流的总数限制包含了关闭的流,所以随着流的关闭,流的总数量限制需要随之增加,一般是在流关闭后增加。

实现

在ngtcp2的example里面,ngtcp2_callbacks的extend_max_streams_bidi标识着对端更新了流上限,可以在此时发起新的双向流了

https://github.com/ngtcp2/ngtcp2/blob/v0.15.0/examples/client.cc#L447

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
31
32
33
34
35
36
37
int extend_max_streams_bidi(ngtcp2_conn *conn, uint64_t max_streams,
void *user_data) {
//通过回调的user_data指针找到Client类实例
auto c = static_cast<Client *>(user_data);

if (c->on_extend_max_streams() != 0) {
return NGTCP2_ERR_CALLBACK_FAILURE;
}

return 0;
}

int Client::on_extend_max_streams() {
int64_t stream_id;

for (; nstreams_done_ < config.nstreams; ++nstreams_done_) {
//打开新流
if (auto rv = ngtcp2_conn_open_bidi_stream(conn_, &stream_id, nullptr);
rv != 0) {
//打开失败直接退出
assert(NGTCP2_ERR_STREAM_ID_BLOCKED == rv);
break;
}

//创建本地的流实例
auto stream = std::make_unique<Stream>(
config.requests[nstreams_done_ % config.requests.size()], stream_id);

//向流提交http数据
if (submit_http_request(stream.get()) != 0) {
break;
}

streams_.emplace(stream_id, std::move(stream));
}
return 0;
}

在我的http3客户端实现里面https://github.com/tedcy/nghttp3_simple_demo/blob/epoll_with_taf_http_dev_dynamic_build_hide_boringssl/client.h

request被提交到quic的client里面

1
2
3
void push_request(shared_ptr<taf::TC_HttpRequest> &req) {
requests_.push_back(req);
}

随后通过在epoll的空闲时间内,尝试使用requests_创建新流,来复用创建流的逻辑:

只要有新增的requests_和对端通过MAX_STREAMS帧更新了最大流限制,都尝试创建流

流关闭

正常关闭

流的发送方在STREAM帧中设置FIN位为1,表示该流上数据已发送完毕,一般正常的流关闭都采用这种方式

image-20250423094801551

异常关闭

流的发送方在流上发送RESET_STREAM帧,表示终止发送

流的接收方也可以主动请求关闭流,一般是由于应用出现错误,通知QUIC关闭流的接收,这时接收方通过发送STOP_SENDING帧告诉发送方不会再读取流上的数据,并在应用层错误码中携带具体原因。发送方应该回复RESET_STREAM帧,携带收到STOP_SENDING帧中的应用层错误码,并告知接收方当前发送的最大数据偏移,这样两端可以对数据发送和接收有一致的状态,对于连接级别的流控也有一致的处理

image-20250423094823968

附录

HTTP3细节实现

类图

classDiagram
class TC_HttpConnKey {
    string proxyAddr
    int proxyPort
    string targetAddr
    int targetPort
    bool isHttps
}
class HttpConnInterface {
    +uint64_t getId()
}
class Timer {
    +pure virtual void onTimeout()
}
class HttpAsyncRequstInterface {
    +pure virtual void onRsp()
    +pure virtual void onException()
}
class Http1AsyncRequst {
    +void BindConn(Http1Conn*)
}
class Http1Conn {
    -shared_ptr~Http1AsyncRequst~ reqPtr
    +void setReqPtr(shared_ptr<Http1AsyncRequst>)
}
class Http1ConnPool {
    +void asyncGetConn(TC_HttpConnKey, shared_ptr~Http1AsyncRequst~)
    +void idleFunc()
    -map&lt;TC_HttpConnKey, TC_LRU&lt;unordered_map, uint64_t&gt;&gt; pool
}
class Http3AsyncRequst {
    +void BindConn(Http3Conn*)
}
class Http3AsyncRequstInterface {
    +int64_t stream_id
}
class Http3Conn {
    -list~shared_ptr~Http1AsyncRequst~~ reqPtrs
    +Http3Conn(Epoller, TimeoutQueue)
    +void push_requests(shared_ptr~Http3AsyncRequst~)
    +void check_pushed_request()
}
class Http3ConnPool {
    +void asyncGetConn(TC_HttpConnKey, shared_ptr~Http3AsyncRequst~)
    +void idleFunc()
    -map~TC_HttpConnKey, uint64_t~ pool
}
class TimeoutQueue {
    +void push_timer(shared_ptr~HttpTimer~ timer)
}
class EventLoop {
    -Epoller poll;
    -TimeoutQueue q;
    +void run()
}
class HttpAsync {
    +void doAsyncHttp1Request()
    +void doAsyncHttp3Requst()
}
Http3ConnPool ..> Http3Conn
Http3Conn ..> Http3AsyncRequst
Http3Conn ..|> HttpConnInterface
Http3AsyncRequst --|> HttpAsyncRequstBase
Http3AsyncRequst --|> Http3AsyncRequstInterface

Http1ConnPool ..> Http1Conn
Http1Conn ..> Http1AsyncRequst
Http1Conn ..|> HttpConnInterface
Http1AsyncRequst --|> HttpAsyncRequstBase

HttpAsyncRequstBase ..|> Timer
HttpAsyncRequstBase ..|> HttpAsyncRequstInterface
Http3Conn ..|> Timer
EventLoop ..> Timer
EventLoop ..> TimeoutQueue
Http3Conn ..> TimeoutQueue
HttpAsync ..> EventLoop
HttpAsync ..> Http1ConnPool
HttpAsync ..> Http3ConnPool

隔离HTTP1Req类和HTTP3Req类的独有成员

由于HTTP1和HTTP3的Conn都继承自HttpConnectionInterface这个Interface,HttpConnectionInterface的push_requests函数只能接受最基础的HttpAsyncRequstInterface

因此传入的Http3AsyncRequst类在变成HttpAsyncRequstInterface接口类后,需要通过dynamic_cast强制转换成Http3AsyncRequstInterface类,以获取到stream_id这个对HTTP1的Req类不透明的成员

细化HTTPReq和HTTPConn类图
classDiagram
    class HttpConnectionInterface {
        <<interface>>
        +push_requests(shared_ptr~HttpAsyncRequestInterface~ req)
    }
    class HttpAsyncRequestInterface {
        <<interface>>
        +onRsp()
        +onException()
    }
    class HttpAsyncRequestBase {
        +onRsp()
        +onException()
    }
    class Http1AsyncRequest {
        +BindConn(Http1Conn*)
    }
    class Http3AsyncRequestInterface {
        <<interface>>
        +int64_t stream_id
        +static Http3AsyncRequestInterface* cast(req)
    }
    class Http3AsyncRequest {
        +BindConn(Http3Conn*)
    }
    class Http1Conn {
        +push_requests(shared_ptr~HttpAsyncRequestInterface~ req)
    }
    class Http3Conn {
        +push_requests(shared_ptr~HttpAsyncRequestInterface~ req)
        +processRequests()
    }
    HttpConnectionInterface <|-- Http1Conn
    HttpConnectionInterface <|-- Http3Conn

    HttpAsyncRequestInterface <|.. HttpAsyncRequestBase
    HttpAsyncRequestBase <|-- Http1AsyncRequest
    HttpAsyncRequestBase <|-- Http3AsyncRequest

    Http3AsyncRequestInterface <|.. Http3AsyncRequest

    Http3Conn ..> Http3AsyncRequestInterface

    note for HttpConnectionInterface "push_requests函数只能接受HttpAsyncRequestInterface"
    note for Http3Conn "push_requests函数内通过dynamic_cast<br>获取Http3AsyncRequestInterface,以访问Http3独有成员"
    note for Http3AsyncRequestInterface "包含Http3独有成员,如stream_id"
接口头文件核心代码
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
// 基础异步请求接口
class HttpAsyncRequstInterface {
public:
virtual ~HttpAsyncRequstInterface() = default;
virtual void onRsp() = 0;
virtual void onException() = 0;
};

// Http3 特有的异步请求接口
class Http3AsyncRequstInterface {
public:
virtual ~Http3AsyncRequstInterface() = default;
int64_t stream_id = -1;
static Http3AsyncRequstInterface* cast(HttpAsyncRequstInterface* req) {
return dynamic_cast<Http3AsyncRequstInterface*>(req);
}
};

// 基础异步请求基类
class HttpAsyncRequstBase : public HttpAsyncRequstInterface {
// 实现 onRsp 和 onException
};

// Http3 异步请求类,继承自 HttpAsyncRequstBase
class Http1AsyncRequst : public HttpAsyncRequstBase {
};

// Http3 异步请求类,继承自 HttpAsyncRequstBase 和 Http3AsyncRequstInterface
class Http3AsyncRequst : public HttpAsyncRequstBase, public Http3AsyncRequstInterface {
};
动态库核心代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Http3 连接类
class Http3Conn {
public:
void push_requests(std::shared_ptr<HttpAsyncRequstInterface> req) {
// 存储请求
reqPtrs.push_back(req);
}

void processRequests() {
for (auto& req : reqPtrs) {
if (auto http3Req = Http3AsyncRequstInterface::cast(req.get())) {
// 访问 Http3 特有的数据
int64_t sid = http3Req->stream_id;
// 处理 Http3 请求
}
}
}

private:
std::list<std::shared_ptr<HttpAsyncRequstInterface>> reqPtrs;
};