Go发起HTTP2.0请求流程分析(前篇)
2023-04-30 来源:飞速影视
前言
继Go中的HTTP请求之——HTTP1.1请求流程分析之后,中间断断续续,历时近一月,终于才敢开始码字写下本文。
阅读建议
HTTP2.0在建立TCP连接和安全的TLS传输通道与HTTP1.1的流程基本一致。所以笔者建议没有看过Go中的HTTP请求之——HTTP1.1请求流程分析这篇文章的先去补一下课,本文会基于前一篇文章仅介绍和HTTP2.0相关的逻辑。
(*Transport).roundTrip
(*Transport).roundTrip方法会调用t.nextProtoOnce.Do(t.onceSetNextProtoDefaults)初始化TLSClientConfig以及h2transport,而这两者都和HTTP2.0有着紧密的联系。
TLSClientConfig: 初始化client支持的http协议, 并在tls握手时告知server。
h2transport: 如果本次请求是http2,那么h2transport会接管连接,请求和响应的处理逻辑。
下面看看源码:

笔者将上述的源码简单拆解为以下几个步骤:
新建一个http2clientConnPool并复制给t2,以后http2的请求会优先从该连接池中获取连接。初始化TLSClientConfig,并将支持的h2和http1.1协议添加到TLSClientConfig.NextProtos中。定义一个h2的upgradeFn存储到t1.TLSNextProto里。鉴于前一篇文章对新建连接前的步骤有了较为详细的介绍,所以这里直接看和server建立连接的部分源码,即(*Transport).dialConn方法:

笔者对上述的源码描述如下:
调用t.dial(ctx, "tcp", cm.addr())创建TCP连接。如果是https的请求, 则对请求建立安全的tls传输通道。检查tls的握手状态,如果和server协商的NegotiatedProtocol协议不为空,且client的t.TLSNextProto有该协议,则返回alt不为空的持久连接(HTTP1.1不会进入if条件里)。笔者对上述的第三点进行展开。经笔者在本地debug验证,当client和server都支持http2时,s.NegotiatedProtocol的值为h2且s.NegotiatedProtocolIsMutual的值为true。
在上面分析http2configureTransport函数时,我们知道TLSNextProto注册了一个key为h2的函数,所以调用next实际就是调用前面的upgradeFn函数。
upgradeFn会调用connPool.addConnIfNeeded向http2的连接池添加一个tls传输通道,并最终返回前面已经创建好的t2即http2Transport。

分析上述的源码我们能够得到两点结论:
执行完upgradeFn之后,(*Transport).dialConn返回的持久化连接中alt字段已经不是nil了。t.NewClientConn(tc)新建出来的连接会保存在http2的连接池即http2clientConnPool中,下一小结将对NewClientConn展开分析。最后我们回到(*Transport).roundTrip方法并分析其中的关键源码:

结合前面的分析,pconn.alt在server和client都支持http2协议的情况下是不为nil的。所以,http2的请求会走pconn.alt.RoundTrip(req)分支,也就是说http2的请求流程就被http2Transport接管啦。
(*http2Transport).NewClientConn
(*http2Transport).NewClientConn内部会调用t.newClientConn(c, t.disableKeepAlives())。
因为本节内容较多,所以笔者不再一次性贴出源码,而是按关键步骤分析并分块儿贴出源码。
1、初始化一个http2ClientConn:

上面的源码新建了一个默认的http2ClientConn。
initialWindowSize:初始化窗口大小为65535,这个值之后会初始化每一个数据流可发送的数据窗口大小。
maxConcurrentStreams:表示每个连接上允许最多有多少个数据流同时传输数据。
streams:当前连接上的数据流。
singleUse: 控制http2的连接是否允许多个数据流共享,其值由t.disableKeepAlives()控制。
2、创建一个条件锁并且新建Writer&Reader。

新建Writer&Reader没什么好说的,需要注意的是cc.flow.add(int32(http2initialWindowSize))。
cc.flow.add将当前连接的可写流控制窗口大小设置为http2initialWindowSize,即65535。
3、新建一个读写数据帧的Framer。

4、向server发送开场白,并发送一些初始化数据帧。

client向server发送的开场白内容如下:

发送完开场白后,client向server发送SETTINGS数据帧。
http2SettingEnablePush: 告知server客户端是否开启push功能。
http2SettingInitialWindowSize:告知server客户端可接受的最大数据窗口是http2transportDefaultStreamFlow(4M)。
发送完SETTINGS数据帧后,发送WINDOW_UPDATE数据帧, 因为第一个参数为0即streamID为0,则是告知server此连接可接受的最大数据窗口为http2transportDefaultConnFlow(1G)。
发送完WINDOW_UPDATE数据帧后,将client的可读流控制窗口大小设置为http2transportDefaultConnFlow http2initialWindowSize。
5、开启读循环并返回
go cc.readLoop()
(*http2Transport).RoundTrip
(*http2Transport).RoundTrip只是一个入口函数,它会调用(*http2Transport). RoundTripOpt方法。
(*http2Transport). RoundTripOpt有两个步骤比较关键:
t.connPool().GetClientConn(req, addr): 在http2的连接池里面获取一个可用连接,其中连接池的类型为http2noDialClientConnPool,参考http2configureTransport函数。
cc.roundTrip(req): 通过获取到的可用连接发送请求并返回响应。
(http2noDialClientConnPool).GetClientConn
根据实际的debug结果(http2noDialClientConnPool).GetClientConn最终会调用(*http2clientConnPool).getClientConn(req *Request, addr string, dialOnMiss bool)。
通过(http2noDialClientConnPool).GetClientConn获取连接时传递给(*http2clientConnPool).getClientConn方法的第三个参数始终为false,该参数为false时代表着即使无法正常获取可用连接,也不在这个环节重新发起拨号流程。
在(*http2clientConnPool).getClientConn中会遍历同地址的连接,并判断连接的状态从而获取一个可以处理请求的连接。

cc.idleState()判断当前连接池中的连接能否处理新的请求:
1、当前连接是否能被多个请求共享,如果仅单个请求使用且已经有一个数据流,则当前连接不能处理新的请求。

2、以下几点均为true时,才代表当前连接能够处理新的请求:
连接状态正常,即未关闭并且不处于正在关闭的状态。当前连接正在处理的数据流小于maxConcurrentStreams。下一个要处理的数据流 当前连接处于等待状态的请求*2 < math.MaxInt32。当前连接没有长时间处于空闲状态(主要通过cc.tooIdleLocked()判断)。

当从链接池成功获取到一个可以处理请求的连接,就可以和server进行数据交互,即(*http2ClientConn).roundTrip流程。
(*http2ClientConn).roundTrip
1、在真正开始处理请求前,还要进行header检查,http2对http1.1的某些header是不支持的,笔者就不对这个逻辑进行分析了,直接上源码:

2、调用(*http2ClientConn).awaitOpenSlotForRequest,一直等到当前连接处理的数据流小于maxConcurrentStreams, 如果此函数返回错误,则本次请求失败。
2.1、double check当前连接可用。

2.2、如果当前连接处理的数据流小于maxConcurrentStreams则直接返回nil。笔者相信大部分逻辑走到这儿就返回了。

2.3、如果当前连接处理的数据流确实已经达到上限,则开始进入等待流程。

通过上面的逻辑知道,当前连接处理的数据流达到上限后有两种情况,一是等待请求被取消,二是等待其他请求结束。如果有其他数据流结束并唤醒当前等待的请求,则重复2.1、2.2和2.3的步骤。
3、调用cc.newStream()在连接上创建一个数据流(创建数据流是线程安全的,因为源码中在调用awaitOpenSlotForRequest之前先加锁,直到写入请求的header之后才释放锁)。

笔者对上述代码简单描述如下:
新建一个http2clientStream,数据流ID为cc.nextStreamID,新建数据流后,cc.nextStreamID =2。数据流通过http2resAndError管道接收请求的响应。初始化当前数据流的可写流控制窗口大小为cc.initialWindowSize,并保存连接的可写流控制指针。初始化当前数据流的可读流控制窗口大小为http2transportDefaultStreamFlow,并保存连接的可读流控制指针。最后将新建的数据流注册到当前连接中。4、调用cc.t.getBodyWriterState(cs, body)会返回一个http2bodyWriterState
结构体。通过该结构体可以知道请求body是否发送成功。

s.fn: 标记当前数据流开始写入数据,并且将请求body的发送结果写入s.resc管道(本文暂不对writeRequestBody展开分析,下篇文章会对其进行分析)。
5、因为是多个请求共享一个连接,那么向连接写入数据帧时需要加锁,比如加锁写入请求头。

6、如果有请求body,则开始写入请求body,没有请求body则设置响应header的超时时间(有请求body时,响应header的超时时间需要在请求body写完之后设置)。

scheduleBodyWrite的内容如下:

因为笔者的请求header中没有携带100-continue标头,所以在前面的getBodyWriterState函数中初始化的s.timer为nil即调用scheduleBodyWrite会立即开始发送请求body。
7、轮询管道获取响应结果。
在看轮询源码之前,先看一个简单的函数:

该函数主要就是判断读到的响应是否正常,并根据响应的结果构造(*http2ClientConn).roundTrip的返回值。
了解了handleReadLoopResponse之后,下面就看看轮询的逻辑:

笔者仅对上面的第二种情况即请求body发送完成进行描述:
能否读到响应,如果能够读取响应则直接返回。判断请求body是否发送成功,如果发送失败,直接返回。如果请求body发送成功,则设置响应header的超时时间。总结
本文主要描述了两个方面的内容:
确认client和server都支持http2协议,并构建一个http2的连接,同时开启该连接的读循环。通过http2连接池获取一个http2连接,并发送请求和读取响应。预告
鉴于HTTTP2.0的内容较多,且文章篇幅过长时不易阅读,笔者将后续要分析的内容拆为两个部分:
描述数据帧和流控制以及读循环读到响应并发送给readLoopResCh管道。http2.0标头压缩逻辑。最后,衷心希望本文能够对各位读者有一定的帮助。
注:写本文时, 笔者所用go版本为: go1.14.2。本文对h2c的情况不予以考虑。因为笔者分析的是请求流程,所以没有在本地搭建server,而是使用了一个支持http2连接的图片一步步的debug。eg:https://dss0.bdstatic.com/5aV1bjqh_Q23odCf/static/superman/img/topnav/baiduyun@2x-e0be79e69e.png
参考
https://developers.google.com/web/fundamentals/performance/http2?hl=zh-cn