前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >图文吃透Golang net/http 标准库--客户端

图文吃透Golang net/http 标准库--客户端

原创
作者头像
小许code
发布2024-02-19 09:52:35
2670
发布2024-02-19 09:52:35

前言

在上一期服务端这一章《图文讲透Golang标准库 net/http实现原理 -- 服务端》我们知道了如何进行路由注册,以及服务的注册和请求处理过程了。

我们已经看完服务端这一半,接下来就是另一部分 ---- 客户端的内容了。

客户端的内容将是如何发送请求和接收响应,走完客户端就把整个流程就完整的串联起来了!

这次我把调用的核心方法和流程走读的函数也贴出来,这样看应该更有逻辑感。

先了解下核心数据结构Client和Request。

Client结构体

代码语言:javascript
复制
type Client struct { 
	Transport RoundTripper 
	CheckRedirect func(req *Request, via []*Request) error 
	Jar CookieJar 
	Timeout time.Duration
}

四个字段分别是:

  • Transport:表示 HTTP 事务,用于处理客户端的请求连接并等待服务端的响应;
  • CheckRedirect:处理重定向的策略
  • Jar:管理和存储请求中的 cookie
  • Timeout:超时设置

Request结构体

Request字段较多,这里就列举一下常见的一些字段

代码语言:javascript
复制
type Request struct {
    Method string
    URL *url.URL
    Header Header
    Body io.ReadCloser
    Host string
    Response *Response
    ...
}
  • Method:指定的HTTP方法(GET、POST、PUT等)
  • URL:请求路径
  • Header:请求头
  • Body:请求体
  • Host:服务器主机
  • Response: 响应参数

构造请求

代码语言:javascript
复制
var DefaultClient = &Client{}

func Get(url string) (resp *Response, err error) {
    return DefaultClient.Get(url)
}

示例HTTP 的 Get方法会调用到 DefaultClient 的 Get 方法,,然后调用到 Client 的 Get 方法。

DefaultClient 是 Client 的一个空实例(跟DefaultServeMux有点子相似)

Client.Get

代码语言:javascript
复制
func (c *Client) Get(url string) (resp *Response, err error) {
	req, err := NewRequest("GET", url, nil)
	if err != nil {
		return nil, err
	}
	return c.Do(req)
}

func NewRequest(method, url string, body io.Reader) (*Request, error) {
	return NewRequestWithContext(context.Background(), method, url, body)
}

Client.Get() 根据用户的入参,请求参数 NewRequest使用上下文包装NewRequestWithContext ,接着通过 Client.Do 方法,处理这个请求。

NewRequestWithContext

代码语言:javascript
复制
func NewRequestWithContext(ctx context.Context, method, url string, body io.Reader) (*Request, error) {
	...
	// 解析url
	u, err := urlpkg.Parse(url)
	...
	rc, ok := body.(io.ReadCloser)
	if !ok && body != nil {
		rc = ioutil.NopCloser(body)
	} 
	u.Host = removeEmptyPort(u.Host)
	req := &Request{
		ctx:        ctx,
		Method:     method,
		URL:        u,
		Proto:      "HTTP/1.1",
		ProtoMajor: 1,
		ProtoMinor: 1,
		Header:     make(Header),
		Body:       rc,
		Host:       u.Host,
	} 
	...
	return req, nil
}

NewRequestWithContext 函数主要是功能是将请求封装成一个 Request 结构体并返回,这个结构体的名称是req。

准备发送请求

构造好的Request结构req,会传入c.Do()方法。

我们看下发送请求过程调用了哪些方法,用下图表示下

🚩 其实不管是Get还是Post请求的调用流程都是一样的,只是对外封装了Post和Get请求

代码语言:javascript
复制
func (c *Client) do(req *Request) (retres *Response, reterr error) {
	...
    for {
        ...
        resp, didTimeout, err = send(req, deadline)
    	if err != nil {
    		return nil, didTimeout, err
    	}
    }
	...
}
//Client 调用 Do 方法处理发送请求最后会调用到 send 函数中
func (c *Client) send(req *Request, deadline time.Time) (resp *Response, didTimeout func() bool, err error) {
	resp, didTimeout, err = send(req, c.transport(), deadline)
	if err != nil {
		return nil, didTimeout, err
	}
	...
	return resp, nil, nil
}

c.transport()方法是为了回去Transport的默认实例 DefaultTransport ,我们看下DefaultTransport长什么样。

DefaultTransport

代码语言:javascript
复制
var DefaultTransport RoundTripper = &Transport{
	Proxy: ProxyFromEnvironment,
	DialContext: defaultTransportDialContext(&net.Dialer{
		Timeout:   30 * time.Second,
		KeepAlive: 30 * time.Second,
	}),
	ForceAttemptHTTP2:     true,
	MaxIdleConns:          100,
	IdleConnTimeout:       90 * time.Second,
	TLSHandshakeTimeout:   10 * time.Second,
	ExpectContinueTimeout: 1 * time.Second,
}

可以根据需要建立网络连接,并缓存它们以供后续调用重用,部分参数如下:

  • MaxIdleConns:最大空闲连接数
  • IdleConnTimeout:空闲连接超时时间
  • ExpectContinueTimeout:预计继续超时

注意这里的RoundTripper是个接口,也就是说 Transport 实现 RoundTripper 接口,该接口方法接收Request,返回Response。

RoundTripper

代码语言:javascript
复制
type RoundTripper interface { 
	RoundTrip(*Request) (*Response, error)
}

虽然还没看完后面逻辑,不过我们猜测RoundTrip方法可能是实际处理客户端请求的实现。

我们继续追下后面逻辑,看下是否能验证这个猜想。

代码语言:javascript
复制
func send(ireq *Request, rt RoundTripper, deadline time.Time) (resp *Response, didTimeout func() bool, err error) {
	...
    resp, err = rt.RoundTrip(req)
	if err != nil {
        ...
    }
    ..
}

👉 你看send函数的第二个参数就是接口类型,调用层传递的Transport的实例DefaultTransport。

而rt.RoundTrip()方法的调用具体在net/http/roundtrip.go文件中,这也是RoundTrip接口的实现,代码如下:

代码语言:javascript
复制
func (t *Transport) RoundTrip(req *Request) (*Response, error) {
	return t.roundTrip(req)
}

Transport.roundTrip 方法概况来说干了这些事:

  • 封装请求transportRequest
  • 调用 Transport 的 getConn 方法获取连接
  • 在获取到连接后,调用 persistConn 的 roundTrip 方法等待请求响应结果
代码语言:javascript
复制
func (t *Transport) roundTrip(req *Request) (*Response, error) {
	...  
	for {
		...
		// 请求封装
		treq := &transportRequest{Request: req, trace: trace, cancelKey: cancelKey} 
		cm, err := t.connectMethodForRequest(treq)
		if err != nil {
			...
		} 
		// 获取连接
		pconn, err := t.getConn(treq, cm)
		if err != nil {
    		...
		}
		
		// 等待响应结果
		var resp *Response
		if pconn.alt != nil {
			t.setReqCanceler(cancelKey, nil) 
			resp, err = pconn.alt.RoundTrip(req)
		} else {
			resp, err = pconn.roundTrip(treq)
		}
		...
	}
}

封装请求transportRequeste没啥好说的,因为treq被roundTrip修改,所以这里需要为每次重试重新创建。

获取连接

获取连接的方法是 getConn,这里代码还是比较长的,会有不同的两种方式去获取连接:

  1. 调用 queueForIdleConn 排队等待获取空闲连接
  2. 如果获取空闲连接失败,那么调用 queueForDial 异步创建一个新的连接,并通过channel来接收readdy信号,来确认连接是否构造完成

getConn

代码语言:javascript
复制
func (t *Transport) getConn(treq *transportRequest, cm connectMethod) (pc *persistConn, err error) {
	...
	//  初始化wantConn结构体
	w := &wantConn{
		cm:         cm,
		key:        cm.key(),
		ctx:        ctx,
		ready:      make(chan struct{}, 1),
		beforeDial: testHookPrePendingDial,
		afterDial:  testHookPostPendingDial,
	}
	...
	// 获取空闲连接
	if delivered := t.queueForIdleConn(w); delivered {
		...
	}
 
	// 异步创建新连接
	t.queueForDial(w)
 
	select {
	// 阻塞等待获取到连接完成
	case <-w.ready:
		...
		return w.pc, w.err
	...
}

queueForIdleConn获取空闲连接

获取成功

成功空闲获取连接Conn流程如下图

  1. 根据wantConn的key从transport.idleConn 这个map中查找,看是否存不存在空闲的 connection 列表
  2. 获取到空闲的 connection 列表后,从列表中拿最后一个 connection
  3. 获取到连接后会调用 wantConn.tryDeliver 方法将连接绑定到 wantConn 请求参数上

获取失败

当不存在该请求的 connection 列表,会将当前 wantConn 加入到名称为 idleConnWait 的等待空闲map中。

不过此时的idleConnWait这个map的值是个队列

queueForIdleConn方法

从上面的两张图解中差不多能看出是如何获取空闲连接和如何获取失败时如何做的了,这里也贴下代码体验下,让大家更清楚里面的实现逻辑。

代码语言:javascript
复制
//idleConn是map类型,指定key返回切片列表
idleConn     map[connectMethodKey][]*persistConn 
//idleConnWait,指定key返回队列
idleConnWait map[connectMethodKey]wantConnQueue

这里将获取空闲连接的代码实现多进行注释,更好理解一些!

代码语言:javascript
复制
func (t *Transport) queueForIdleConn(w *wantConn) (delivered bool) {
	//参数判断
    if t.DisableKeepAlives {
		return false
	}

	if w == nil { 
		return false
	}
 
	// 计算空闲连接超时时间
	var oldTime time.Time
	if t.IdleConnTimeout > 0 {
		oldTime = time.Now().Add(-t.IdleConnTimeout)
	}
	//从idleConn根据w.key找对应的persistConn 列表
	if list, ok := t.idleConn[w.key]; ok {
		stop := false
		delivered := false
		for len(list) > 0 && !stop {
			// 找到persistConn列表最后一个
			pconn := list[len(list)-1] 
			// 检查这个 persistConn 是不是过期
			tooOld := !oldTime.IsZero() && pconn.idleAt.Round(0).Before(oldTime)
			if tooOld {
                //如果过期进行异步清理
				go pconn.closeConnIfStillIdle()
			}
			// 该 persistConn 被标记为 broken 或 闲置太久 continue
			if pconn.isBroken() || tooOld { 
				list = list[:len(list)-1]
				continue
			}
			// 尝试将该 persistConn 写入到 wantConn(w)中
			delivered = w.tryDeliver(pconn, nil)
			if delivered {
				// 写入成功,将persistConn从空闲列表中移除
				if pconn.alt != nil { 
				} else { 
					t.idleLRU.remove(pconn)
                    //缺省了最后一个conn
					list = list[:len(list)-1]
				}
			}
			stop = true
		}
        //对被获取连接后的列表进行判断
		if len(list) > 0 {
			t.idleConn[w.key] = list
		} else {
			// 如果该 key 对应的空闲列表不存在,那么将该key从字典中移除
			delete(t.idleConn, w.key)
		}
		if stop {
			return delivered
		}
	} 
	// 如果找不到空闲的 persistConn
	if t.idleConnWait == nil {
		t.idleConnWait = make(map[connectMethodKey]wantConnQueue)
	}
    // 将该 wantConn添加到等待空闲idleConnWait中
	q := t.idleConnWait[w.key] 
	q.cleanFront()
	q.pushBack(w)
	t.idleConnWait[w.key] = q
	return false
}

我们知道了为找到的空闲连接会被放到空闲idleConnWait这个等待map中,最后会被Transport.tryPutIdleConn方法将pconne添加到等待新请求的空闲持久连接列表中。

queueForDial创建新连接

queueForDial意思是排队等待拨号,为什么说是等带呢,因为最终的结果是在ready这个channel上进行通知的。

流程如下图:

我们先看下Transport结构体的这两个map,名称不一样map的属性和解释都是一样的,其中idleConnWait是在没查找空闲连接的时候存放当前连接的map。

而connsPerHostWait用在了创建新连接的地方,可以猜测一下创建新链接的地方就是将当前的请求放入到 connsPerHostWait 等待map中。

代码语言:javascript
复制
// waiting getConns
idleConnWait map[connectMethodKey]wantConnQueue 
// waiting getConns
connsPerHostWait map[connectMethodKey]wantConnQueue

Transport.queueForDial

代码语言:javascript
复制
func (t *Transport) queueForDial(w *wantConn) {
	w.beforeDial()
    // 小于等于零,意思是限制,直接异步建立连接
	if t.MaxConnsPerHost <= 0 {
		go t.dialConnFor(w)
		return
	}
    ...
    //host建立的连接数没达到上限,执行异步建立连接
	if n := t.connsPerHost[w.key]; n < t.MaxConnsPerHost {
		if t.connsPerHost == nil {
			t.connsPerHost = make(map[connectMethodKey]int)
		}
		t.connsPerHost[w.key] = n + 1
		go t.dialConnFor(w)
		return
	}
    //进入等待队列
	if t.connsPerHostWait == nil {
		t.connsPerHostWait = make(map[connectMethodKey]wantConnQueue)
	}
	q := t.connsPerHostWait[w.key]
	q.cleanFront()
	q.pushBack(w)
	t.connsPerHostWait[w.key] = q
}

在获取不到空闲连接之后,会尝试去建立连接:

  1. queueForDial 方法的内部会先校验 MaxConnsPerHost 是否未设置和是否已达上限
  2. 检验不通过则将当前的请求放入到 connsPerHostWait 这个等待map中
  3. 校验通过那么会异步的调用 dialConnFor 方法创建连接

👉那会不会queueForDial方法中将idleConnWait和connsPerHostWait打包到等待空闲连接idleConn这个map中呢?

我们继续看dialConnFor的实现,它会给我们这个问题的答案!

dialConnFor

代码语言:javascript
复制
func (t *Transport) dialConnFor(w *wantConn) {
	defer w.afterDial()
    //创建 persistConn
	pc, err := t.dialConn(w.ctx, w.cm)
    //绑定到 wantConn
	delivered := w.tryDeliver(pc, err)
	if err == nil && (!delivered || pc.alt != nil) {
        //绑定wantConn失败
        //放到存放空闲连接idleConn的map中
		t.putOrCloseIdleConn(pc)
	}
	if err != nil {
		t.decConnsPerHost(w.key)
	}
}
  • dialConnFor 先调用 dialConn 方法创建 TCP 连接
  • 调用 tryDeliver 将连接绑定到 wantConn 上,绑定成功的话,就将该链接放到空闲连接的idleConn这个map中
  • 绑定失败的话会调用decConnsPerHost方法,用递减密钥的每主机连接计数方式,继续异步调用Transport.dialConnFor

我们可以追踪下代码会发现Transport.tryPutIdleConn()方法就是将persistConn添加到等待的空闲持久连接列表中的实现。

Transport.dialConn创建连接

代码语言:javascript
复制
func (t *Transport) dialConn(ctx context.Context, cm connectMethod) (pconn *persistConn, err error) {
	pconn = &persistConn{
		t:             t,
		cacheKey:      cm.key(),
		reqch:         make(chan requestAndChan, 1),
		writech:       make(chan writeRequest, 1),
		closech:       make(chan struct{}),
		writeErrCh:    make(chan error, 1),
		writeLoopDone: make(chan struct{}),
	}
    ...
    // 创建 tcp 连接,给pconn.conn
	conn, err := t.dial(ctx, "tcp", cm.addr())
	if err != nil {
		return nil, wrapErr(err)
	}
	pconn.conn = conn
    ...
    //开启两个goroutine处理读写
    go pconn.readLoop()
	go pconn.writeLoop()
	return pconn, nil
}

👉 看完这个创建persistConn的代码是不是心里仿佛懂了什么?

上述代码中HTTP 连接的创建过程是建立 tcp 连接,然后为连接异步处理读写数据,最后将创建好的连接返回。

我们可以看到创建的每个连接会分别创建两个goroutine循环地进行进行读写的处理,这就是为什么我们连接能接受请求参数和处理请求的响应的关键。

👉 这两个协程功能是这样的!

  1. persisConn.writeLoop(),通过 persistConn.writech 通道读取到客户端提交的请求,将其发送到服务端
  2. persisConn.readLoop(),读取来自服务端的响应,并添加到 persistConn.reqCh 通道中,给persistConn.roundTrip 方法接收

想看这两个协程

等待响应

persistConn 连接本身创建了两个读写goroutine,而这两个goroutine就是通过两个channel进行通信的。

这个通信就是在persistConn.roundTrip()方法中的进行传递交互的,其中writech 是用来写入请求数据,reqch是用来读取响应数据。

代码语言:javascript
复制
func (pc *persistConn) roundTrip(req *transportRequest) (resp *Response, err error) {
	...
	// 请求数据写入到 writech channel中
	pc.writech <- writeRequest{req, writeErrCh, continueCh}

	// 接收响应的channel
	resc := make(chan responseAndError)
	// 接收响应的结构体 requestAndChan 写到 reqch channel中
	pc.reqch <- requestAndChan{
		req:        req.Request,
		cancelKey:  req.cancelKey,
		ch:         resc,
		...
	}
	...
	for {
        ...
        select { 
    		// 接收到响应数据
		case re := <-resc:
			...
			// return响应数据
			return re.res, nil
		...
	}
}
  1. 连接获取到之后,会调用连接的 roundTrip 方法,将请求数据写入到 persisConn.writech channel中,而连接 persisConn 中的协程 writeLoop() 接收到请求后就会处理请求
  2. 响应结构体 requestAndChan 写入到 persisConn.reqch 中
  3. 通过readLoop 接受响应数据,然后读取 resc channel 的响应结果
  4. 接受到响应数据之后循环结束,连接处理完成

好了,net/http标准库的客户端构造请求、发送请求、接受服务端的请求数据流程就讲完了,看完之后是否意欲未尽呢?

还别说,小许也是第一次看是如何实现的,确实还是了解到了点东西呢!

安利一波: 欢迎朋友们关注我的公众号📢📢:【小许code】🤣🤣

欢迎点赞 👍、收藏 💙、关注 💡 三连支持一下~🎈🎈

知道的越多,不知道的也越多,我是小许,下期见~🙇💻

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 前言
    • Client结构体
      • Request结构体
      • 构造请求
      • 准备发送请求
      • 获取连接
      • 等待响应
      领券
      问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档