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

tomato01 · · 3615 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

&emsp;&emsp;Go语言中,当我们需要访问第三方服务时,通常基于http.Client完成,顾名思义其代表HTTP客户端。http.Client的使用相对比较简单,不过底层有一些细节还是要多注意,包括长连接(连接池问题),可能偶现的reset情况等等。本篇文章主要介绍http.Client的基本使用方式,实现原理,以及一些注意事项。 ## http.Client 概述 &emsp;&emsp;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) ``` &emsp;&emsp;这些函数其实都是基于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())) } ``` &emsp;&emsp;那么,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) } ``` &emsp;&emsp;http.RoundTripper是一个接口,只自定义了一个方法,用于实现如何传输HTTP请求(长连接还是短连接等);如果该字段为空,默认使用http.DefaultTransport,其类型为http.Transport结构(实现了RoundTripper接口)。 &emsp;&emsp;CheckRedirect定义了请求重定向的处理方式,也就是当第三方服务返回301、302之类的重定向状态码时,如何处理,继续请求还是直接返回给上层业务;如果该字段为空,默认使用http.defaultCheckRedirect函数实现,该函数限制重定向次数不能超过10次。 &emsp;&emsp;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 } ``` &emsp;&emsp;Timeout就比较简单了,就是请求的超时时间,超时返回错误"Client.Timeout exceeded while awaiting headers"。 &emsp;&emsp;发起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 } } ``` &emsp;&emsp;可以看到,http.Client.do方法整个流程还是比较简单的,那我们还研究什么呢?发起HTTP请求最复杂的逻辑应该是"HTTP请求的发送",也就是http.RoundTripper,最明显的一个问题就是,采用的是短链接还是长连接呢?长连接的话如何维护连接池呢? ## 连接池概述 &emsp;&emsp;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 } ``` &emsp;&emsp;可以看到,空闲连接池(idleConn)是一个map结构,而key为协议目标地址等组合,注意字段MaxIdleConnsPerHost定义了每host最大空闲连接数,即同一种协议与同一个目标host可建立的连接或者空闲连接是有限制的,如果你没有配置MaxIdleConnsPerHost,Go语言默认MaxIdleConnsPerHost等于2,即与目标主机最多只维护两个空闲连接。MaxIdleConns描述的也是最大空闲连接数,只不过其限制的是总数。想想如果这两个配置不合理(过少),会导致什么呢?如果遇到突发流量,由于空闲连接数较少,会瞬间建立大量连接,但是回收连接时,同样由于最大空闲连接数的限制,该连接不能进入空闲连接池,只能直接关闭。结果是,一直新建大量连接,又关闭大量连,业务机器的TIME_WAIT连接数随之突增。 &emsp;&emsp;MaxConnsPerHost描述的是最大连接数,如果没有配置意味着无限制,注意不是空闲连接,也就是同一种协议与同一个目标host可建立的最大连接数。空闲连接数有限制,连接数也有限制,那如果超过限制怎么办?也就是获取空闲连接没有了,新建连接也不行,这时候怎么办?排队等待呗,idleConnWait维护等待空闲连接队列,connsPerHostWait维护等待连接的队列。想想如果MaxConnsPerHost配置的不合理呢?发送HTTP请求获取空闲连接发现没有排队等待,同时尝试新建连接发现超过限制,继续排队等待,如果遇到突发流量,可能请求都超时了,还没有获取到可用连接。 &emsp;&emsp;最后,Transport也提供了配置DisableKeepAlives,禁用长连接,使用短连接访问第三方服务。 &emsp;&emsp;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 } } } ``` &emsp;&emsp;整个流程省略了很多细节,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 ``` &emsp;&emsp;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处理(可能直接关闭该连接) } } } ``` &emsp;&emsp;初学Go语言时,可能很难理解各种异步操作,但是要知道,协程是Go语言的精髓。这里在发起HTTP请求时,也是采用异步协程,这样socket的读写操作阻塞的也是异步协程,主协程只控制好主流程就行,很简单就实现了各种超时处理,错误处理等逻辑。 &emsp;&emsp;最后提出一个问题,如何实现队列呢?你是不是想说,这也太简单了,基于切片不就行了,入队append切片结尾,出队即返回切片第一个元素。想想这样有什么问题吗?随着频繁的入队与出队操作,切片的底层数组,会有大量空间无法复用而造成浪费。或者是采用环形队列,可是环形队列也意味有长度限制(管道chan就是基于环形队列)。 &emsp;&emsp;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 &emsp;&emsp;没想到连接池需要注意这么多事情吧,别急,还有一个问题我们没有解决,我们直接少了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) } } } ``` &emsp;&emsp;那如果没有业务请求到达,一直不需要获取连接,空闲连接就不会超时关闭吗?其实在将空闲连接添加到连接池时,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) } } } ``` &emsp;&emsp;所以说,连接池中的空闲长连接如果长时间没有被使用,是会被关闭的。其实Go服务主动关闭长连接是一件好事,如果是上游服务先关闭长连接,那就有可能导致"connection reset by peer"情况出现。为什么呢?想想某一时刻,上游服务关闭长连接,与此同时你的Go服务刚好需要发起HTTP请求,并且获取到该上连接(此时连接还正常),于是你的请求通过该长连接发送了,但是上游服务已经关闭该连接了,这时候怎么办?上游服务TCP层只能给你返回RST包了,于是就出现了上述错误。所以说,基于长连接传输HTTP请求时,最好是下游主动关闭长连接,不要等到上游服务关闭。 &emsp;&emsp;我们以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. ``` &emsp;&emsp;当长连接超过keepalive_timeout时间段没有收到客户端请求,或者单个长连接最大收到keepalive_requests个请求,Nginx会关闭连接。http2_max_requests用于配置HTTP2协议下,每个长连接最大处理的请求数。 &emsp;&emsp;Go语言只有IdleConnTimeout可以配置空闲长连接超时时间,没有类似Nginx配置keepalive_requests可以限制请求数。所以,我们生产环境就遇到了,无论怎么配置,总是会出现偶发的"connection reset by peer"。 &emsp;&emsp;那怎么办?眼睁睁的看着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 } ``` &emsp;&emsp;所以,如果你是GET请求,没问题Go语言底层在遇到RST情况,会自动帮你重试。但是如果是POST请求呢,如果你确信你的请求是幂等性的,或者可以接受重试导致提交两次的的风险,可以通过添加header使得Go语言帮你自动重试。或者,如果你的业务量较小,不考虑性能的话,使用短链接也能避免。 ## 总结 &emsp;&emsp;http.Client的使用相对比较简单,不过其底层连接池问题还是要多多注意,另外还有使用长连接可能出现的"connection reset by peer"情况。关于http.Client就介绍到这里,当然本篇文章只摘抄除了部分代码,整个流程的详细代码还需要你自己多研读学习。

有疑问加站长微信联系(非本文作者))

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

3615 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传