6

【4-2 Golang】常用标准库—net/http.client

 1 year ago
source link: https://studygolang.com/articles/35906
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK