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。
虽然后面对 URL、Header、Trailer 等字段做了深拷贝,但 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
}