Go语言中,当我们需要访问第三方服务时,通常基于http.Client完成,顾名思义其代表HTTP客户端。http.Client的使用相对比较简单,不过底层有一些细节还是要多注意,包括长连接(连接池问题),可能偶现的reset情况等等。本篇文章主要介绍http.Client的基本使用方式,实现原理,以及一些注意事项。
http.Client 概述
Go语言中想发起一个HTTP请求真的是非常简单,net/http包封装了非常好用的函数,基本上一行代码就能搞定,如下面几个函数,用于发起GET请求或者POST请求:
func Post(url, contentType string, body io.Reader) (resp *Response, err error)
func PostForm(url string, data url.Values) (resp *Response, err error)
func Get(url string) (resp *Response, err error)
这些函数其实都是基于http.Client实现的,其代表着HTTP客户端,如下所示:
//使用默认客户端DefaultClient
func PostForm(url string, data url.Values) (resp *Response, err error) {
return DefaultClient.PostForm(url, data)
}
func (c *Client) PostForm(url string, data url.Values) (resp *Response, err error) {
return c.Post(url, "application/x-www-form-urlencoded", strings.NewReader(data.Encode()))
}
那么,http.Client是如何实现HTTP请求的发起过程呢?我们先看看http.Client结构的定义,非常简单,只有4个字段:
type Client struct {
//顾名思义传输层
Transport RoundTripper
//处理重定向方式(当301、302等之类重定向怎么办)
CheckRedirect func(req *Request, via []*Request) error
//存储预置cookie,向外发起请求时自动添加cookie
Jar CookieJar
//超时时间
Timeout time.Duration
}
type RoundTripper interface {
RoundTrip(*Request) (*Response, error)
}
http.RoundTripper是一个接口,只自定义了一个方法,用于实现如何传输HTTP请求(长连接还是短连接等);如果该字段为空,默认使用http.DefaultTransport,其类型为http.Transport结构(实现了RoundTripper接口)。
CheckRedirect定义了请求重定向的处理方式,也就是当第三方服务返回301、302之类的重定向状态码时,如何处理,继续请求还是直接返回给上层业务;如果该字段为空,默认使用http.defaultCheckRedirect函数实现,该函数限制重定向次数不能超过10次。
http.CookieJar是做什么的呢?存储预设置的cookie,而当我们使用http.Client发起请求时,会查找对应cookie,并自动添加;http.CookieJar也是一个接口,定义了两个方法,分别用于预设置cookie,以及发起请求时查找cookie,Go语言中cookiejar.Jar结构实现了接口http.CookieJar。
type CookieJar interface {
SetCookies(u *url.URL, cookies []*Cookie)
Cookies(u *url.URL) []*Cookie
}
Timeout就比较简单了,就是请求的超时时间,超时返回错误"Client.Timeout exceeded while awaiting headers"。
发起HTTP请求最终都会走到http.Client.do方法:这个方法的输入参数类型是http.Request,表示HTTP请求,包含有请求的method、Host、url、header、body等数据;方法的返回值类型是http.Response,表示HTTP响应,包含有响应状态码status、header、body等数据。http.Client.do方法的主要流程如下:
func (c *Client) do(req *Request) (retres *Response, reterr error) {
for {
//被重定向了
if len(reqs) > 0 {
loc := resp.Header.Get("Location")
//重新封装请求
req = &Request{
}
//重定向校验,默认使用ttp.defaultCheckRedirect函数,限制最多重定向10次
err = c.checkRedirect(req, reqs)
if err == ErrUseLastResponse {
return resp, nil
}
}
reqs = append(reqs, req)
if resp, didTimeout, err = c.send(req, deadline); err != nil {
//超时了
if !deadline.IsZero() && didTimeout() {
err = &httpError{
err: err.Error() + " (Client.Timeout exceeded while awaiting headers)",
timeout: true,
}
}
return nil, uerr(err)
}
//是否需要重定向(状态码301、302、307、308)
redirectMethod, shouldRedirect, includeBody = redirectBehavior(req.Method, resp, reqs[0])
if !shouldRedirect {
return resp, nil
}
}
可以看到,http.Client.do方法整个流程还是比较简单的,那我们还研究什么呢?发起HTTP请求最复杂的逻辑应该是"HTTP请求的发送",也就是http.RoundTripper,最明显的一个问题就是,采用的是短链接还是长连接呢?长连接的话如何维护连接池呢?
连接池概述
Go语言作为常驻进程,发起HTTP请求时,采用的是短链接还是长连接呢?短链接的话需要我们每次请求关闭关闭连接吗?长连接的话是不是需要维护一个连接池?也就是已建立的连接,请求返回之后,这些连接就空闲了,将其存储在连接池(而不是直接关闭),待下次发起HTTP请求时,继续复用这个连接(从连接池获取)。当然连接池并不止这么简单,比如池子中最多存储多少个空闲连接呢?如果某个连接长时间空闲会将其关闭吗?有没有心跳机制呢?发起HTTP请求获取空闲连接时,如果没有空闲连接怎么办?新建连接吗?可以无限制新建连接吗(突发流量)?这些所有的行为都定义在结构http.Transport,而且这个结构实现了接口http.RoundTripper:
type Transport struct {
//空闲连接池(key为协议目标地址等组合)
idleConn map[connectMethodKey][]*persistConn // most recently used at end
//等待空闲连接的队列
idleConnWait map[connectMethodKey]wantConnQueue // waiting getConns
//连接数(key为协议目标地址等组合)
connsPerHost map[connectMethodKey]int
//等待建立连接的队列
connsPerHostWait map[connectMethodKey]wantConnQueue // waiting getConns
//禁用HTTP长连接(请求完毕后完毕连接)
DisableKeepAlives bool
//最大空闲连接数,0无限制
MaxIdleConns int
//每host最大空闲连接数,默认为2(注意默认值)
MaxIdleConnsPerHost int
//每host最大连接数,0无限制
MaxConnsPerHost int
//空闲连接超时时间,该时间段没有请求则关闭该连接
IdleConnTimeout time.Duration
}
可以看到,空闲连接池(idleConn)是一个map结构,而key为协议目标地址等组合,注意字段MaxIdleConnsPerHost定义了每host最大空闲连接数,即同一种协议与同一个目标host可建立的连接或者空闲连接是有限制的,如果你没有配置MaxIdleConnsPerHost,Go语言默认MaxIdleConnsPerHost等于2,即与目标主机最多只维护两个空闲连接。MaxIdleConns描述的也是最大空闲连接数,只不过其限制的是总数。想想如果这两个配置不合理(过少),会导致什么呢?如果遇到突发流量,由于空闲连接数较少,会瞬间建立大量连接,但是回收连接时,同样由于最大空闲连接数的限制,该连接不能进入空闲连接池,只能直接关闭。结果是,一直新建大量连接,又关闭大量连,业务机器的TIME_WAIT连接数随之突增。
MaxConnsPerHost描述的是最大连接数,如果没有配置意味着无限制,注意不是空闲连接,也就是同一种协议与同一个目标host可建立的最大连接数。空闲连接数有限制,连接数也有限制,那如果超过限制怎么办?也就是获取空闲连接没有了,新建连接也不行,这时候怎么办?排队等待呗,idleConnWait维护等待空闲连接队列,connsPerHostWait维护等待连接的队列。想想如果MaxConnsPerHost配置的不合理呢?发送HTTP请求获取空闲连接发现没有排队等待,同时尝试新建连接发现超过限制,继续排队等待,如果遇到突发流量,可能请求都超时了,还没有获取到可用连接。
最后,Transport也提供了配置DisableKeepAlives,禁用长连接,使用短连接访问第三方服务。
Transport结构我们基本了解了,那么其发送HTTP请求的流程是怎样的呢?如下:
func (t *Transport) roundTrip(req *Request) (*Response, error) {
for {
//获取连接
pconn, err := t.getConn(treq, cm)
//发送请求
resp, err = pconn.roundTrip(treq)
if err == nil {
resp.Request = origReq
return resp, nil
}
//判断是否需要重试
if !pconn.shouldRetryRequest(req, err) {
return nil, err
}
}
}
整个流程省略了很多细节,http.Transport.getConn方法用于从连接池获取可用连接,获取连接基本就是两个步骤:1)尝试获取空闲连接;2)常识新建连接。该过程涉及到的核心流程(方法)如下:
func (t *Transport) getConn(treq *transportRequest, cm connectMethod) (pc *persistConn, err error) {
//获取到空闲连接,返回
if delivered := t.queueForIdleConn(w); delivered {
return pc, nil
}
//新建连接或者排队等待
t.queueForDial(w)
select {
//空闲连接放回连接池时,或者异步建立连接成功后,分配,同时关闭管道w.ready,这里select就会触发
case <-w.ready:
return w.pc, w.err
// 其他case,如超时等
}
}
//请求处理完毕,将空闲连接放回连接池
func (t *Transport) tryPutIdleConn(pconn *persistConn) error
http.persistConn结构代表着一个连接,值得一提的是,HTTP请求的发送以及响应的读取也是异步协程完成的,主协程与之都是通过管道通信的(写请求,获取响应),这两个异步协程是在建立连接的时候启动的,分别是writeLoop以及readLoop(真正执行socket读写操作),如下所示:
type persistConn struct {
//协程间通信用的管道(请求与响应)
reqch chan requestAndChan // written by roundTrip; read by readLoop
writech chan writeRequest // written by roundTrip; read by writeLoop
}
func (pc *persistConn) roundTrip(req *transportRequest) (resp *Response, err error) {
//通知准备发起HTTP请求(写数据)
pc.writech <- writeRequest{req, writeErrCh, continueCh}
//通知准备读取响应
pc.reqch <- requestAndChan{
}
for {
select {
//获取到响应了
case re := <-resc:
return re.res, nil
//超时,出错等等case处理(可能直接关闭该连接)
}
}
}
初学Go语言时,可能很难理解各种异步操作,但是要知道,协程是Go语言的精髓。这里在发起HTTP请求时,也是采用异步协程,这样socket的读写操作阻塞的也是异步协程,主协程只控制好主流程就行,很简单就实现了各种超时处理,错误处理等逻辑。
最后提出一个问题,如何实现队列呢?你是不是想说,这也太简单了,基于切片不就行了,入队append切片结尾,出队即返回切片第一个元素。想想这样有什么问题吗?随着频繁的入队与出队操作,切片的底层数组,会有大量空间无法复用而造成浪费。或者是采用环形队列,可是环形队列也意味有长度限制(管道chan就是基于环形队列)。
Go语言在实现队列时,使用了两个切片head和tail;head切片用于出队操作,tail切片用于入队操作;入队时,直接append到tail切片;出队优先从head切片获取,如果head切片为空,则交换head与tail。通过这种方式,实现了底层数组空间的复用。
//入队
func (q *wantConnQueue) pushBack(w *wantConn) {
q.tail = append(q.tail, w)
}
//出队
func (q *wantConnQueue) popFront() *wantConn {
// head为空
if q.headPos >= len(q.head) {
if len(q.tail) == 0 {
return nil
}
// 交换
q.head, q.headPos, q.tail = q.tail, 0, q.head[:0]
}
w := q.head[q.headPos]
q.head[q.headPos] = nil
q.headPos++
return w
}
connection reset by peer
没想到连接池需要注意这么多事情吧,别急,还有一个问题我们没有解决,我们直接少了IdleConnTimeout配置空闲长连接超时时间,Go语言HTTP连接池如何实现空闲连接的超时关闭逻辑呢?其实是在queueForIdleConn函数实现的,每次在获取到空闲连接时,都会检测是否已经超时,超时则关闭连接。
func (t *Transport) queueForIdleConn(w *wantConn) (delivered bool) {
//如果配置了空闲超时时间,获取到连接需要检测,超时则关闭连接
if t.IdleConnTimeout > 0 {
oldTime = time.Now().Add(-t.IdleConnTimeout)
}
if list, ok := t.idleConn[w.key]; ok {
for len(list) > 0 && !stop {
pconn := list[len(list)-1]
//pconn.idleAt记录该长连接空闲时间(什么时候添加到连接池)
tooOld := !oldTime.IsZero() && pconn.idleAt.Round(0).Before(oldTime)
//超时了,关闭连接
if tooOld {
go pconn.closeConnIfStillIdle()
}
//分发连接到wantConn
delivered = w.tryDeliver(pconn, nil)
}
}
}
那如果没有业务请求到达,一直不需要获取连接,空闲连接就不会超时关闭吗?其实在将空闲连接添加到连接池时,Golang同时还设置了定时器,定时器到期后,自然会关闭该连接。
func (t *Transport) tryPutIdleConn(pconn *persistConn) error {
if t.IdleConnTimeout > 0 && pconn.alt == nil {
if pconn.idleTimer != nil {
pconn.idleTimer.Reset(t.IdleConnTimeout)
} else {
//设置定时器,超时后关闭连接
pconn.idleTimer = time.AfterFunc(t.IdleConnTimeout, pconn.closeConnIfStillIdle)
}
}
}
所以说,连接池中的空闲长连接如果长时间没有被使用,是会被关闭的。其实Go服务主动关闭长连接是一件好事,如果是上游服务先关闭长连接,那就有可能导致"connection reset by peer"情况出现。为什么呢?想想某一时刻,上游服务关闭长连接,与此同时你的Go服务刚好需要发起HTTP请求,并且获取到该上连接(此时连接还正常),于是你的请求通过该长连接发送了,但是上游服务已经关闭该连接了,这时候怎么办?上游服务TCP层只能给你返回RST包了,于是就出现了上述错误。所以说,基于长连接传输HTTP请求时,最好是下游主动关闭长连接,不要等到上游服务关闭。
我们以Nginx(常用来做接入层网关)为例(Go服务通过长连接向发起HTTP请求,请求先到达网关Nginx节点),讲解下为什么上游服务会关闭长连接。Nginx有两个配置描述长连接断开行为:
Syntax: keepalive_timeout timeout [header_timeout];
Default:
keepalive_timeout 75s;
Context: http, server, location
The first parameter sets a timeout during which a keep-alive client connection will stay open on the server side
Syntax: keepalive_requests number;
Default:
keepalive_requests 1000;
Context: http, server, location
Sets the maximum number of requests that can be served through one keep-alive connection. After the maximum number of requests are made, the connection is closed.
Syntax: http2_max_requests number;
Default:
http2_max_requests 1000;
Context: http, server
This directive appeared in version 1.11.6.
Sets the maximum number of requests (including push requests) that can be served through one HTTP/2 connection, after which the next client request will lead to connection closing and the need of establishing a new connection.
当长连接超过keepalive_timeout时间段没有收到客户端请求,或者单个长连接最大收到keepalive_requests个请求,Nginx会关闭连接。http2_max_requests用于配置HTTP2协议下,每个长连接最大处理的请求数。
Go语言只有IdleConnTimeout可以配置空闲长连接超时时间,没有类似Nginx配置keepalive_requests可以限制请求数。所以,我们生产环境就遇到了,无论怎么配置,总是会出现偶发的"connection reset by peer"。
那怎么办?眼睁睁的看着HTTP请求异常?Go语言目前有这几个措施应对连接关闭情况:1)底层检测连接关闭事件,标记连接不可用;2)HTTP请求出现传输错误等情况时,对部分请求进行重试,注意重试请求是有条件的,比如:GET请求可以重试,或者请求头中出现{X-,}Idempotency-Key也可以重试。
+Transport.roundTrip
+persistConn.shouldRetryRequest
+RequestisReplayable
func (r *Request) isReplayable() bool {
if r.Body == nil || r.Body == NoBody || r.GetBody != nil {
switch valueOrDefault(r.Method, "GET") {
case "GET", "HEAD", "OPTIONS", "TRACE":
return true
}
if r.Header.has("Idempotency-Key") || r.Header.has("X-Idempotency-Key") {
return true
}
}
return false
}
所以,如果你是GET请求,没问题Go语言底层在遇到RST情况,会自动帮你重试。但是如果是POST请求呢,如果你确信你的请求是幂等性的,或者可以接受重试导致提交两次的的风险,可以通过添加header使得Go语言帮你自动重试。或者,如果你的业务量较小,不考虑性能的话,使用短链接也能避免。
总结
http.Client的使用相对比较简单,不过其底层连接池问题还是要多多注意,另外还有使用长连接可能出现的"connection reset by peer"情况。关于http.Client就介绍到这里,当然本篇文章只摘抄除了部分代码,整个流程的详细代码还需要你自己多研读学习。