从0搭建博客:HTTP2
发布于:
## 前言 最近给自己放了个长假。原本打算利用这此长假去徒步川西进行场雪山冰川旅的,但是看了下小红书发现...四姑娘山 热 秃 顶 了! 达古冰川 热 没 了! 只好明年春天见呐 > 等我换了a7cR+2470f2.8狠狠拍死你们 那这段时间就用来改进博客吧! ## Why Http2? 为什么要http2? 问我鸭?我只是找了点有挑战的事情做。 如果你是想要实现http2而点进来的人,我想你心中应该有了答案。 是看中了HTTP2的多路复用,还是觉得头压缩能减少宽带浪费?或者是觉得服务器推送非常非常炫! 或者你什么都不知道就点进来了,那就看看我的梳理也行。 ### 多路复用! Http2的多路复用允许一个链接上并发的请求服务器上的资源.. 一个典型的问题是:Http1.x的 Connection: keep-alive 和这个有什么区别? 答案是Http1.x的keep alive仅仅是复用链接,在其上的请求处理必须是顺序的 比如窝发送了一个请求,请求Host: feiqi3.cn 的Path / ,网页上同时也有两个js /a.js /b.js 对于http1.x,浏览器会在请求’/‘并且收到回复后 发送第二个请求 a.js,收到a.js回复后 请求b.js 这三个请求的Request/Response是顺序的,在时间上不会有重叠冲突  对于http2,他会首先请求 "/" ,收到回复后发现还需要请求 "a.js" "b.js",于是浏览器会同时发送这些请求。服务器收到请求后可以同时处理,并且把所有请求的结果一起发送,所有事情一个TCP链接就够了。  这些请求/回复能够并发处理的原因在于,http2引入了frame机制,使得这些数据能够交错有序的发送/接收 > 所以如果够厉害的话,可以做一个多线程并发处理HTTP2请求的服务器~ > 很神奇的是,虽然http1.x支持keep-connection,但是我发现只有firefox最大化利用了这一项技术 > 像chrome的safari都倾向于新启动tcp connection来获请求资源 > 我是怎么知道的呢...Firefox打不开网页,结果发现是我自己没处理好QAQ > 火狐狸赛高 ### 头压缩! 这玩意儿英文叫HPACK,看到的时候不要陌生啊 咱都知道Http1.x有gzip压缩,但是那东西只能用在body上。 要对巨长无比的headers进行压缩,Http2发明了HPACK机制。 Http2创建了一张索引表,对着索引表开查。 一个链接上的所有请求头,都会有字段“:method” ":path",那么就把这些字段放入头表中。 接下来有请求头用到了,发送方用表中索引代替,接收方查表还原。 这个头表有静态的部分(比如"GET","POST"),也有动态调整的部分(比如不断变化的请求路径);头表在链接两端是同步的。 ### 服务器推送! 服务器可以通过Push Promise来提前给客户端发送数据! 这项功能允许服务器在客户端实际请求之前“推送”客户端可能需要的资源。 服务器可以提前准备好一个请求以及相应的回应,将其发送给客户端已达到提前加载的目的: 比如 服务端准备好了: “我是aaa 要bbb”,以及bbb的数据,提前发送给客户端;客户端在之后如果要发送请求“我是aaa 要bbb”,bbb的数据就会从客户端缓存中取出。 听起来很美好,但是窝实测下来 chrome 和 safari不支持push promise firefox 支持 这是为什么呢,请看[大屏幕](https://groups.google.com/a/chromium.org/g/blink-dev/c/K3rYLvmQUBY?utm_source=chatgpt.com) TLDR; 这是Chromium开发者在决定关闭push promise前的讨论,并且直接导致了Chrome 106后这项功能的禁用。 > 我tm在实现好了push promise功能后才发现这个事情,!!!! ## Http2协议简介 在这一章,我按照顺序讲解 http2 的一些规则。 ### ALPN 比较通用的来讲:HTTP2只会在TLS环境下生效。 在建立TLS握手的过程中,会发生一个叫ALPN的过程,来决定服务器/客户端是否要启用H2的支持。 ALPN全名叫Application-Layer Protocol Negotiation,是用来协商应用层协议的。  ### HTTP2的Session/Stream/Frame机制 一个TCP链接只有一个Http2 Session。 一个Session可以启动很多Stream,而stream的数据是以frame为单位进行传输的。 而Http的request/response就存在于stream上。 frame是数据的载体,他可以承载不同的数据,以streamId和frameType来区分。 ----- 举一个栗子: 浏览器 发送了一个 GET请求feiqi3.cn的 / 路径 实际上是先启动了一个TCP链接,在经过TLS握手/ALPN后建立了HTTP2 Session 然后创建了Stream[1],并且在这个Stream里发送了Http Request。 这个Request会分成两部分发送,Headers Frame和Data Frame,Headers Frame里只包含了请求头,Data Frame则包含请求数据 服务器收到stream[1]的带“END_STREAM”标识的Frame后开始处理,然后把response发回stream[1]的对端,同样也分成了Headers Frame和Data Frame,Data Stream上带着“END_STREAM”标识,代表该stream上数据发送完成。 ------- Stream ID有个简单的约定:奇数号码是客户端启动的,偶数号码是服务端启动的 Stream ID = 0 代表无效或者有特殊用途 ------- Frame Type除了上文提到的Headers和Data还有些有着特殊用处的frame,通常是用来控制链接的 比如说: - SETTINGS -- 关于Session的设置 - RST_STREAM -- 代表发送方关闭这个stream - PUSH_PROMISE -- 服务端的push promise机制相关,包含了一个伪请求 - PING -- 保活用 - GOAWAY -- 说明发送端不再接收新的的stream,这个帧里会包含一个last id,代表最后接收到的stream号 - WINDOW_UPDATE -- Http2有一套滑动窗口系统用来控制发送数据数量 - CONTINUATION -- 就是frame里的分片机制,如果Frame承载的数据太大,就会拆成好几个CONTINUATION Frame ### HTTP2 Connection Preface Http2链接前奏,如果处理不好,有些浏览器就会直接罢工(就是你,safari),或者发生奇奇怪怪的事情,比如打开突然会闪现出“链接丢失”(chrome,firefox),然后恢复正常。 客户端发起的链接,其preface包括: 发送一个Magic 和接着的 一个Setting帧。 服务端的preface包括: 一个setting帧,以及对之前接收到客户端setting帧的ACK。  如图:服务端发送出去了俩setting帧,但是仔细看,第一个setting帧是真正做设置的;第二个setting帧是空的,但是有个ACK标志,这是对之前客户端setting帧的回复。 但不论如何:服务端的第一帧 都必须是 setting帧!!!!! 而且ACK setting帧必须跟在后面。(服务端发送的可以是空setting帧) > 摘录自 rfc9113 3.4 HTTP/2 Connection Preface > > The server connection preface consists of a potentially empty SETTINGS frame (Section 6.5) that MUST be the first frame the server sends in the HTTP/2 connection. > > The SETTINGS frames received from a peer as part of the connection preface MUST be acknowledged (see Section 6.5.3) after sending the connection preface. ### HTTP2 的头部 HTTP2 所有header的name都得是小写。 HTTP2 相比HTTP1.x还有个不同就是,http2用伪头来标识Host,path,method,而非单独的一行。 伪头(persudo head),指的是那些以 ":" 开头的header name 一个客户端的请求一般包含 ":method" 方法 ":authority" 原来的host ":scheme" 这东西一般来说value只能是"https" ":path" 请求路径 一个服务端的响应一般包含: ":status" 状态码 如此就足够了 ## HTTP2 服务器实现! 在这里我用了nghttp2来处理具体的http2链接相关。 > 我知道大家都喜欢造轮子,但是http2的数据处理...还是别折磨自己了吧 如果实在顶不住可以对着[我的代码](https://github.com/feiqi3/BlogServer/blob/main/FeiLib/src/Http/FHttp2Helper.cpp)看看嘛 只有1k行,还是很好懂的~ ### NGHTTP2 一个Http2的C库,[仓库](https://github.com/nghttp2/nghttp2) NGHTTP2基本上是基于回调驱动的 各种回调的参数用法看[文档](https://nghttp2.org/documentation/)! ### ALPN设置 在OpenSSL中,可以设置ALPN的回调。 ```cxx static int alpn_select_proto_cb(SSL* ssl, const unsigned char** out, unsigned char* outlen, const unsigned char* in, unsigned int inlen, void* arg) { int rv; rv = nghttp2_select_alpn(out, outlen, in, inlen); auto sslEnv = Fei::FSSLEnv::instance(); if(!sslEnv->preferH2()){ return SSL_TLSEXT_ERR_NOACK; } if (rv == -1) { return SSL_TLSEXT_ERR_NOACK; } return SSL_TLSEXT_ERR_OK; } SSL_CTX_set_alpn_select_cb((SSL_CTX*)SSLContext, alpn_select_proto_cb, NULL); ``` 通过调用nghttp2_select_alpn来选择到http2。 如果客户端发送了ALPN,那么服务器ALPN回调这里的“in”就会是: h2 http1.1 如果选择了h2,那么 *out = "h2" , *outlen = 2,并且返回SSL_TLSEXT_ERR_OK。 如果选择http1.1,*out = "http1.1" *outlen = 7。 或者直接和我一样返回SSL_TLSEXT_ERR_NOACK得了。 然后可以这样知道是否开启了H2 ``` const unsigned char* alpn = NULL; unsigned int alpnlen = 0; SSL_get0_alpn_selected(ssl, &alpn, &alpnlen); if (alpn && alpnlen == 2 && memcmp("h2", alpn, 2) == 0) { dp->mIsH2 = true; } ``` 现在Http2已经启用啦!  ### 创建和数据的接收和发送 nghttp2是通过回调来驱动的,所以大概的流程就是:创建上下文,绑定callback,喂数据发数据,然后让它自己动! 通过`int nghttp2_session_callbacks_new(nghttp2_session_callbacks **callbacks_ptr);`创建一个回调上下文, 再通过一堆`nghttp2_session_callbacks_set_*`绑定回调。 最后创建http2上下文绑定之前的回调上下文`int nghttp2_session_server_new(nghttp2_session **session_ptr,const nghttp2_session_callbacks *callbacks,void *user_data)`。 我的代码如下: ```cxx nghttp2_session_callbacks_new(&cb); nghttp2_session_callbacks_set_on_frame_recv_callback(cb, Http2Callbacks::on_frame_recv_callback); nghttp2_session_callbacks_set_on_header_callback(cb, Http2Callbacks::on_header_callback); nghttp2_session_callbacks_set_on_stream_close_callback(cb, Http2Callbacks::on_stream_close_callback); nghttp2_session_callbacks_set_on_begin_headers_callback(cb, Http2Callbacks::on_begin_headers_callback); nghttp2_session_callbacks_set_on_data_chunk_recv_callback(cb, Http2Callbacks::on_data_chunk_recv_callback); nghttp2_session_server_new(&session, cb, &sessionData); ``` 之后我会讲解每个回调的~ nghttp2通过函数`int nghttp2_session_want_read(nghttp2_session *session)`获取当前http2上下文的状态是不是需要读取数据。 如果函数返回值大于0,说明需要把数据喂进去。 nghttp2通过`nghttp2_session_mem_recv2`来接受数据。 也可以通过函数`nghttp2_session_recv`来接收数据,这个函数需要绑定一个回调,回调的作用是把接收到的数据拷贝到参数给的buffer里去,简单来说就是只用`nghttp2_session_mem_recv2`已经够了... ```cxx uint32_t FHttp2Context::http2RecvProcess(FBufferReader& reader) { auto session = mDp->session; uint32_t recvLen = 0; .... //........Other code...............// while(nghttp2_session_want_read(session) ){ int peakNum = 0; auto dataPtr = reader.peekAll(peakNum); if(peakNum == 0)break; auto curRecvLen = nghttp2_session_mem_recv2(mDp->session,(uint8_t*)dataPtr, peakNum); if (curRecvLen < 0) { Logger::instance()->log(lvl::err, MODULE_NAME "Http2 Recv Process Error: {}", nghttp2_strerror(curRecvLen)); break; } recvLen += curRecvLen; reader.expireSize(curRecvLen); } return recvLen; } ``` 发送也差不多,`ssize_t nghttp2_session_mem_send2(nghttp2_session *session, const uint8_t **data_ptr)`会返回一个需要发送数据的长度,和数据的buffer,利用这些信息把数据发送出去吧~ ```cxx void FHttp2Context::http2SendProcess(const FTcpConnPtr& ptr) { uint32_t needToSendSize = 0; auto session = mDp->session; while(nghttp2_session_want_write(session)){ const uint8_t* dataPtr = 0; auto curNeedToSend = nghttp2_session_mem_send2(mDp->session, &dataPtr); if (curNeedToSend > 0) { ptr->send((const char*)dataPtr, curNeedToSend); } else { if (curNeedToSend < 0) { Logger::instance()->log(lvl::err, MODULE_NAME "Http2 Recv Process Error: {}", nghttp2_strerror(curNeedToSend)); } } } } ``` ### Stream的管理机制 stream是发送请求/响应的载体。 所以一个stream必然由headers开始。 每当nghttp2接收到新的header的时候会触发 ```cxx on_begin_headers_callback(nghttp2_session* session, const nghttp2_frame* frame, void* user_data) ``` 回调。 在这个回调里,可以处理用户逻辑stream的创建。 `nghttp2_session_callbacks_set_on_begin_headers_callback(cb, Http2Callbacks::on_begin_headers_callback); ` 当stream被关闭的时候,会触发`on_stream_close_callback(nghttp2_session* session, int32_t stream_id, uint32_t error_code, void* user_data)`回调 同样的,在这里删除stream相关的数据即可。 `nghttp2_session_callbacks_set_on_stream_close_callback(cb, Http2Callbacks::on_stream_close_callback); ` 在FeiLib里,每个stream的数据结构如下: ```cxx struct Http2StreamData { Fei::Http::FHttp2Parser parser; std::unique_ptr<FHttp2Response> returnResponse = 0; std::unique_ptr<FHttp2PushPromise> pushPromise = 0; bool isStreamRecvFinish = false; bool isServerOpen = false; bool processed = false; uint32_t parentStreamId = 0; uint32_t streamId; }; ``` 其中isServerOpen,pushPromise,parentStreamId是给push promise机制用的,我后面会提到。 客户端通常一次会发很多个stream,这个数量是由链接前奏中发送的setting frame中的maxConcurrentFrames来决定的。 但如果客户端没有遵守,多发了stream,我们完全可以拒绝掉这些请求。 `int nghttp2_submit_rst_stream(nghttp2_session *session, uint8_t flags,int32_t stream_id, uint32_t error_code)` 这个函数用于发送rst_stream,代表通知对端 本端关闭了这个stream。 那假如客户端在不断的发送stream,而我不想继续接收了怎么办? 可以通过发送GOAWAY来拒绝之后所有的stream。 ```cxx uint32_t last = nghttp2_session_get_last_proc_stream_id(session); nghttp2_submit_goaway(session, 0,last , NGHTTP2_NO_ERROR, NULL, 0); ``` nghttp2_submit_goaway第三个参数是 服务端最后一个接收到的stream,也代表着“到这个stream为止,服务端保证会处理完” 对于stream id,有个简单的约定:客户端发起的stream, id为奇数,服务端发起的id为偶数;stream id一定是顺序增长的。 ### Request/Response 处理机制 由于h2的头真的很难手动解析... nghttp2使用了靠回调来帮你解析头的做法 这个回调长这样: ```cxx int on_header_callback(nghttp2_session* session, const nghttp2_frame* frame, const uint8_t* name, size_t namelen, const uint8_t* value, size_t valuelen, uint8_t flags, void* user_data) ``` name就是header的name,value就是header的value... 然后只需要放入你自己的request里去就好了~ 处理完header需要处理data `on_data_chunk_recv_callback`里接受data。如果flags & NGHTTP2_FLAG_END_STREAM,那么说明数据接收完成了。 在FeiLib中,我维护了一套h2request,所有原始数据都会往里面塞 但是当头frame发送结束后,我就会对request进行处理转换成通用的request 可以通过on_frame_recv_callback来完成这一点。这个函数只有在header/data frame被完全接受后才会被调用。 当然处理完request就要生成response了... nghttp2为了追求高性能,这部分的处理有些反人类。 ```cpp int nghttp2_submit_response2(nghttp2_session *session, int32_t stream_id, const nghttp2_nv *nva, size_t nvlen, const nghttp2_data_provider2 *data_prd) ``` nghttp2_nv 是 name value对,他长这样子 ```cxx typedef struct { uint8_t *name; uint8_t *value; size_t namelen; size_t valuelen; uint8_t flags; } nghttp2_nv; ``` 为了做到0拷贝,可以把flag设置为(NGHTTP2_NV_FLAG_NO_COPY_NAME | NGHTTP2_NV_FLAG_NO_COPY_VALUE),但你也得在headers frame发送完以前,维护这些字符串内存.... nghttp2_data_provider2也是类似指导下做出来的东西。 ```cxx typedef struct { nghttp2_data_source source; nghttp2_data_source_read_callback2 read_callback; } nghttp2_data_provider2; ``` nghttp2_data_source内部就是fd或者void*,nghttp2_data_source_read_callback2则是一个数据读取的回调,会在nghttp2 send的时候被调用,用于处理数据的读取。 我把我的回调的实现放在下面...很重要的地方是:如果数据读取完了,记得要设置flag:NGHTTP2_DATA_FLAG_EOF!! ```cxx static nghttp2_ssize submit_data_read_callback( nghttp2_session* session, int32_t stream_id, uint8_t* buf, size_t length, uint32_t* data_flags, nghttp2_data_source* source, void* user_data) { auto streamUD = getStreamUserData(session, stream_id); auto& response = *(streamUD->returnResponse); auto dataPtr = response.getBody().data(); auto dataHasSend = response.getDataSendedSize(); auto dataNeedToSend = response.getBody().size() - dataHasSend; uint32_t toSendDataSize = 0; if (dataNeedToSend < length) { toSendDataSize = dataNeedToSend; *data_flags |= NGHTTP2_DATA_FLAG_EOF; } else { toSendDataSize = length; } memcpy(buf, dataPtr + dataHasSend, toSendDataSize); response.peakDataSize(toSendDataSize); return toSendDataSize; } ``` ### PUSH PROMISE机制 这部分没什么用,大部分服务器都不支持这个功能。 但是我还是会讲滴~~~ push promise的stream是服务端启动的,但是服务端也需要指定这个流的父流是谁。 也是代表了谁触发了这次push promise 首先通过`nghttp2_submit_push_promise`提交伪请求,这个函数会返回开启的流的id 要注意伪请求中的有些参数需要保持和父流一致!比如host,scheme... > 变相的cache请求了说是 在得到了开启的stream的id后,就能通过nghttp2_submit_response2,如同普通的请求一样发送过去对应的response~ > 注意,这部分代码我没有测试过也没法测试,因为主流浏览器都不再允许push promise。 ### Setting机制 在之前说过:preface时双方都需要交换setting frame,并且成功收到setting frame需要发回去一个带ack的空setting frame帧。 这里的setting并非是协商,而是告知对方自己的承载能力。 大部分的setting都是nghttp2在帮你处理,真正有用的setting只有client发过来的"ENABLE_PUSH"和"MAX_CONCURRENT_STREAMS" 第一个代表的是是否启用push promise,第二个代表的是同时最大能处理的stream数量。 MAX_CONCURRENT_STREAMS可以想象成一个内容是stream的滑动窗口。 通过nghttp2_submit_settings来提交设置。 ### 最后注意 所有的submit都只是提交到了nghttp2的内部状态中啊!真正处理数据还是在nghttp2_session_mem_send2时 其实我个人觉得...http2相比http1带来的访问体验上并没有多大差别 所以各位如果想和我一样把它搬上服务器的话..... 还是花这个时间去做些更有意义的事情吧~ ## 额外:Debug记 涉及到协议层面的东西,如果只是靠啃代码来Debug已经行不通了 如果发现了网页打不开,链接突然被中止,显示TLS错误等等问题 我的建议是用WireShark去查Bug 目前来说wireshark要解密tls,只能靠设置(pre)-master-log,这部分如何操作网上查一下就好了。 但是如此操作只能对firefox和chrome有效 对于safari,它并不能导出这个东西。。。。 我也尝试过用fiddler/charles作为代理,然后抓代理和safari的包 但是...并不能成功,协议层面的东西被代理层抹掉了许多 甚至当我用fiddler时,原本safari上打不开的网页此时竟然能打开了?! > 可恶的代理竟然篡改我的请求! 这时候,就自求多福吧 ## 结尾 写完了!写累了!睡觉了! <audio controls> <source src="https://pic.feiqi3.cn/media/MightyQuinn_NGHFB.mp3" type="audio/mpeg"> </audio>