1. 起因
分析http.Client
源码实现的起因, 是因为在使用如下步骤模拟网站登录时, 出现了问题, 参考知乎 - go net/http.Client 处理redirect:
-
POST
账号密码等参数进行登录 -
下发
token
, 此token
通过cookie
下发 -
重定向到主页
/
在通过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()
}
}
复制代码
-
拷贝
req
中的全部headers
, 返回一个函数copyHeaders
, 出现重定向时根据规则处理一部分请求头字段 -
reqs = append(reqs, req)
, 将请求记录到reqs
数组中, 第一个req
一定是最原始的请求, 后面的req
一定都是重定向的~ -
resp = c.send(req)
, 正儿八经发起请求, 得到resp
-
redirectBehavior(req.Method, resp, reqs[0])
, 根据响应判断是否需要重定向, 如果不需要, 流程结束, 如果需要, 继续向下 -
进入
if len(reqs) > 0
分支, 开始对重定向的处理 -
从
resp
中获取Location
字段, 结合原始请求reqs[0]
组装出新的重定向请求, 并赋值给req
-
copyHeaders(req)
, 比对reqs[0]
和req
, 根据上面提到的两条规则去除特定的字段 -
c.checkRedirect(req, reqs)
判断是否符合重定向策略, 如果不符合, 返回最后一个resp, 不再继续重定向 -
再次执行步骤1-4
3. 总结
虽然是个前端, 第一次看Go源码, 体验还是非常爽的, 800行代码, 400行注释, 量也不是很大QAQ。
总结一下经验:
-
带着问题看源码, 便于确定方向
-
沿着主分支分析, 去除/跳过一些旁枝末节和错误处理的代码
-
DEBUG是能最快确定流程的方式
有疑问加站长微信联系(非本文作者)