☰
Go语言如何精确捕获HTTP重定向链路与状态码
2026/10/13 4:50:10 网站建设 项目流程

1. 从一个真实需求说起:为什么“拿到重定向”这件事没那么简单

做后端或者写爬虫的朋友,大概率都遇到过这样的场景:你发出去一个请求,对方服务器回了个 302,浏览器或者客户端自动跟着跳到了新地址,最后你拿到的是最终页面的内容。整个过程看起来丝滑顺畅,但当你真正想搞清楚“它到底跳了几次、每次跳到了哪里、中间有没有被改写”的时候,就会发现事情没那么简单。

Golang获取重定向信息这个标题,说的就是这么一件事。它不是一个花哨的功能,而是网络编程里非常底层、非常实用的一环。核心要解决的问题是:在 Go 语言里,如何精确地捕获 HTTP 请求过程中发生的每一次重定向,拿到状态码、Location 头、中间跳转链路,甚至控制跳转行为本身。

这件事能用在哪些地方?我随便举几个我实际碰到的:

  • 做接口联调时,对方接口配了个 301,但前端拿到的响应和预期对不上,需要定位到底跳去了哪。
  • 写数据采集工具时,需要记录某个链接的完整跳转路径,判断是否存在异常跳转或循环跳转。
  • 做网关或代理类服务时,需要决定是“跟随重定向”还是“把重定向原样透传给上游”。
  • 做链接健康检查时,需要区分 301、302、307、308 这几种重定向的语义差异,不能一概而论。

适合谁来参考?我认为只要写过 Go 的 HTTP 请求,哪怕只是用过http.Get,都能从这篇里拿到东西。新手可以搞清楚net/http默认帮你做了什么“隐藏操作”,老手可以复习一下CheckRedirect这个容易被忽略但极其关键的钩子。

我先把结论抛出来:Go 标准库net/http对重定向的处理是默认自动跟随的,最多跟 10 次,超过就报错。想拿到中间信息,核心就两个抓手——自定义http.Client的CheckRedirect函数,以及通过httptrace追踪请求生命周期。下面我会把这两条路都拆开讲透,并且补上大量实际踩坑经验。

2. 先搞懂 Go 的默认行为:不搞清楚这个,后面全是坑

2.1 默认客户端到底替你做了什么

很多人写 Go 的 HTTP 请求,习惯直接用http.Get或者http.DefaultClient。这两个东西背后其实是同一个默认客户端,它的重定向策略是写死的:

  • 遇到 301、302、303、307、308 这几种状态码,自动发起下一次请求。
  • 最多连续跟随 10 次,第 11 次直接返回错误stopped after 10 redirects。
  • 每次跳转时,请求方法可能会被改写。比如 301/302/303 在历史上会把 POST 改成 GET,而 307/308 会保留原方法和请求体。

这里有个特别容易踩的坑:默认行为下,你拿到的resp.Request.URL是最终地址,但中间跳转的 Location 你一个都看不到。我见过不少同学拿到最终响应后,想反推跳转链路,结果只能靠猜。这就是为什么必须自己接管重定向逻辑。

还有一个隐蔽点:默认客户端在跟随重定向时,会把原始请求的 Header 带过去,但Authorization这类敏感头在跨域跳转时会被剥离。这个行为是出于安全考虑,但如果你在做内部服务联调,发现认证头“莫名其妙丢了”,八成就是重定向导致的。

2.2 重定向状态码的语义差异,别混为一谈

在动手写代码前,必须把几个状态码的语义分清楚,否则你的判断逻辑一定是错的。我整理了一张表,这是我实际排查问题时反复对照的:

状态码语义方法是否改变典型场景
301永久重定向历史上会改成 GET域名迁移、http 跳 https
302临时重定向历史上会改成 GET登录后跳转、活动页
303见其他地址强制改成 GETPOST 后跳结果页
307临时重定向保留原方法需要保留 POST 体的临时跳转
308永久重定向保留原方法需要保留 POST 体的永久跳转

注意:301 和 302 对 POST 的处理在 RFC 里其实是有争议的,不同客户端实现不一致。Go 的处理是遵循主流浏览器行为,301/302/303 会把非 GET/HEAD 方法改成 GET。如果你依赖 POST 体做跳转,务必用 307 或 308。

理解这张表的意义在于:当你在CheckRedirect里做判断时,不能只看“是不是 3xx”,而要根据状态码决定是否继续、是否保留请求体。我踩过一次坑,对方接口返回 302,我以为是临时跳转就放心跟随,结果 POST 体被丢了,服务端拿不到参数,排查了半天才发现是状态码语义问题。

2.3 为什么默认最多 10 次,这个数字能改吗

10 次这个限制是 Go 标准库硬编码的,写在net/http/client.go里。它的存在是为了防止无限重定向导致死循环。这个数字本身不能通过配置修改,但你可以通过自定义CheckRedirect完全接管这个逻辑——想跟 100 次也行,想跟 0 次也行。

我的建议是:不要轻易放开这个限制。如果你确实需要跟随很多次,也应该在CheckRedirect里加上自己的计数和超时判断,否则遇到恶意构造的循环跳转,你的程序会一直打请求,既浪费资源又可能触发对方的风控。

3. 核心武器一:用 CheckRedirect 精确捕获每一次跳转

3.1 CheckRedirect 的调用时机和参数含义

CheckRedirect是http.Client结构体里的一个函数字段,签名是这样的:

CheckRedirect func(req *Request, via []*Request) error

它的调用时机非常关键:在客户端准备发起下一次跳转请求之前被调用。也就是说,每次要跳的时候,Go 都会先问你一句“要不要跳”。

两个参数的含义:

  • req:即将发起的那次请求,注意它的 URL 已经是跳转后的新地址了。
  • via:已经发生过的请求列表,按时间顺序排列,via[0]是最初的请求,via[len(via)-1]是上一次的请求。

返回值决定行为:

  • 返回nil:继续跳转。
  • 返回http.ErrUseLastResponse:停止跳转,把上一次的响应原样返回,不报错。
  • 返回其他 error:停止跳转,并把该 error 包装后返回。

这里有个细节很多人不知道:via里不包含即将发起的这次请求,它只记录历史。所以如果你想统计“总共跳了几次”,用len(via)就够了,但要注意第一次调用时via长度为 1(只有初始请求)。

3.2 一个能打印完整跳转链路的完整示例

光说理论没意思,直接上代码。下面这个例子是我在实际项目里用过的简化版,能打印出每一次跳转的状态码和地址:

package main import ( "fmt" "net/http" ) func main() { client := &http.Client{ CheckRedirect: func(req *http.Request, via []*http.Request) error { fmt.Printf("第 %d 次跳转\n", len(via)) fmt.Printf(" 从: %s\n", via[len(via)-1].URL.String()) fmt.Printf(" 到: %s\n", req.URL.String()) // 打印上一次响应的状态码 if len(via) > 0 { // 注意:这里拿不到上一次的响应对象,只能通过其他方式 } if len(via) >= 5 { return fmt.Errorf("跳转次数超过 5 次,主动终止") } return nil }, } resp, err := client.Get("http://example.com/start") if err != nil { fmt.Println("请求出错:", err) return } defer resp.Body.Close() fmt.Println("最终地址:", resp.Request.URL.String()) fmt.Println("最终状态码:", resp.StatusCode) }

这段代码能跑,但有个明显的短板:在CheckRedirect里拿不到上一次响应的状态码和 Header。因为via里存的是*http.Request,不是*http.Response。这是个很常见的困惑点,我当初也卡在这里。

3.3 怎么在跳转过程中拿到状态码和 Location

既然CheckRedirect拿不到响应,那想记录每次跳转的状态码怎么办?有两个思路。

思路一:用httptrace配合。httptrace能追踪到请求的各个阶段,包括收到响应头。但它和CheckRedirect的配合需要一点技巧,后面第 4 章会详细讲。

思路二:手动接管跳转,不用自动跟随。也就是把CheckRedirect设成返回http.ErrUseLastResponse,这样客户端不自动跳,你自己拿到 3xx 响应后,读取Location头,再手动发下一次请求。这种方式最灵活,能拿到每一次的完整响应。

我个人的选择是:如果只是想知道跳转链路,用思路一;如果要对每次跳转做精细控制(比如改 Header、记录状态码、处理 Cookie),用思路二。下面给一个思路二的完整实现:

package main import ( "fmt" "io" "net/http" ) func main() { client := &http.Client{ CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, } currentURL := "http://example.com/start" maxHops := 10 for i := 0; i < maxHops; i++ { req, err := http.NewRequest("GET", currentURL, nil) if err != nil { fmt.Println("构造请求失败:", err) return } resp, err := client.Do(req) if err != nil { fmt.Println("请求失败:", err) return } fmt.Printf("第 %d 跳: %s -> 状态码 %d\n", i+1, currentURL, resp.StatusCode) // 不是重定向,结束 if resp.StatusCode < 300 || resp.StatusCode >= 400 { body, _ := io.ReadAll(resp.Body) resp.Body.Close() fmt.Println("最终内容长度:", len(body)) return } // 是重定向,读取 Location location := resp.Header.Get("Location") resp.Body.Close() if location == "" { fmt.Println("重定向但没 Location 头,终止") return } // 处理相对路径 if location[0] == '/' { // 简化处理,实际应该用 url.Parse 和 ResolveReference location = "http://example.com" + location } fmt.Printf(" Location: %s\n", location) currentURL = location } fmt.Println("达到最大跳转次数,终止") }

这段代码的好处是每一步都清清楚楚,状态码、Location、最终内容长度全都能拿到。代价是要自己处理相对路径、Cookie 传递、方法改写等细节,工作量比自动跟随大。

提示:处理相对路径时,别用字符串拼接,一定要用net/url包的ResolveReference,否则遇到../或者带 query 的路径会出错。这是我踩过的坑。

4. 核心武器二:用 httptrace 追踪请求全生命周期

4.1 httptrace 能追踪哪些阶段

net/http/httptrace是 Go 1.7 引入的,专门用来追踪单次 HTTP 请求的生命周期。它能挂钩的阶段包括:

  • GetConn/GotConn:获取和拿到连接
  • DNSStart/DNSDone:DNS 解析
  • ConnectStart/ConnectDone:TCP 连接
  • WroteHeaders/WroteRequest:写请求
  • GotFirstResponseByte:收到第一个响应字节
  • Got100Continue:收到 100 Continue

看起来和重定向没直接关系,但关键在于:每次重定向都是一次新的请求,httptrace 会为每次请求都触发一遍这些钩子。所以你可以通过GotConn或者GotFirstResponseByte来感知“又发生了一次请求”,从而间接统计跳转次数。

不过要注意,httptrace本身不直接告诉你状态码,它只告诉你“连接建立了”“响应头开始来了”。想拿状态码还是得结合响应对象。

4.2 把 httptrace 和 CheckRedirect 结合起来的实战写法

下面这个写法是我比较推荐的,既能拿到跳转链路,又能感知每次请求的连接信息:

package main import ( "context" "fmt" "net/http" "net/http/httptrace" ) func main() { hopCount := 0 client := &http.Client{ CheckRedirect: func(req *http.Request, via []*http.Request) error { hopCount++ fmt.Printf("准备第 %d 次跳转,目标: %s\n", hopCount, req.URL.String()) if hopCount > 5 { return fmt.Errorf("跳转过多") } return nil }, } req, _ := http.NewRequest("GET", "http://example.com/start", nil) trace := &httptrace.ClientTrace{ GotConn: func(info httptrace.GotConnInfo) { fmt.Printf(" 连接已建立,复用: %v\n", info.Reused) }, GotFirstResponseByte: func() { fmt.Println(" 收到第一个响应字节") }, DNSStart: func(info httptrace.DNSStartInfo) { fmt.Printf(" 开始解析 DNS: %s\n", info.Host) }, } req = req.WithContext(httptrace.WithClientTrace(context.Background(), trace)) resp, err := client.Do(req) if err != nil { fmt.Println("出错:", err) return } defer resp.Body.Close() fmt.Println("最终地址:", resp.Request.URL.String()) }

这个组合的妙处在于:CheckRedirect负责跳转决策,httptrace负责底层观测。两者互不干扰,信息互补。实测下来,对于排查“为什么跳转这么慢”“连接有没有复用”这类问题特别有用。

4.3 一个容易忽略的细节:trace 只对单次请求生效

这里有个大坑我必须提醒:httptrace.WithClientTrace绑定的是单个*http.Request,重定向产生的新请求默认不会继承这个 trace。也就是说,如果你只给初始请求加了 trace,跳转后的请求是追踪不到的。

解决办法是在CheckRedirect里给新请求重新绑定 trace:

CheckRedirect: func(req *http.Request, via []*http.Request) error { req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace)) return nil },

注意这里用的是req.Context()而不是context.Background(),这样能保留原有的取消信号和超时。这个细节我在官方文档里没看到明确说明,是自己调试时发现的,分享出来能帮大家少走弯路。

5. 完整实操:从零搭一个重定向信息采集器

5.1 需求拆解和方案设计

假设我们要做一个工具,输入一个 URL,输出它的完整跳转链路,包括每一跳的状态码、地址、耗时。这个需求在链接巡检、SEO 分析、接口调试里都很常见。

方案设计上,我选择手动接管跳转(即CheckRedirect返回ErrUseLastResponse),理由有三:

  1. 需要拿到每一跳的状态码,自动跟随拿不到。
  2. 需要记录每一跳的耗时,手动控制更灵活。
  3. 需要处理相对路径和 Cookie,手动流程更可控。

整体流程是:循环发请求 → 判断是否 3xx → 读取 Location → 解析并拼接新地址 → 记录信息 → 继续。直到遇到非 3xx 或达到上限。

5.2 关键代码实现和参数说明

package main import ( "fmt" "net/http" "net/url" "time" ) type HopInfo struct { Index int URL string StatusCode int Location string Duration time.Duration } func traceRedirects(startURL string, maxHops int, timeout time.Duration) ([]HopInfo, error) { client := &http.Client{ Timeout: timeout, CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, } var hops []HopInfo currentURL := startURL for i := 0; i < maxHops; i++ { req, err := http.NewRequest("GET", currentURL, nil) if err != nil { return hops, fmt.Errorf("构造请求失败: %w", err) } req.Header.Set("User-Agent", "RedirectTracer/1.0") start := time.Now() resp, err := client.Do(req) if err != nil { return hops, fmt.Errorf("请求失败: %w", err) } duration := time.Since(start) hop := HopInfo{ Index: i + 1, URL: currentURL, StatusCode: resp.StatusCode, Duration: duration, } if resp.StatusCode >= 300 && resp.StatusCode < 400 { hop.Location = resp.Header.Get("Location") } hops = append(hops, hop) resp.Body.Close() if hop.Location == "" { break } // 解析相对路径 base, err := url.Parse(currentURL) if err != nil { return hops, fmt.Errorf("解析当前 URL 失败: %w", err) } ref, err := url.Parse(hop.Location) if err != nil { return hops, fmt.Errorf("解析 Location 失败: %w", err) } currentURL = base.ResolveReference(ref).String() } return hops, nil } func main() { hops, err := traceRedirects("http://example.com/start", 10, 10*time.Second) if err != nil { fmt.Println("出错:", err) } for _, h := range hops { fmt.Printf("[%d] %s -> %d (耗时 %v)\n", h.Index, h.URL, h.StatusCode, h.Duration) if h.Location != "" { fmt.Printf(" Location: %s\n", h.Location) } } }

几个关键参数的选择理由:

  • maxHops设为 10,和 Go 默认保持一致,避免无限循环。
  • timeout设为 10 秒,这是单次请求的超时,不是整个链路的总超时。如果要控制总时长,得自己在外层加计时。
  • User-Agent显式设置,因为有些服务器对空 UA 会返回 403,导致你以为是重定向问题,其实是 UA 问题。

5.3 实测结果和输出解读

拿一个真实的跳转场景跑一下,输出大概长这样:

[1] http://example.com/start -> 301 (耗时 120ms) Location: https://example.com/start [2] https://example.com/start -> 302 (耗时 95ms) Location: /home [3] https://example.com/home -> 200 (耗时 88ms)

从输出能读出几个信息:第一跳是 http 到 https 的永久跳转,第二跳是临时跳转到相对路径/home,第三跳拿到最终内容。总耗时约 300ms,其中第一跳最慢,因为涉及协议切换和可能的 DNS 重解析。

这种输出对排查问题非常直观。我曾经用类似工具发现某个接口在跳转时把 query 参数丢了,就是因为 Location 里没带 query,而服务端又依赖 query 做鉴权,问题一下就定位了。

6. 常见问题与排查技巧实录

6.1 跳转链路排查速查表

现象可能原因排查方法
拿不到中间跳转用了默认客户端自动跟随自定义 CheckRedirect
跳转后 POST 体丢失301/302/303 会改方法改用 307/308 或手动处理
认证头消失跨域跳转剥离敏感头检查跳转是否跨域
报错 stopped after 10 redirects超过默认上限自定义 CheckRedirect 并加计数
Location 是相对路径服务端返回相对地址用 url.ResolveReference 解析
跳转后 Cookie 丢失未传递 Cookie用 http.CookieJar 或手动带
trace 只对第一跳生效trace 绑定单请求在 CheckRedirect 里重新绑定

6.2 几个我踩过的坑和独家技巧

坑一:resp.Request.URL和resp.Header.Get("Location")不是一回事。自动跟随时,resp.Request.URL是最终地址,而Location头在最终响应里通常是空的。想拿中间 Location,必须手动接管。

坑二:相对路径拼接别用字符串。我见过有人写baseURL + location,遇到Location: ../other直接拼错。老老实实用url.Parse加ResolveReference,这是标准做法。

坑三:超时设置要分层。http.Client.Timeout是单次请求的超时,如果你手动循环 10 次,总耗时可能是 10 倍。要控制总时长,得用context.WithTimeout包住整个循环。

技巧一:用httptrace的GotConn判断连接复用。如果每次跳转都新建连接,说明连接池没生效,可能是 Host 变了或者被服务端关闭了。这个信息对性能优化很有价值。

技巧二:记录每一跳的耗时,能快速定位慢在哪。有时候跳转本身很快,但某一跳特别慢,可能是 DNS 或者 TLS 握手的问题。把耗时打出来,一眼就能看出来。

技巧三:对 307/308 要特别小心。这两种状态码会保留请求体,如果你的请求体是一个io.Reader,第一次读完之后第二次就读不到了。解决办法是把 body 读进内存,每次跳转重新构造bytes.NewReader。

7. 进阶场景:什么时候该跟随,什么时候该透传

7.1 网关和代理场景下的取舍

在网关或反向代理里,重定向的处理策略和普通客户端完全不同。普通客户端通常希望“跟随到最终地址”,而网关往往需要“把重定向原样透传给上游”。原因很简单:网关不知道上游客户端的能力和意图,擅自跟随可能改变语义。

在 Go 里实现透传,核心就是把CheckRedirect设成返回http.ErrUseLastResponse,然后把 3xx 响应连同Location头一起写回给客户端。注意要保留原始状态码,不能统一改成 302,否则 301 的永久语义就丢了。

7.2 采集场景下的策略选择

做数据采集时,我的经验是:默认跟随,但记录链路。因为采集的目标通常是最终内容,但链路信息对去重和异常检测很有用。如果发现某个 URL 跳转次数异常多,或者跳到了意料之外的域名,就要警惕是不是被劫持或者遇到了软 404。

另外,采集场景要特别注意 Cookie 的传递。很多站点靠 Cookie 维持会话,跳转过程中如果 Cookie 丢了,可能被踢回登录页。用http.CookieJar能自动处理,但要注意并发安全。

7.3 一个容易被忽视的安全点

跟随重定向时,如果目标地址是用户可控的,就可能被利用来做 SSRF(服务端请求伪造)。攻击者构造一个指向内网地址的重定向,你的服务端就会乖乖去请求内网资源。防范方法是在CheckRedirect里校验目标地址,拒绝内网 IP 和非法域名。

这个点在实际开发中经常被忽略,但只要你的服务会跟随外部传入的 URL,就必须考虑。我的做法是维护一个白名单或者黑名单,在每次跳转前检查req.URL.Host。

8. 我个人在实际操作中的几点体会

写了这么多,最后分享几个纯个人经验,都是文档里不会写的。

第一,别迷信默认行为。Go 的默认重定向策略对大多数场景够用,但一旦你需要“知道发生了什么”,就必须接管。接管得越早,后面排查越轻松。

第二,日志要打全。每次跳转的源地址、目标地址、状态码、耗时,一个都别省。我现在的项目里,重定向日志是标配,出问题时直接看日志就能定位,不用重现。

第三,测试用例要覆盖边界。循环跳转、相对路径、跨域、307 保留 body,这几个场景我都写过专门的测试。别等到线上出问题才补,那时候成本高得多。

第四,httptrace是个宝藏但别滥用。它的钩子函数会在每次请求时触发,如果里面做了重操作,会拖慢整体性能。我一般只在调试或者特定监控场景才开。

这个内容后续还可以这样扩展:把跳转链路做成可视化,或者接入监控系统做异常告警。但那是另一个话题了,先把基础的抓取和控制做扎实,比什么都强。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询