HTTP 编程(二):http.Client 底层实现剖析

除了上篇教程介绍的基本 HTTP 操作,Go 语言标准库也提供了比较底层的 HTTP 相关库,让开发者可以基于这些库灵活定制 HTTP 服务器并使用 HTTP 服务。

http.Client 的数据结构

前面我们已经介绍过,http.Get()http.Post()http.PostForm()http.Head() 方法其实都是在 http.DefaultClient 的基础上进行调用的。

http.DefaultClientnet/http 包提供的 HTTP 客户端默认实现:

// DefaultClient is the default Client and is used by Get, Head, and Post.
var DefaultClient = &Client{}

实际上,我们还可以基于 http.Client 自定义 HTTP 客户端实现,在此之前,我们先来看看 Client 类型的数据结构:

type Client struct {
    // Transport 用于指定单次 HTTP 请求响应的完整流程
    // 默认值是 DefaultTransport 
    Transport RoundTripper

    // CheckRedirect 用于定义重定向处理策略
    // 它是一个函数类型,接收 req 和 via 两个参数,分别表示即将发起的请求和已经发起的所有请求,最早的已发起请求在最前面
    // 如果不为空,客户端将在跟踪 HTTP 重定向前调用该函数
    // 如果返回错误,客户端将直接返回错误,不会再发起该请求
    // 如果为空,Client 将采用一种确认策略,会在 10 个连续请求后终止 
    CheckRedirect func(req *Request, via []*Request) error

    // Jar 用于指定请求和响应头中的 Cookie 
    // 如果该字段为空,则只有在请求中显式设置的 Cookie 才会被发送
    Jar CookieJar

    // 指定单次 HTTP 请求响应事务的超时时间
    // 未设置的话使用 Transport 的默认设置,为零的话表示不设置超时时间
    Timeout time.Duration
}

其中 Transport 字段必须实现 http.RoundTripper 接口,Transport 指定了一次 HTTP 事务(请求响应)的完整流程,如果不指定 Transport,默认会使用 http.DefaultTransport 这个默认实现,比如 http.DefaultClient 就是这么做的,后面我们会深入探讨 http.DefaultTransport 的底层实现。

CheckRedirect 函数用于定义处理重定向的策略。当使用 HTTP 默认客户端提供的 Get() 或者 Head() 方法发送 HTTP 请求时,如果响应状态码为 30x (比如 301302 等),HTTP 客户端会在遵循跳转规则之前先调用这个 CheckRedirect 函数。

Jar 可用于在 HTTP 客户端中设置 Cookie,Jar 类型必须实现 http.CookieJar 接口,该接口预定义了 SetCookies()Cookies() 两个方法。如果 HTTP 客户端中没有设置 Jar,Cookie 将被忽略而不会发送到客户端。实际上,我们一般都用 http.SetCookie() 方法来设置 Cookie。

Timeout 字段用于指定 Transport 的超时时间,没有指定的话则使用 Transport 自定义的设置。

http.Transport 的底层实现

下面我们通过 http.DefaultTransport 的实现来重点介绍下 http.Transport,没有显式设置 Transport 字段时,就会使用 DefaultTransport

func (c *Client) transport() RoundTripper {
    if c.Transport != nil {
        return c.Transport
    }
    return DefaultTransport
}

DefaultTransportTransport 的默认实现,对应的初始化代码如下:

var DefaultTransport RoundTripper = &Transport{
    Proxy: ProxyFromEnvironment,
    DialContext: (&net.Dialer{
        Timeout:   30 * time.Second,
        KeepAlive: 30 * time.Second,
        DualStack: true,
    }).DialContext,
    MaxIdleConns:          100,
    IdleConnTimeout:       90 * time.Second,
    TLSHandshakeTimeout:   10 * time.Second,
    ExpectContinueTimeout: 1 * time.Second,
}

这里只设置了 Transport 的部分属性,Transport 类型的完整数据结构如下所示:

type Transport struct {
    ...
    
    // 定义 HTTP 代理策略
    Proxy func(*Request) (*url.URL, error)
    
    // 用于指定创建未加密 TCP 连接的上下文参数(通过 net.Dial()创建连接时使用)
    DialContext func(ctx context.Context, network, addr string) (net.Conn, error)
    // 已废弃,使用 DialContext 替代
    Dial func(network, addr string) (net.Conn, error)
    // 创建加密 TCP 连接
    DialTLS func(network, addr string) (net.Conn, error)
    // 指定 tls.Client 所使用的 TLS 配置
    TLSClientConfig *tls.Config
    // TLS 握手超时时间
    TLSHandshakeTimeout time.Duration
    
    // 是否禁用 HTTP 长连接
    DisableKeepAlives bool
    // 是否对 HTTP 报文进行压缩传输(gzip)
    DisableCompression bool
    
    // 最大空闲连接数(支持长连接时有效)
    MaxIdleConns int
    // 单个服务(域名)最大空闲连接数
    MaxIdleConnsPerHost int
    // 单个服务(域名)最大连接数
    MaxConnsPerHost int
    // 空闲连接超时时间
    IdleConnTimeout time.Duration

    // 从客户端把请求完全提交给服务器到从服务器接收到响应报文头的超时时间
    ResponseHeaderTimeout time.Duration
    // 包含 "Expect: 100-continue" 请求头的情况下从客户端把请求完全提交给服务器到从服务器接收到响应报文头的超时时间
    ExpectContinueTimeout time.Duration
    
    ...        
}

结合 Transport 数据结构来看下 DefaultTransport 的设置:

  • 通过 net.Dialer 初始化 Dial 上下文配置,默认超时时间设置为 30 秒;
  • 通过 MaxIdleConns 指定最大空闲连接数为 100,未显式设置 MaxIdleConnsPerHostMaxConnsPerHostMaxIdleConnsPerHost 有默认值,通过 http.DefaultMaxIdleConnsPerHost 设置,对应缺省值是 2;
  • 通过 IdleConnTimeout 指定最大空闲连接时间为 90 秒,即当某个空闲连接超过 90 秒没有被复用,则销毁,空闲连接需要 DisableKeepAlivesfalse 的情况下才可用,即 HTTP 长连接状态下有效(HTTP/1.1以上版本支持长连接,对应请求头 Connection:keep-alive);
  • 通过 TLSHandshakeTimeout 指定基于 TLS 协议的安全 TCP 连接在被建立时握手阶段的超时时间为 10 秒;
  • 通过 ExpectContinueTimeout 指定客户端想要使用 POST 请求把一个很大的报文体发送给服务端的时候,先通过发送一个包含了 Expect: 100-continue 的请求报文头,来询问服务端是否愿意接收这个大报文体对应的超时时间,这里默认设置为 1 秒。

另外,Transport 包含了 RoundTrip 方法实现,所以实现了 RoundTripper 接口。下面我们来看看 TransportRoundTrip 方法的实现。

Transport.RoundTrip() 方法实现

首先我们来看看 http.RoundTripper 接口的具体定义:

type RoundTripper interface {
    RoundTrip(*Request) (*Response, error)
}

从上述代码中可以看到,http.RoundTripper 接口很简单,只定义了一个名为 RoundTrip 的方法。RoundTrip() 方法用于执行一个独立的 HTTP 事务,接受传入的 *Request 请求值作为参数并返回对应的 *Response 响应值,以及一个 error 值。

在实现具体的 RoundTrip() 方法时,不应该试图在该函数里边解析 HTTP 响应信息。若响应成功,error 的值必须为 nil,而与返回的 HTTP 状态码无关。若不能成功得到服务端的响应,error 必须为非零值。类似地,也不应该试图在 RoundTrip() 中处理协议层面的相关细节,比如重定向、认证或是 Cookie 等。

非必要情况下,不应该在 RoundTrip() 方法中改写传入的请求对象(*Request),请求的内容(比如 URL 和 Header 等)必须在传入 RoundTrip() 之前就已组织好并完成初始化。

任何实现了 RoundTrip() 方法的类型都实现了 http.RoundTripper 接口,http.Transport 正是实现了 RoundTrip() 方法继而实现了该接口,在底层,Go 通过 WHATWG Fetch API 实现了单次 HTTP 请求响应事务:

func (t *Transport) RoundTrip(req *Request) (*Response, error) {
	if useFakeNetwork() {
		return t.roundTrip(req)
	}

	ac := js.Global().Get("AbortController")
	if ac != js.Undefined() {
		// Some browsers that support WASM don't necessarily support
		// the AbortController. See
		// https://developer.mozilla.org/en-US/docs/Web/API/AbortController#Browser_compatibility.
		ac = ac.New()
	}

	opt := js.Global().Get("Object").New()
	// See https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch
	// for options available.
	opt.Set("method", req.Method)
	opt.Set("credentials", "same-origin")
	if h := req.Header.Get(jsFetchCreds); h != "" {
		opt.Set("credentials", h)
		req.Header.Del(jsFetchCreds)
	}
	if h := req.Header.Get(jsFetchMode); h != "" {
		opt.Set("mode", h)
		req.Header.Del(jsFetchMode)
	}
	if ac != js.Undefined() {
		opt.Set("signal", ac.Get("signal"))
	}
	headers := js.Global().Get("Headers").New()
	for key, values := range req.Header {
		for _, value := range values {
			headers.Call("append", key, value)
		}
	}
	opt.Set("headers", headers)

	if req.Body != nil {
		// TODO(johanbrandhorst): Stream request body when possible.
		// See https://bugs.chromium.org/p/chromium/issues/detail?id=688906 for Blink issue.
		// See https://bugzilla.mozilla.org/show_bug.cgi?id=1387483 for Firefox issue.
		// See https://github.com/web-platform-tests/wpt/issues/7693 for WHATWG tests issue.
		// See https://developer.mozilla.org/en-US/docs/Web/API/Streams_API for more details on the Streams API
		// and browser support.
		body, err := ioutil.ReadAll(req.Body)
		if err != nil {
			req.Body.Close() // RoundTrip must always close the body, including on errors.
			return nil, err
		}
		req.Body.Close()
		a := js.TypedArrayOf(body)
		defer a.Release()
		opt.Set("body", a)
	}
	respPromise := js.Global().Call("fetch", req.URL.String(), opt)
	var (
		respCh = make(chan *Response, 1)
		errCh  = make(chan error, 1)
	)
	success := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
		result := args[0]
		header := Header{}
		// https://developer.mozilla.org/en-US/docs/Web/API/Headers/entries
		headersIt := result.Get("headers").Call("entries")
		for {
			n := headersIt.Call("next")
			if n.Get("done").Bool() {
				break
			}
			pair := n.Get("value")
			key, value := pair.Index(0).String(), pair.Index(1).String()
			ck := CanonicalHeaderKey(key)
			header[ck] = append(header[ck], value)
		}

		contentLength := int64(0)
		if cl, err := strconv.ParseInt(header.Get("Content-Length"), 10, 64); err == nil {
			contentLength = cl
		}

		b := result.Get("body")
		var body io.ReadCloser
		// The body is undefined when the browser does not support streaming response bodies (Firefox),
		// and null in certain error cases, i.e. when the request is blocked because of CORS settings.
		if b != js.Undefined() && b != js.Null() {
			body = &streamReader{stream: b.Call("getReader")}
		} else {
			// Fall back to using ArrayBuffer
			// https://developer.mozilla.org/en-US/docs/Web/API/Body/arrayBuffer
			body = &arrayReader{arrayPromise: result.Call("arrayBuffer")}
		}

		select {
		case respCh <- &Response{
			Status:        result.Get("status").String() + " " + StatusText(result.Get("status").Int()),
			StatusCode:    result.Get("status").Int(),
			Header:        header,
			ContentLength: contentLength,
			Body:          body,
			Request:       req,
		}:
		case <-req.Context().Done():
		}

		return nil
	})
	defer success.Release()
	failure := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
		err := fmt.Errorf("net/http: fetch() failed: %s", args[0].String())
		select {
		case errCh <- err:
		case <-req.Context().Done():
		}
		return nil
	})
	defer failure.Release()
	respPromise.Call("then", success, failure)
	select {
	case <-req.Context().Done():
		if ac != js.Undefined() {
			// Abort the Fetch request
			ac.Call("abort")
		}
		return nil, req.Context().Err()
	case resp := <-respCh:
		return resp, nil
	case err := <-errCh:
		return nil, err
	}
}

因为实现了 http.RoundTripper 接口的代码通常需要在多个 goroutine 中并发执行,因此我们必须确保实现代码的线程安全性。

以上就是 http.Client 底层实现的几个核心组件及其默认实现,重点关注 http.Transport,它定义了一次 HTTP 事务的完整流程,我们可以通过自定义 Transport 实现对 HTTP 客户端请求的定制化,了解即可,实际开发的时候,我们一般只需要调用上篇教程提供的几个方法即可,除非需要做底层开发和自定义,否则一般不会涉及到这些。

上一篇: HTTP 编程(一):客户端如何发起 HTTP 请求

下一篇: HTTP 编程(三):HTTP/HTTPS 请求处理