Go标准库Request.Clone后Host字段的坑

Author avatarShuakami
5 分钟阅读

UapiPro

免费 Api 项目,拥有 77 个接口,550万+ 调用量

访问项目

问题是这样的

我需要从 GitHub 私有仓库获取一些配置文件。因为是私有仓库嘛,就得在请求头里加 Authorization: token xxx

但访问 GitHub raw 文件经常会慢或者超时,所以我做了个代理层,会自动三级降级,每一级是我们在全国各地的节点。

代理的原理很简单,就是把原本要访问的 URL 拼到代理地址后面。比如:

原始: https://raw.githubusercontent.com/user/repo/file.txt
代理: https://proxy.com/https://raw.githubusercontent.com/user/repo/file.txt

代码也很简单对吧,用 req.Clone(ctx) 复制一份请求,改个 URL 就完事了:

func (c *ProxyClient) Do(ctx context.Context, req *http.Request) (*http.Response, error) {
    targetURL := req.URL.String()
    proxyReq := req.Clone(ctx)
    proxyReq.URL, _ = url.Parse(fmt.Sprintf("%s/%s", proxyURL, targetURL))
    
    return c.httpClient.Do(proxyReq)
}

看起来没问题对吧?我也觉得。

然后跑起来一测试,代理死活返回 402 错误:

Payment required

DEPLOYMENT_DISABLED

而且明明 token 是对的,直接访问 GitHub 也是 200 成功。

开始排查

一开始我以为是代理的问题。我用 curl 测了一下,结果是 200 成功...

那就奇怪了。curl 能访问,Go 的代码不行?

我又写了个最简单的测试,不用我的代理客户端,直接用 Go 标准库的 http.Client

// 用 Clone
req1, _ := http.NewRequest("GET", originalURL, nil)
req1.Header.Set("Authorization", "token xxx")

cloned := req1.Clone(ctx)
cloned.URL, _ = url.Parse(proxyURL)

resp1, _ := client.Do(cloned)  // 402
// 直接创建新请求
req2, _ := http.NewRequest("GET", proxyURL, nil)
req2.Header.Set("Authorization", "token xxx")

resp2, _ := client.Do(req2)  // 200

好家伙,用 Clone 就 402,直接 NewRequest 就 200?

于是我打印了两个请求的所有字段对比:

// Clone的请求
fmt.Println("Host:", cloned.Host)          // raw.githubusercontent.com(旧的)
fmt.Println("URL.Host:", cloned.URL.Host)  // proxy.com(新的)

// 新创建的请求
fmt.Println("Host:", newReq.Host)          // proxy.com
fmt.Println("URL.Host:", newReq.URL.Host)  // proxy.com

真相大白了!

req.Clone(ctx) 会把所有字段都复制过来,包括 req.Host

当你修改 req.URL 时,req.Host 不会自动更新

而 HTTP 请求发送时,如果 req.Host 不为空,会优先用它作为 Host header,而不是 req.URL.Host

所以代理服务器收到的请求是这样的:

GET /https://raw.githubusercontent.com/... HTTP/1.1
Host: raw.githubusercontent.com <- 饿啊,旧的Host
Authorization: token xxx

代理一看:你要访问 raw.githubusercontent.com,但 URL 路径却是 /https://raw.githubusercontent.com/...

这明显不对啊,给你个 402 报错。

为什么curl没问题?

因为 curl 会自动从 URL 提取 Host header。你用 -v 看一下就知道了:

curl -v https://proxy.com/https://raw.githubusercontent.com/...
> Host: proxy.com    <- 对的)

Go 的 http.NewRequest 也会自动填充 req.Host,所以没问题。

只有 req.Clone() 修改 URL 后不更新 Host,才会出这个坑。

解决办法

知道原因就好办了。Clone 后手动清空 Host 字段,让 Go 自动从 URL.Host 填充:

proxyReq := req.Clone(ctx)
proxyReq.URL, _ = url.Parse(proxyURL)
proxyReq.Host = "" // 清空力

resp, _ := client.Do(proxyReq)  // 200

或者手动设置成新的 Host:

proxyReq.Host = proxyReq.URL.Host

两种方法都可以。我选了第一种,更简洁。

这算不算Go的bug?

不是,这是 Clone 的设计。

Go 的官方文档这样描述 Clone 方法:

Clone returns a deep copy of r with its context changed to ctx.

Clone only makes a shallow copy of the Body field.

文档只说了:

  • 返回一个深拷贝
  • 只对 Body 字段做浅拷贝

完全没有提到 Host 字段的行为。也没说修改 URL 后 Host 会不会自动更新。

....

但这个行为确实很容易踩坑,因为大部分人(包括我)会觉得修改了 URL,Host 应该跟着变才对。

我翻了一下 Go 的源码,Request.Clone() 的实现是这样的:

func (r *Request) Clone(ctx context.Context) *Request {
    if ctx == nil {
        panic("nil context")
    }
    r2 := new(Request)
    *r2 = *r  // 浅拷贝所有字段(L391)
    r2.ctx = ctx
    r2.URL = cloneURL(r.URL)
    r2.Header = r.Header.Clone()
    r2.Trailer = r.Trailer.Clone()
    // ...
    return r2
}

关键就在 *r2 = *r,这会把原始请求的所有字段都浅拷贝过来,包括 req.Host

虽然后面对 URLHeaderTrailer 等字段做了深拷贝,但 req.Host 并不在深拷贝列表中。

所以:

  • 文档说的"deep copy"指的是整体行为(大部分重要字段都深拷贝了)
  • req.Host 作为一个简单的 string 字段,被 *r2 = *r 直接复制了
  • 修改 req.URL 不会触发任何钩子去更新 req.Host

完结撒花

这个问题排查了快半个小时,最搞笑的是,我一度怀疑是不是发现了什么 Go 标准库的重大 bug --)

希望这篇文章能帮你避开这个坑。

Ciallo~

最后放个测试代码

如果你也想复现这个问题,可以用这段代码:

package main

import (
    "context"
    "fmt"
    "net/http"
    "net/url"
)

func main() {
    ctx := context.Background()
    originalURL := "https://example.com/path"
    proxyURL := "https://proxy.com/https://example.com/path"
    
    // Clone(会出问题)
    req1, _ := http.NewRequest("GET", originalURL, nil)
    cloned := req1.Clone(ctx)
    cloned.URL, _ = url.Parse(proxyURL)
    
    fmt.Println("Clone方式")
    fmt.Println("req.Host    :", cloned.Host)       // example.com(旧的)
    fmt.Println("req.URL.Host:", cloned.URL.Host)   // proxy.com(新的)
    
    // 直接创建(没问题)
    req2, _ := http.NewRequest("GET", proxyURL, nil)
    
    fmt.Println("NewRequest方式")
    fmt.Println("req.Host    :", req2.Host)         // proxy.com
    fmt.Println("req.URL.Host:", req2.URL.Host)     // proxy.com
    
    // Clone后修复
    req3, _ := http.NewRequest("GET", originalURL, nil)
    fixed := req3.Clone(ctx)
    fixed.URL, _ = url.Parse(proxyURL)
    fixed.Host = ""
    
    fmt.Println("Clone后修复")
    fmt.Println("req.Host    :", fixed.Host)        // 空
    fmt.Println("req.URL.Host:", fixed.URL.Host)    // proxy.com
    
}