Go - http.Client源码分析

分析 http.Client 源码实现的起因, 是因为在使用如下步骤模拟网站登录时, 出现了问题, 参考 知乎 - go net/http.Client 处理redirect :

  1. POST 账号密码等参数进行登录

  2. 下发 token , 此 token 通过 cookie 下发

  3. 重定向到主页 /

在通过 http.Post 进行请求, 预期不进行重定向, 能够直接获取到 cookie 值, 但实际上go帮我们处理了重定向, 丢失了 cookie

分析源码后, 可以很轻易地解决这个问题:

// 请求http.calabash.top将被301重定向到https

myClient := http.Client{
    CheckRedirect: func(req *http.Request, via []*http.Request) error {
        return http.ErrUseLastResponse
    },
}
respWithNoRedirect, _ := myClient.Get("http://blog.calabash.top")
respWithRedirect, _ := http.Get("http://blog.calabash.top")

fmt.Println(respWithNoRedirect.StatusCode) // 301
fmt.Println(respWithRedirect.StatusCode)   // 200
复制代码

2. Client

HTTP客户端, 其零值为 DefaultClient , 本处分析的核心在于处理重定向的方式

type Client struct {
    // 分析范围之外
    Transport RoundTripper
    // 重定向策略
    CheckRedirect func(req *Request, via []*Request) error
    // Cookie的存储, 单纯的get/set方法
    Jar CookieJar
    // 超时
    Timeout time.Duration
}

var DefaultClient = &Client{}
复制代码

当遇见重定向时, 除了以下的情况, Client 将转发所有初始请求头:

  • 当重定向地址和初始地址的 domain 不同且也不是 sub domain 时, 请求头中的 cookie , authorization 等敏感字段将被忽略

  • 重定向可能会改变初始请求中的 cookie 值, 因此转发 cookie 请求头时将忽略任何有变化的 cookie

2.1 重定向的规则: redirectBehavior

func redirectBehavior(reqMethod string, resp *Response, ireq *Request) (redirectMethod string, shouldRedirect, includeBody bool) {
	switch resp.StatusCode {
	case 301, 302, 303:
		redirectMethod = reqMethod
		shouldRedirect = true
		includeBody = false

		if reqMethod != "GET" && reqMethod != "HEAD" {
			redirectMethod = "GET"
		}
	case 307, 308:
		redirectMethod = reqMethod
		shouldRedirect = true
		includeBody = true

		if resp.Header.Get("Location") == "" {
			shouldRedirect = false
			break
		}
		if ireq.GetBody == nil && ireq.outgoingLength() != 0 {
			shouldRedirect = false
		}
	}
	return redirectMethod, shouldRedirect, includeBody
}
复制代码

由源码可以得到如下的信息:

  • 对于301, 302, 303的状态码, 重定向不会附带请求体, 并且对于非 GET/HEAD 请求方法会被强制修改为 GET 请求方法

  • 对于307, 308状态码, 重定向不会更改修改请求方法且会重用请求体, Location 为空或 Body 为空时, 不应重定向

参考 MDN - Http Status Code , 其中有这样的描述

301: 尽管标准要求浏览器在收到该响应并进行重定向时不应该修改http method和body,但是有一些浏览器可能会有问题。 所以最好是在应对GET或HEAD方法时使用301,其他情况使用308来替代301

302: 即使规范要求浏览器在重定向时保证请求方法和请求主体不变,但并不是所有的用户代理都会遵循这一点 所以推荐仅在响应GET或HEAD方法时采用302状态码,其他情况使用307来替代301

303: 通常作为PUT或POST操作的返回结果,它表示重定向链接指向的不是新上传的资源,而是另外一个页面 而请求重定向页面的方法要总是使用GET

307: 状态码307与302之间的唯一区别在于,当发送重定向请求的时候,307状态码可以确保请求方法和消息主体不会发生变化

308: 在重定向过程中,请求方法和消息主体不会发生改变,然而在返回301状态码的情况下,请求方法有时候会被客户端错误地修改为GET方法。

能够发现 MDN 的描述和 redirectBehavior 源码的设计非常吻合

2.2 重定向的检查策略: checkRedirect

当没有指定 CheckRedirect 函数时, Client 会使用 defaultCheckRedirect 策略: 默认最多重定向十次

func (c *Client) checkRedirect(req *Request, via []*Request) error {
	fn := c.CheckRedirect
	if fn == nil {
		fn = defaultCheckRedirect
	}
	return fn(req, via)
}

func defaultCheckRedirect(req *Request, via []*Request) error {
	if len(via) >= 10 {
		return errors.New("stopped after 10 redirects")
	}
	return nil
}
复制代码

2.3 处理重定向请求头: makeHeadersCopier

这个方法通过 闭包 的方式, 完成了上面提到的对请求头的处理:

  • 当重定向地址和初始地址的域名不同且也不是子域时, 请求头中的 cookie , authorization 等敏感字段将被忽略

  • 重定向可能会改变初始请求中的 cookie 值, 因此转发 cookie 请求头时将忽略任何有变化的 cookie

func (c *Client) makeHeadersCopier(ireq *Request) func(*Request) {
	// 克隆一份header
	var (
		ireqhdr  = ireq.Header.Clone()
		icookies map[string][]*Cookie
	)
	// 再维护一个cookie哈希表
	if c.Jar != nil && ireq.Header.Get("Cookie") != "" {
		icookies = make(map[string][]*Cookie)
		for _, c := range ireq.Cookies() {
			icookies[c.Name] = append(icookies[c.Name], c)
		}
	}
	// The previous request
	preq := ireq 
	
	// 调用返回一个接受req的函数, 用于对拷贝header进行处理
	return func(req *Request) {
		if c.Jar != nil && icookies != nil {
			var changed bool
			resp := req.Response
			// 如果响应中的set-cookie操作设定的cookie名称存在于cookie哈希表中, 从哈希表中删除它
			for _, c := range resp.Cookies() {
				if _, ok := icookies[c.Name]; ok {
					delete(icookies, c.Name)
					changed = true
				}
			}
			// 忽略所有变化的cookie, 重新组装cookie请求头字段
			if changed {
				ireqhdr.Del("Cookie")
				var ss []string
				for _, cs := range icookies {
					for _, c := range cs {
						ss = append(ss, c.Name+"="+c.Value)
					}
				}
				sort.Strings(ss) // Ensure deterministic headers
				ireqhdr.Set("Cookie", strings.Join(ss, "; "))
			}
		}

		for k, vv := range ireqhdr {
		        // 对于非同域或子域, 敏感请求头的处理
			if shouldCopyHeaderOnRedirect(k, preq.URL, req.URL) {
				req.Header[k] = vv
			}
		}
		// Update previous Request with the current request
		preq = req 
	}
}
复制代码

顺便贴一下对于非同域或子域, 敏感请求头的处理, 比较简单易懂

func shouldCopyHeaderOnRedirect(headerKey string, initial, dest *url.URL) bool {
	switch CanonicalHeaderKey(headerKey) {
	case "Authorization", "Www-Authenticate", "Cookie", "Cookie2":
		ihost := canonicalAddr(initial)
		dhost := canonicalAddr(dest)
		return isDomainOrSubdomain(dhost, ihost)
	}
	// All other headers are copied:
	return true
}

func isDomainOrSubdomain(sub, parent string) bool {
	if sub == parent {
		return true
	}
	if !strings.HasSuffix(sub, parent) {
		return false
	}
	return sub[len(sub)-len(parent)-1] == '.'
}
复制代码

2.4 http.Get 等方法的背后

从源码中可以找到, http.Get 等方法都是 DefaultClient 包装后的一层

var DefaultClient = &Client{}

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

func Post(url, contentType string, body io.Reader) (resp *Response, err error) {
	return DefaultClient.Post(url, contentType, body)
}

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

func Head(url string) (resp *Response, err error) {
	return DefaultClient.Head(url)
}
复制代码

而这些方法最终调用的都是 Client.Do 方法, 这部分可以说十分开胃了

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 (c *Client) Post(url, contentType string, body io.Reader) (resp *Response, err error) {
	req, err := NewRequest("POST", url, body)
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", contentType)
	return c.Do(req)
}

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()))
}

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

2.5 Client.Do 方法

func (c *Client) Do(req *Request) (*Response, error) {
	return c.do(req)
}
复制代码

开始分析 Client.do 方法, 该方法大概200行, 为了避免对流程分析的影响, 将去除错误处理部分代码, 简化代码如下

func (c *Client) do(req *Request) (retres *Response, reterr error) {
	var (
		deadline      = c.deadline()
		reqs          []*Request
		resp          *Response
		copyHeaders   = c.makeHeadersCopier(req)
		reqBodyClosed = false
		redirectMethod string
		includeBody    bool
	)
	for {
		if len(reqs) > 0 {
			loc := resp.Header.Get("Location")
			ireq := reqs[0]
			req = &Request{
				Method:   redirectMethod,
				Response: resp,
				URL:      u,
				Header:   make(Header),
				Host:     host,
				Cancel:   ireq.Cancel,
				ctx:      ireq.ctx,
			}
			if includeBody && ireq.GetBody != nil {
				req.Body, err = ireq.GetBody()
				req.ContentLength = ireq.ContentLength
			}

			copyHeaders(req)

			err = c.checkRedirect(req, reqs)
			if err == ErrUseLastResponse {
				return resp, nil
			}
			resp.Body.Close()
		}

		reqs = append(reqs, req)
		var err error
		var didTimeout func() bool
		resp, didTimeout, err = c.send(req, deadline)

		var shouldRedirect bool
		redirectMethod, shouldRedirect, includeBody = redirectBehavior(req.Method, resp, reqs[0])
		if !shouldRedirect {
			return resp, nil
		}

		req.closeBody()
	}
}
复制代码
  1. 拷贝 req 中的全部 headers , 返回一个函数 copyHeaders , 出现重定向时根据规则处理一部分请求头字段

  2. reqs = append(reqs, req) , 将请求记录到 reqs 数组中, 第一个 req 一定是最原始的请求, 后面的 req 一定都是重定向的~

  3. resp = c.send(req) , 正儿八经发起请求, 得到 resp

  4. redirectBehavior(req.Method, resp, reqs[0]) , 根据响应判断是否需要重定向, 如果不需要, 流程结束, 如果需要, 继续向下

  5. 进入 if len(reqs) > 0 分支, 开始对重定向的处理

  6. resp 中获取 Location 字段, 结合原始请求 reqs[0] 组装出新的重定向请求, 并赋值给 req

  7. copyHeaders(req) , 比对 reqs[0]req , 根据上面提到的两条规则去除特定的字段

  8. c.checkRedirect(req, reqs) 判断是否符合重定向策略, 如果不符合, 返回最后一个resp, 不再继续重定向

  9. 再次执行步骤1-4

3. 总结

源码地址

虽然是个前端, 第一次看Go源码, 体验还是非常爽的, 800行代码, 400行注释, 量也不是很大QAQ。

总结一下经验:

  1. 带着问题看源码, 便于确定方向

  2. 沿着主分支分析, 去除/跳过一些旁枝末节和错误处理的代码

  3. DEBUG是能最快确定流程的方式

我来评几句
登录后评论

已发表评论数()

相关站点

热门文章