1

  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就介绍到这里,当然本篇文章只摘抄除了部分代码,整个流程的详细代码还需要你自己多研读学习。


李烁
156 声望92 粉丝