Go反向代理默认不支持路径重写,需自定义Director函数显式修改req.URL.Path和RawPath,并注意前缀匹配顺序、Body重放及多路由查表设计。

Go反向代理默认不支持路径重写,必须手动修改req.URL
Go标准库的httputil.NewSingleHostReverseProxy只做基础转发,不会自动改写req.URL.Path。比如你配置路由/api/v1/ → http://backend:8080/,请求GET /api/v1/users会原样发到后端/api/v1/users,而不是期望的/users。这是因为ReverseProxy在Director函数里只设置了req.URL.Host和req.URL.Scheme,没碰Path。
解决方法是传入自定义Director,在其中显式重写req.URL.Path和req.URL.RawPath(后者影响URL编码):
proxy := httputil.NewSingleHostReverseProxy(u)
proxy.Director = func(req *http.Request) {
// 原始请求路径:/api/v1/users
// 目标后端根路径:/
req.URL.Scheme = u.Scheme
req.URL.Host = u.Host
req.URL.Path = strings.TrimPrefix(req.URL.Path, "/api/v1")
req.URL.RawPath = strings.TrimPrefix(req.URL.RawPath, "/api/v1")
}
- 必须同时处理
Path和RawPath,否则带中文或特殊字符的路径会出错 -
TrimPrefix只匹配前缀,不校验是否为完整路径段(比如/api/v11也会被误删),生产环境建议用正则或strings.HasPrefix+ 长度判断 - 重写后记得调用
req.Header.Set("X-Forwarded-Prefix", "/api/v1"),方便后端生成正确跳转链接
多个路由规则怎么组织?别硬写if-else,用map[string]*httputil.ReverseProxy
当有/admin → http://admin-svc、/user → http://user-svc、/api/v2 → http://legacy等多条规则时,直接在Director里堆if容易失控。更清晰的做法是预建好每个目标的ReverseProxy实例,用路径前缀做键查表:
type Router struct {
proxies map[string]*httputil.ReverseProxy
mux *http.ServeMux
}
<p>func (r <em>Router) ServeHTTP(w http.ResponseWriter, req </em>http.Request) {
for prefix, proxy := range r.proxies {
if strings.HasPrefix(req.URL.Path, prefix) {
// 复制请求,避免并发读写req.URL
dup := req.Clone(req.Context())
proxy.Director(dup)
proxy.ServeHTTP(w, dup)
return
}
}
http.NotFound(w, req)
}
- 每个
ReverseProxy应独立配置Director,对应各自路径重写逻辑 - 务必用
req.Clone(),否则多个goroutine可能同时修改同一req.URL导致竞态 - 注意前缀匹配顺序——长前缀(如
/api/v2)要放在短前缀(如/api)前面,否则会被截断
ReverseProxy转发时丢失请求体?检查req.Body是否被提前读取
常见现象:POST/PUT请求到达后端时body为空,curl -X POST -d 'name=foo' /api/v1/user在后端收到空体。根本原因是ReverseProxy内部调用req.ParseForm()或req.FormValue()时,会隐式读取并关闭req.Body,后续转发就无数据可读。
立即学习“go语言免费学习笔记(深入)”;
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
修复方式是在Director中确保req.Body可重放:
proxy.Director = func(req *http.Request) {
// 如果原始body是*bytes.Reader或*strings.Reader,它天然可重放
// 但如果是网络流(*http.body),需缓存
if req.Body != nil && req.Body != http.NoBody {
bodyBytes, _ := io.ReadAll(req.Body)
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
// 同时重置ContentLength,否则后端可能拒绝
req.ContentLength = int64(len(bodyBytes))
req.Header.Set("Content-Length", strconv.Itoa(len(bodyBytes)))
}
// ... 其他重写逻辑
}
- 不要无脑
io.Copy到bytes.Buffer,大文件会OOM;生产环境应设大小上限(如io.LimitReader(req.Body, 10) - 如果后端不需要原始body(比如只校验token),可直接设
req.Body = http.NoBody省资源 - 注意
Transfer-Encoding: chunked请求无法被io.ReadAll安全处理,此时应透传原始req.Body并禁用ParseForm
超时、重试、Header透传这些非功能需求,得自己补全
标准ReverseProxy不处理超时、重试、Header过滤。比如默认没有Client.Timeout,后端卡死会导致整个网关goroutine阻塞;又比如Cookie、Authorization等敏感头默认不透传,需手动设置。
关键补丁点:
// 1. 设置Transport超时
proxy.Transport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
IdleConnTimeout: 30 * time.Second,
}
<p>// 2. 透传指定Header(去掉Hop-by-Hop头)
hopHeaders := []string{"Connection", "Keep-Alive", "Proxy-Authenticate", "Proxy-Authorization", "Te", "Trailers", "Transfer-Encoding", "Upgrade"}
for _, h := range hopHeaders {
req.Header.Del(h)
}
// 3. 添加自定义Header
req.Header.Set("X-Real-IP", getClientIP(req))
req.Header.Set("X-Forwarded-For", req.Header.Get("X-Forwarded-For")+", "+getClientIP(req))
-
Transport的IdleConnTimeout比Timeout更重要——它控制连接复用时的空闲释放,防止TIME_WAIT堆积 - 透传
Authorization头时,确认后端是否需要base64解码(某些鉴权服务要求原始token未被篡改) - 不要依赖
X-Forwarded-For做权限控制,它可被伪造;真实IP应从req.RemoteAddr或边缘负载均衡器加的X-Real-IP获取
路径重写的本质不是“改URL字符串”,而是协调请求路径、后端路由、响应Location头、静态资源引用这四者的映射关系。漏掉任意一环,都可能造成302跳转错乱或CSS加载失败。调试时优先抓包看实际发出的请求路径和响应头,而不是只信日志里的req.URL.Path。


















