
深入 Go 的 net/http
net/http 的 API 使用非常简单,但一次请求仍要经过连接获取、并发读写、Body 回收、超时与取消。沿着客户端和服务端的源码调用链读下去,才能准确解释连接为何没有复用、超时发生在哪一层,以及 Handler 返回后还有哪些工作没有结束。
使用 Go 启动一个 HTTP 服务又或者作为客户端进行调用,都只需要几行代码:
http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "hello")
})
log.Fatal(http.ListenAndServe(":8080", nil))
resp, err := http.Get("https://example.com/")ListenAndServe 负责启动服务端,Get 负责发起客户端请求。它们的 API 很简洁,但内部仍要处理 DNS、TCP、TLS、代理、连接复用、请求序列化、响应解析、重定向、Cookie、压缩、超时和取消。每次 Get 不一定建立新连接,多个请求可能复用同一个 Transport 和连接池;Handler 返回后,Server 也还需要刷新缓冲区、处理请求 Body 并判断连接能否复用。
net/http 是一套分层的状态机。高层的 Client 负责重定向、Cookie 和总超时,低层的 Transport 负责代理、拨号、TLS、连接池和一次往返;每条 HTTP/1 持久连接内部又有读写 goroutine,通过 channel 与发起请求的 goroutine 协作。服务端从 Listener.Accept 开始,为每条连接建立 conn,读取并解析 Request,再把内部 response 以 ResponseWriter 接口交给 Handler。Handler 写出的数据还要经过状态码与 Header 处理、内容类型检测、长度或分块编码、缓冲刷新和连接复用判断。
server.go 和 transport.go 都很长,其中包含兼容性分支、测试钩子和低频协议路径。为了保持阅读顺序,本文沿客户端和服务端两条主要调用链分析:
客户端:Client.Do
-> Client.do
-> Client.send
-> RoundTripper.RoundTrip
-> Transport.roundTrip
-> Transport.getConn
-> persistConn.roundTrip
-> writeLoop / readLoop
服务端:ListenAndServe
-> Server.ListenAndServe
-> Server.Serve
-> Listener.Accept
-> conn.serve
-> conn.readRequest
-> serverHandler.ServeHTTP
-> Handler.ServeHTTP
-> response.finishRequest调用链确定了各组件的执行顺序。Header、Body、超时、取消、HTTP/2 和优雅关闭等机制,随后都可以定位到具体层次。
本文内容可能过时,考古请参考 Go 1.16
一、源码文件与职责划分
net/http 对外是一个包,内部却按职责拆开。阅读时常用的文件大致如下:
| 文件 | 主要职责 |
|---|---|
client.go | Client、RoundTripper、重定向、Cookie 与请求总超时 |
transport.go | HTTP/1 客户端传输、拨号、代理、连接池、读写循环 |
request.go | Request、请求构造、表单解析、请求序列化与解析 |
response.go | Response、响应解析与序列化 |
transfer.go | Content-Length、chunked、Body 边界与 trailer |
server.go | Server、连接生命周期、Handler、ResponseWriter、ServeMux |
header.go | Header 的增删改查、规范化与写出 |
h2_bundle.go | 捆绑进标准库的 HTTP/2 实现 |
cookie.go | Cookie 与 Set-Cookie 的解析和序列化 |
httptest | Handler、Server 和客户端行为测试工具 |
文件边界反映了职责划分。Client 不直接维护 TCP 连接,因为连接由传输层管理;Transport 不负责自动跟随重定向,因为重定向属于客户端策略;Request 和 Response 不独立决定 chunked 编码,因为消息对象与传输边界由不同代码处理。
这种拆分还解释了为何很多配置放错位置后毫无效果。想限制一次完整请求的总时间,应看 Client.Timeout 或请求 Context;只想限制等待响应头,应看 Transport.ResponseHeaderTimeout。想限制服务端读取请求头,应看 Server.ReadHeaderTimeout;想给业务 Handler 一个执行期限,Server.ReadTimeout 并不能完整表达这个意图。名字都带 Timeout,插入调用链的位置完全不同。
二、核心类型:Request、Response 与 Header
客户端和服务端共用 Request,但同一个字段在两个方向上的语义可能不同。源码注释明确把它称为「服务器收到的请求,或客户端将要发送的请求」。这不是单纯的数据传输对象,它同时承载协议字段、Body 流、取消信号和一些只在单侧有效的信息。
Request 的字段与复制语义
Request 的定义很长,核心可以分为五组:
- 请求行:
Method、URL、Proto、ProtoMajor、ProtoMinor; - 头部与主机:
Header、Host; - 消息体:
Body、GetBody、ContentLength、TransferEncoding、Close、Trailer; - 服务端信息:
RemoteAddr、RequestURI、TLS; - 生命周期:旧式
Cancelchannel 和内部ctx。
客户端请求中,URL 必须存在,RequestURI 必须为空;服务端解析出的请求则会保存原始 RequestURI。客户端发送时的 Host 优先取 Request.Host,为空才使用 Request.URL.Host。所以想覆盖线上的 Host 头,不能只写:
req.Header.Set("Host", "api.internal")应写:
req.Host = "api.internal"这是 Host 在 HTTP 语义中的特殊地位造成的,不是 Header map 恰好漏了一个键。
Body 是 io.ReadCloser,意味着请求体是流,不要求一次装进内存。客户端的 Transport 会负责关闭它,即使请求失败也一样;服务端则由 Server 负责关闭传入请求的 Body,Handler 通常不需要自己关闭。两边都叫 Request.Body,所有权不同,阅读代码时必须先问「这是入站还是出站」。
GetBody 是一个能重新生成等价 Body 的函数,供重定向和安全重试使用。Body 本身是一次性流,读取之后不能自动恢复到起点。NewRequest 识别 *bytes.Buffer、*bytes.Reader 和 *strings.Reader 等常见类型时,会设置准确的 ContentLength,并构造 GetBody。手工把 Reader 包进 io.NopCloser 可以发送请求,但可能失去重放能力。
Context 属于请求,而不是客户端全局变量
NewRequestWithContext把 Context 存进请求。对出站请求,它控制拨号、写请求、等待响应头以及读取响应体的整个生命期;对入站请求,客户端连接关闭、请求被取消,或 Handler 返回时,Server 会取消 Context。
派生新请求时通常使用:
req = req.WithContext(ctx)WithContext 做请求结构的浅拷贝并替换 Context。Clone 则进一步浅拷贝 Body,并深拷贝 Header、Trailer、URL 等部分字段。两者都不会神奇地复制流里的字节。若请求可能被两个 goroutine 同时修改,光调用 Clone 仍不代表 Body 可以并发读取。
不要把请求 Context 保存到一个长寿命对象里继续用。Handler 返回后它就会被取消。需要后台任务时,应明确创建独立生命周期,并决定哪些请求值值得复制过去;否则「异步写审计日志」可能随着响应结束一起被取消,而「脱离请求做无限后台工作」又可能失去退出机制。
Header 是 map[string][]string
Header的底层类型是:
type Header map[string][]stringSet 替换一个键的全部值,Add 追加值,Get 只返回第一个值,Values 返回全部值,Del 删除。Set-Cookie、Accept、Cache-Control 等字段都可能包含多个值。并非所有字段都允许用逗号合并,统一拼接可能改变协议语义。
公开方法通过 textproto.CanonicalMIMEHeaderKey 把 content-type 规范成 Content-Type。但 Header 仍是普通 map,直接写 h["content-type"] = ... 可以绕过规范化,随后 h.Get("Content-Type") 未必找到它。通常应使用 Set、Add 和 Del,需要整体复制时使用 Clone,不要让多个并发请求共享同一个可变 Header map。
Header 的值来自网络时属于不可信输入。客户端 Transport 在发送前验证字段名和值,拒绝换行等非法字符,防止一个值被解析为额外报文。业务层仍要处理更高层风险:转发代理不能无条件复制 hop-by-hop 头,日志不能原样记录 Authorization 和 Cookie,生成下载文件名也不能未经编码便把外部输入写入 Content-Disposition。
Response 的 Body 在返回时还没有读完
Response保存状态行、状态码、协议版本、Header、Body、ContentLength、TransferEncoding、Trailer、请求指针和 TLS 状态。Client.Do 返回成功,只代表已经拿到响应头,不代表响应体已经进入内存。
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)Body.Read 可能继续阻塞、超时或遇到网络错误。把 Do 的耗时当成整个下载耗时,会漏掉最主要的一段。下载大文件时更不应无上限 ReadAll,而应流式复制、校验 ContentLength,并由 Context 或客户端超时控制生命期。
即使状态码是 404 或 500,只要协议往返成功,Do 通常返回 nil error。HTTP 状态是应用结果,不是网络失败。调用方必须自己判断 StatusCode,并决定是否读取错误 Body。把 err == nil 理解成「业务成功」是最常见的客户端误判之一。
三、客户端入口:从 http.Get 到 Client.do
包级 Get 只是调用 DefaultClient.Get,而 Client.Get 创建 GET 请求再调用 Do。真正的高层入口在 Client.do。
Client 只有四个公开配置:
type Client struct {
Transport RoundTripper
CheckRedirect func(req *Request, via []*Request) error
Jar CookieJar
Timeout time.Duration
}字段少,是因为它只负责策略,不负责连接细节。Transport 为空时使用 DefaultTransport;CheckRedirect 为空时最多连续跟随 10 次请求;Jar 负责出站前补 Cookie、入站后保存 Set-Cookie;Timeout 覆盖连接、所有重定向以及 Body 读取。
DefaultClient 是一个零字段 Client。它可以工作,但 Timeout 为 0,表示没有总超时。在访问公网、第三方服务或任何可能失去响应的系统时,直接长期使用包级 http.Get 是在把资源生命周期交给对端。更稳妥的起点是显式创建并复用客户端:
client := &http.Client{
Timeout: 15 * time.Second,
}这 15 秒包括读取 Body。若接口需要持续流式返回较长时间,就不应设置缺少业务依据的短总超时,而应使用请求 Context、分阶段 Transport 超时和业务空闲检测。超时值需要与协议的预期进度一致。
Client.do 是一个重定向循环
Client.do 不是只发送一次。它在循环中保存已发送的请求列表,调用 c.send 得到响应,再根据状态码决定是否生成下一跳。
对 301、302、303,非 GET/HEAD 方法会兼容性地改成 GET,且不带原 Body。对 307、308,方法和 Body 应保持不变;若原请求没有 GetBody 而 Body 又非空,客户端无法安全重放,就停止自动跟随。这个差异解释了为什么构造可重放请求体不只是微小优化。
每一跳都会解析 Location,复制允许转发的 Header,设置 Referer,并调用 CheckRedirect。从 HTTPS 跳到 HTTP 时不会发送 Referer;Authorization、WWW-Authenticate、Cookie 等敏感头也不会直接转发到不相关域名。因此每个重定向目标都需要重新执行安全检查。
如果 CheckRedirect 返回普通错误,调用者会同时得到上一个 Response 和包装后的 url.Error,但 Response.Body 已被关闭。若返回 ErrUseLastResponse,客户端停止跳转,把最近响应和仍可读取的 Body 交给调用者。想观察 302 页面而不是跟随,可以这样写:
client := &http.Client{
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}准备发送下一跳请求时,源码会尝试从旧响应体读取最多 2 KiB,再关闭 Body。小响应被读到 EOF 后,连接更有机会回到连接池。Body 的读取结果会直接影响底层连接能否复用,因此重定向逻辑也需要处理旧响应体。
Client.send 在策略层和传输层之间交接
Client.send先从 CookieJar 取出与目标 URL 匹配的 Cookie 加入请求,再调用包内 send。响应回来后,它把 Set-Cookie 交回 Jar。Cookie 的域、路径、Secure 和过期规则由 Jar 实现;Client 只安排调用时机。
包内 send 做进入 RoundTripper 前的防御检查:URL 不能为 nil,客户端请求不能设置 RequestURI,Header 必须初始化。若 URL 中含用户信息且 Header 没有 Authorization,它还会生成 Basic Authentication 头。为了不随便修改调用者传入的 Request,真正需要补字段时会先浅拷贝一份。
总超时也在这一层变成 deadline 与取消动作。响应返回后,Body 会被包装,使定时器能够在后续读取阶段继续生效。因此 Do 已返回不等于 Client.Timeout 停止计时。超时发生在读取 Body 时,错误可能从 Body.Read 或 io.Copy 返回,而不是从 Do 返回。
四、RoundTripper 的接口边界
RoundTripper只有一个方法:
type RoundTripper interface {
RoundTrip(*Request) (*Response, error)
}它表达一次 HTTP 事务:输入 Request,返回 Response 或无法获得响应的错误。它不应跟随重定向、不负责 Cookie,也不应把 500 当成 Go error。Client 在这个接口之上实现高层策略,因此可以替换具体传输实现。
这也是客户端中间件最自然的插入点。记录耗时、注入 User-Agent、分布式追踪、受控重试、测试桩,都可以包装另一个 RoundTripper:
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) {
return f(r)
}
func withUserAgent(next http.RoundTripper, ua string) http.RoundTripper {
return roundTripperFunc(func(req *http.Request) (*http.Response, error) {
clone := req.Clone(req.Context())
clone.Header.Set("User-Agent", ua)
return next.RoundTrip(clone)
})
}为什么先 Clone?因为同一个 Client 和 Transport 可以被多个 goroutine 并发使用,RoundTripper 还可能在异步 goroutine 中读取请求字段。直接修改调用方的 Header 容易与其他逻辑竞态。Clone 不复制 Body 数据,所以中间件若要读取或重试 Body,仍需处理 GetBody 和生命周期。
自定义 RoundTripper 还必须遵守一个经常被测试桩忽略的约定:无论成功失败,都要安排关闭请求 Body。若测试 fake 只返回一个 Response,从不关闭 Body,生产实现和测试实现的资源行为就不一致。一个接口只有一个方法,不代表契约只有一行。
五、Transport 的网络传输职责
默认 RoundTripper 是 DefaultTransport。它的默认值很值得读,因为零值 Transport 与 DefaultTransport 并不相同:
var DefaultTransport RoundTripper = &Transport{
Proxy: ProxyFromEnvironment,
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}直接写 &http.Transport{} 会丢掉这些默认配置,包括环境代理、拨号超时和显式尝试 HTTP/2。自定义时通常应该复制默认值再改:
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.MaxIdleConns = 200
transport.MaxIdleConnsPerHost = 20
transport.MaxConnsPerHost = 50
transport.ResponseHeaderTimeout = 5 * time.Second
client := &http.Client{
Transport: transport,
Timeout: 15 * time.Second,
}Client 和 Transport 都设计为并发复用。每次请求创建一个新 Transport,会让每个实例拥有自己的连接池,请求结束后空闲连接也没有机会服务下一次请求。看起来是在「避免共享」,实际是在反复拨号、TLS 握手和积攒待关闭连接。
Transport 的公开字段可以按阶段分组:
- 找路:
Proxy、DialContext、DialTLSContext; - TLS:
TLSClientConfig、TLSHandshakeTimeout、TLSNextProto; - 连接复用:
DisableKeepAlives、MaxIdleConns、MaxIdleConnsPerHost、MaxConnsPerHost、IdleConnTimeout; - 传输等待:
ResponseHeaderTimeout、ExpectContinueTimeout; - 报文行为:
DisableCompression、MaxResponseHeaderBytes、读写缓冲区大小。
这些字段不是一组可互换的「性能旋钮」。例如 MaxIdleConnsPerHost 限制每个目标保留多少空闲连接,不限制活跃连接;MaxConnsPerHost 才限制拨号中、活跃和空闲连接总数。只提高全局 MaxIdleConns,却不提高每主机默认空闲上限 2,对单一上游服务的连接复用帮助有限。
Transport.roundTrip 的检查与重试循环
Transport.roundTrip先检查请求:URL、Header、scheme、Method 和 Host 必须合法,Header 名和值不能包含非法字节。随后根据 URL 和代理计算 connectMethod,调用 getConn 获取可用连接,再把一次事务交给 persistConn.roundTrip 或替代协议的 RoundTripper。
外层有一个保守的重试循环。重试需要同时考虑连接是否已经成功使用过、错误发生在写出之前还是之后、方法是否幂等,以及 Body 能否重新生成。新连接被服务器立即关闭时,无条件重试可能持续创建连接;POST Body 已写出一部分时重试,则可能让服务端重复执行副作用。
源码把 GET、HEAD、OPTIONS、TRACE,以及显式携带幂等键的请求视为幂等候选;带 Body 时还需要 GetBody 才能重放。即使如此,重试也主要用于处理复用到一条已被服务端悄悄关闭的空闲连接,而不是替业务提供通用重试策略。
业务重试还要理解状态码、退避、次数预算、幂等键和总 deadline,不能因为 Transport 内部已有一次网络重试,就假设 503、限流或读取 Body 中断会自动恢复。
六、connectMethodKey:连接池的索引维度
连接池不能只用 req.URL.Host 做 key。同一个目标可能直连,也可能经 HTTP 代理或 SOCKS5 代理;HTTPS 走 HTTP 代理时,还要先向代理发送 CONNECT 建立隧道。协议、代理和目标共同决定一条连接能否复用。
connectMethod保存代理 URL、目标 scheme 和目标地址。它再生成可比较的 connectMethodKey,大意包含代理、scheme、地址以及是否只能使用 HTTP/1。Transport 的空闲连接表和每主机连接计数都使用这个 key。
ProxyFromEnvironment 读取 HTTP_PROXY、HTTPS_PROXY 与 NO_PROXY 等环境变量,并对 localhost 做特殊处理。代理配置因此是进程环境的一部分。服务跑在开发机没走代理,部署到容器却被环境变量接管,是完全可能的。需要确定性时应显式设置 Transport.Proxy;需要遵循运维代理时,则应把环境变量作为可观测配置记录,但不要把含凭据的代理 URL 打进普通日志。
HTTPS 经 HTTP 代理的典型路径是:先拨号到代理,写 CONNECT 请求,等待 200,再在隧道上做 TLS 握手。DialContext 成功只代表连到了代理,不代表目标站点 TLS 已建立。故障日志应区分 DNS、代理拒绝、TCP、TLS 证书和上游响应阶段。
七、getConn:连接池的获取机制
Transport.getConn并不是简单的「池里没有就拨号」。它创建一个 wantConn,同时尝试从空闲池获得连接,并把拨号需求排入队列。请求 Context 取消时,等待者会被取消;连接到达时,通过 channel 交付给等待者。
wantConn 表示一次正在等待连接的请求。它保存连接池 key、拨号 Context、结果连接、错误和一个 ready channel。空闲池、拨号任务与取消路径都通过 tryDeliver 或 cancel 更新同一个对象,并由互斥锁保证只有一个结果生效。调用 getConn 的 goroutine 只需等待 ready、请求取消或 Context 结束,不必分别管理多个结果 channel。
queueForIdleConn 先从对应 key 的空闲连接切片尾部查找最近使用的连接,跳过已损坏或超过 IdleConnTimeout 的条目。若当前没有可用连接,它把 wantConn 加入 idleConnWait。以后某条连接结束事务并返回空闲池时,Transport 会优先尝试交付给等待者,而不是先存入池再让等待者重新查询。HTTP/1 连接交付后从空闲列表移除,HTTP/2 连接则可以继续保留以支持共享。
请求 A 未找到空闲连接时会启动拨号。如果拨号完成前,请求 B 恰好归还了一条符合条件的连接,A 可以直接接收这条空闲连接。A 已经发起的拨号不会因此立即失去用途:拨号成功后,新连接可以交给其他等待者,也可以进入空闲池。这样可以减少等待者与特定拨号任务的固定绑定。
这个设计避免了为了严格一一对应而浪费连接,但也意味着 DialContext 的调用次数不一定等于最终实际使用的新连接数。监控里看见拨号数略高于业务请求所需,并不能立刻判定泄漏;要结合空闲池、并发等待和连接关闭原因分析。
MaxConnsPerHost 在拨号队列前控制每个连接 key 的总连接数。达到上限后,新请求排队等待,而不是再拨号。它能保护本机文件描述符和上游承载能力,也会把排队延迟带进请求。若客户端总超时太短,请求可能还没拿到连接就取消;若没有超时,队列又可能不断积累。连接上限必须与请求并发、服务时间和超时预算一起设计。
空闲连接的每主机与全局限制
Transport 维护 idleConn map[connectMethodKey][]*persistConn,每个 key 的切片尾部是最近使用的连接;另有全局 LRU 追踪全部空闲连接。放回连接时,先受 MaxIdleConnsPerHost 限制,再受 MaxIdleConns 全局限制;超过全局上限会淘汰最久未使用的连接。
每条空闲连接还可设置 IdleConnTimeout 定时关闭。关闭空闲连接不会打断正在使用的事务。CloseIdleConnections 适合配置切换、测试清理或确定长时间不用客户端时调用,但它不是「让所有请求立刻停止」的取消按钮。
一个常见误区是把 HTTP keep-alive 和 TCP keepalive 混为一谈。前者是 HTTP 连接能否承载后续请求,由 DisableKeepAlives、报文 Connection: close 和 Body 消费情况影响;后者是内核用于探测长时间连接存活性的 TCP 机制,由 net.Dialer.KeepAlive 等控制。关掉其中一个,不等于关掉另一个。
八、dialConn:建立 TCP、代理与 TLS 连接
dialConn把 connectMethod 变成 persistConn。直连 HTTP 只需 TCP;直连 HTTPS 在 TCP 之后包装 tls.Client 并执行握手;代理路径则根据代理 scheme 决定普通转发、CONNECT 隧道或 SOCKS5。
TLS 握手受 TLSHandshakeTimeout 约束。证书验证使用 TLSClientConfig,其中 ServerName 通常根据目标主机补齐。把 InsecureSkipVerify 设为 true 会跳过正常的服务端身份验证,不应作为长期配置使用。
握手完成后,代码检查 ALPN(Application-Layer Protocol Negotiation,应用层协议协商)结果。如果协商出 h2 且 TLSNextProto 中存在处理器,连接就交给 HTTP/2 RoundTripper;否则继续构建 HTTP/1 的带缓冲读写器。
HTTP/1 路径最后会启动两个长期 goroutine:
go persistConn.readLoop()
go persistConn.writeLoop()从这里开始,这条连接不再由某个调用 Do 的 goroutine 独占。它有自己的读写执行者,外部请求通过 channel 排队协作。理解这个变化,是读懂 Transport 的关键。
九、persistConn 的并发模型
persistConn包装一条 net.Conn,保存:
- 所属 Transport 与连接池 key;
- TCP 连接、TLS 状态、
bufio.Reader和bufio.Writer; reqch、writech、writeErrCh等通信 channel;- 关闭 channel、空闲时间与定时器;
- 预计响应数、是否损坏、是否复用、取消错误等状态。
一次 persistConn.roundTrip 做的不是直接调用 conn.Write 和 conn.Read。它把写请求任务发给 writeLoop,把「接下来应读取哪个响应」的信息发给 readLoop,然后在 select 中等待写错误、响应、响应头超时、请求取消或连接关闭。
请求 goroutine
|-- writech ----> writeLoop ----> bufio.Writer ----> TCP
|
|-- reqch ------> readLoop <---- bufio.Reader <---- TCP
|
`-- select <----- 写错误 / Response / timeout / cancel为何不在请求 goroutine 中先写后读?服务器可能在客户端上传完整 Body 前就返回,例如鉴权失败、请求过大或 Expect: 100-continue 被拒绝。并行等待响应能及时停止无意义上传。读写拆分也让连接生命周期、取消和池化逻辑集中在固定 goroutine,而不是散落到每个调用者。
HTTP/1 在一条连接上仍然按顺序匹配请求与响应。默认 Transport 不使用 HTTP/1 pipelining 在同一连接同时发送多个事务;并发请求通常使用不同连接。numExpectedResponses 和 channel 协调连接读写两侧的状态,不允许响应乱序匹配。
writeLoop:请求序列化与发送
writeLoop从 writech 取任务,调用 Request.write 把请求行、Header、Body 和 trailer 写入带缓冲 Writer,然后 Flush。它记录开始前后的已写字节数:若错误发生但一个字节都没写,可包装成 nothingWrittenError,供上层判断安全重试。
请求序列化并不只是 fmt.Fprintf。transferWriter根据方法、Body、ContentLength 和 TransferEncoding 决定是否发送 Body、使用固定长度还是 chunked,并确保线上边界可解析。
若请求含 Expect: 100-continue,writeLoop 先写 Header,再等待服务器 100 Continue、最终响应、超时或连接关闭。服务器同意或等待达到 ExpectContinueTimeout 才发送 Body。这适合大上传:服务端可在读主体前完成鉴权和大小判断,避免双方传输注定被拒绝的数据。
readLoop:响应读取与连接回收
readLoop在循环开头先从缓冲区 Peek 一个字节,确认有响应可读,再按 reqch 中的请求调用 ReadResponse。响应头读取受 MaxResponseHeaderBytes 限制,默认上限由 Transport 内部给出,避免服务端通过过大的 Header 消耗客户端内存。
若响应没有 Body,或是协议切换,readLoop 可以很快决定连接去向。普通响应则把原 Body 包成 bodyEOFSignal,把 Response 交给调用者后停下来等待:调用者究竟读到了 EOF、提前 Close、取消,还是连接死亡?
只有读到 EOF、请求写入成功、连接未见 EOF、取消器仍有效等条件同时满足,连接才会回到空闲池。关闭 Body 是必要动作,读到 EOF 才是复用 HTTP/1 连接的关键证据。 提前 Close 无法保证剩余字节与下一条响应的边界已经处理干净,Transport 往往只能放弃该连接。
这就是客户端模板总要写 defer resp.Body.Close() 的原因,也是只 Close 不读 Body 仍可能导致连接复用率差的原因。对小错误响应,可以有上限地读完再关闭:
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
return fmt.Errorf("upstream status %s: %s", resp.Status, body)
}上限不可省。对端声称错误却无限输出时,无限制读取会把连接复用优化变成内存或时间攻击面。大正常响应则应按业务消费到 EOF;如果业务决定中途放弃,接受该连接可能无法复用的代价即可,不能为了省一次握手强迫自己下载几个 GB。
十、gzip 自动解压
persistConn.roundTrip 发现调用者没有设置 Accept-Encoding、不是 Range 请求且方法不是 HEAD 时,会额外发送 Accept-Encoding: gzip。若响应确实为 gzip,readLoop 用 gzipReader 包装 Body,删除响应中的 Content-Encoding 和 Content-Length,把 ContentLength 设为未知,并标记 Uncompressed=true。
这是一种很实用的透明行为,也容易让抓包与程序观察不一致:线上是压缩字节,业务读到的是解压内容;线上 Header 有 gzip 和压缩长度,程序里的 Response.Header 却看不到它们。若调用者自己显式设置 Accept-Encoding: gzip,Transport 认为你要自己控制,不再自动解压。
签名校验、对象存储透传、缓存原始实体等场景需要线上原字节,可以设置 DisableCompression。普通 JSON API 通常可以保留自动解压。配置前需要确定业务校验的是传输表示还是解码后的内容。
十一、客户端各阶段的超时
HTTP 请求经过多个阶段:等待连接池、DNS 与拨号、代理 CONNECT、TLS 握手、写请求、等待响应头、读取响应体。一个 time.Duration 很难同时适合所有阶段。
客户端常用限制可以画成:
Request.Context / Client.Timeout
|------------------------------------------------------------|
Dialer.Timeout
| DNS + TCP |
TLSHandshakeTimeout
| TLS |
ResponseHeaderTimeout
| 写完请求 -> 收到响应头 |
Body.Read
| 流式读取... |Client.Timeout 是总预算,包含重定向和 Body 读取。请求 Context deadline 也覆盖完整生命周期,并更适合把上游请求剩余预算传给下游。二者同时存在时,更早到期者生效。
Dialer.Timeout 限制建立连接;TLSHandshakeTimeout 只限制 TLS 握手;ResponseHeaderTimeout 从请求(含 Body)写完后开始等待响应头,不限制读取响应体;ExpectContinueTimeout 只服务于 100-continue 阶段;IdleConnTimeout 管的是空闲池连接,不是活跃请求。
Transport 没有 ReadBodyTimeout 字段。流式 Body 的期限通常由 Context 或 Client.Timeout 控制;若需要根据读取进度刷新空闲超时,可能要在自定义 DialContext 返回的连接上管理 deadline,或在应用层监测进度。固定总超时可能中断合法的长时间下载。
超时错误最终常被包装成 *url.Error。判断时可使用 errors.Is(err, context.DeadlineExceeded)、net.Error.Timeout() 和请求 Context 状态,而不是匹配错误字符串。错误字符串是给人看的,不是稳定协议。
十二、请求取消的传播路径
最推荐的取消入口是 Request.Context:
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return err
}
resp, err := client.Do(req)Context 取消可能发生在等待连接、拨号、写请求、等响应头或读 Body 任一阶段。Transport 为请求登记取消函数,persistConn.roundTrip 同时监听 Context channel。取消一条 HTTP/1 请求时,最可靠的停止方法可能是关闭它所在的连接;这会牺牲复用,但比让 goroutine 永远等网络更重要。
Request.Cancel 和 Transport.CancelRequest 仍存在于源码中,主要为了兼容旧调用方式。自定义 RoundTripper 应实现 Context 取消,而不是只依赖旧接口。混用多个取消源会让错误归因变复杂,业务代码最好统一从 Context 传播。
取消也不是回滚。请求字节可能已经到达服务端,服务端可能已经提交数据库事务,只是客户端不再等待响应。对有副作用操作,仍需幂等键、事务状态查询或业务补偿。把 Context cancel 理解成「服务端什么都没做」非常危险。
十三、httptrace 的阶段观测
当一个请求耗时 800 ms,只看总时间无法知道它卡在 DNS、连接池、TLS 还是首字节。net/http/httptrace 允许把 ClientTrace 放进请求 Context,在不同阶段接收回调。
trace := &httptrace.ClientTrace{
GetConn: func(hostPort string) {
log.Printf("get conn %s", hostPort)
},
GotConn: func(info httptrace.GotConnInfo) {
log.Printf("reused=%v idle=%v", info.Reused, info.IdleTime)
},
GotFirstResponseByte: func() {
log.Printf("first response byte")
},
}
req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace))还可以观察 DNSStart/DNSDone、ConnectStart/ConnectDone、TLSHandshakeStart/TLSHandshakeDone、WroteHeaders、WroteRequest、Got100Continue 等事件。源码在 getConn、拨号、TLS、writeLoop 和 readLoop 的关键点调用这些钩子,所以 trace 不是外部猜测,而是状态机主动暴露的观测面。
回调可能从不同 goroutine 执行,不应在里面做重活或无保护地修改共享 map。生产观测通常记录阶段时间、目标标签和有限错误分类,避免把完整 URL 查询串、Cookie、Authorization 或用户数据带进指标。
十四、服务端入口:ListenAndServe
包级 ListenAndServe只创建 Server{Addr: addr, Handler: handler},再调用它的方法。Server.ListenAndServe 用 net.Listen("tcp", addr) 建立 Listener,最后进入 Server.Serve。
因此这段:
http.ListenAndServe(":8080", mux)等价于使用一个几乎全是零值的 Server。它没有 ReadTimeout、ReadHeaderTimeout、WriteTimeout 和 IdleTimeout。内网小工具可以接受,直接暴露在不可信网络上则缺少资源边界。显式 Server 更稳妥:
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 20,
}
log.Fatal(srv.ListenAndServe())具体数值必须结合上传大小、Handler 行为和部署层代理设置。上面的配置是结构示例,不是可以复制到所有服务的标准答案。
十五、Server.Serve:Accept 循环与连接 goroutine
Server.Serve先包装 Listener,登记到 Server 的活动监听器集合,创建基础 Context,然后进入 Accept 循环。
Accept 成功后,它可通过 ConnContext 给连接 Context 增加信息,构造 conn,把状态设为 StateNew,最后:
go c.serve(connCtx)也就是说,HTTP/1 服务端基本并发单位首先是连接。不同连接的 Handler 自然并发;同一 HTTP/1 持久连接上的请求由 conn.serve 循环读取和处理,通常顺序执行。不要因为每个请求都有 *Request 就假设同一连接里一定并行。
Accept 遇到临时网络错误不会立刻让整个 Server 退出。源码从 5 ms 开始指数退避,最大到 1 秒,记录日志后重试;一次成功 Accept 会把延迟归零。这个小状态机防止文件描述符耗尽等临时错误导致忙循环刷满 CPU,同时给系统恢复机会。
若 Server 正在 Shutdown,Accept 因 Listener 关闭而返回错误,Serve 会转换成 ErrServerClosed。因此优雅关闭后 ListenAndServe 返回这个错误是正常控制流,main 函数不应把它当成服务崩溃:
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}BaseContext 与 ConnContext
BaseContext 为整个 Listener 上接受的连接提供根 Context,适合放服务级依赖或统一取消信号;ConnContext 在每次 Accept 后派生连接 Context,可加入远端地址、连接 ID 等。请求 Context 再从连接 Context 派生。
Context value 应只携带请求范围元数据,不应作为所有服务依赖的容器。数据库、日志器等稳定依赖通常放在 Handler 结构体字段中;Context 主要传递取消、deadline 和跨边界的请求标识。
十六、conn.serve:连接生命周期
conn保存 Server、底层 net.Conn、远端地址、TLS 状态、读写缓冲、当前 Request 和当前 Response。它是服务端与客户端 persistConn 对应但并不对称的角色。
conn.serve开头设置 Context 值,defer 中负责 recover、关闭连接并清理状态。普通 Handler panic 会被捕获并记录堆栈,然后关闭连接;若 panic 值是 ErrAbortHandler,Server 会中止当前处理而不打印堆栈。recover 可以防止单个请求终止整个进程,但该请求不会自动得到完整的 500 响应,已经写出的部分响应也无法撤回。
若底层连接是 TLS,conn.serve 设置握手 deadline、执行握手、检查协商协议。ALPN 选中 h2 时,连接交给 TLSNextProto 对应处理函数,HTTP/1 循环不再接管它。否则开始反复:
readRequest解析下一个请求;- 更新连接状态为 Active;
- 调用顶层 Handler;
finishRequest完成响应并关闭请求 Body;- 判断是否复用连接;
- 进入 Idle,等待下一个请求。
Handler 返回后,源码立即调用 cancelCtx,再检查 panic、完成响应和复用。所以请求 Context 的生命期与一次 ServeHTTP 紧密绑定。Handler 启动 goroutine 后直接返回,而 goroutine 继续拿着 r.Context() 做工作,通常会立刻看到取消。
HTTP/1 请求体与响应体的读写顺序
对 HTTP/1 请求,Server 通常会在开始写响应前消费未读完的请求 Body,以便安全定位同一连接上的下一条请求。Handler 一边从 r.Body 读,一边持续向 ResponseWriter 写,行为会受到协议、缓冲和客户端实现影响,不能当成可靠的全双工流。
普通 JSON API 应先有上限地读完或解析请求,再写响应。真正需要双向流时,要明确选择支持的协议与接口,而不是期待 ResponseWriter 像裸 net.Conn 一样工作。
十七、readRequest:请求解析与期限设置
conn.readRequest先根据 Server 配置设置读写 deadline,再调用 ReadRequest 从 bufio.Reader 解析请求行和 Header。它检查 Host、协议版本、Header 合法性、请求目标与 Transfer-Encoding,并把远端地址、TLS 状态和 Context 填入 Request。
Server 不会一开始就把整个 Body 读进内存。解析 Header 后,Body 仍是连接上的受限流,Content-Length 或 chunked 编码决定它在哪里结束。Handler 调用 ParseForm、JSON decoder 或直接 Read 时,才继续从网络获取主体。
请求头大小先受 MaxHeaderBytes 约束,内部会额外留出少量空间给 bufio 和边界判断。限制 Header 不能替代限制 Body:攻击者完全可以发送合法小 Header,再声明或持续发送巨大 Body。两者需要独立预算。
四个服务端超时分别管什么
Server 的超时字段经常被误配:
ReadHeaderTimeout:从接受连接到读完请求头;ReadTimeout:读取整个请求(包括 Body)的最大时间;WriteTimeout:写响应的最大时间,设置时机与协议路径有关;IdleTimeout:keep-alive 状态等待下一次请求的时间,未设置时使用 ReadTimeout。
只设置 ReadTimeout 可能让大上传没有足够时间,只设置 ReadHeaderTimeout 又无法限制一个极慢的 Body。WriteTimeout 也不是 Handler CPU 执行超时的完美替代:网络 deadline 主要作用于底层连接 I/O,Handler 在写之前做长时间计算时,不会像 Context deadline 那样自然传播给下游调用。
面对公网慢速攻击,ReadHeaderTimeout 和 MaxHeaderBytes 是基本边界;面对上传接口,应再用 MaxBytesReader 限制 Body 大小,并根据业务速率决定 ReadTimeout。所有请求共享同一套 Server 超时不够灵活时,可以按路由在 Handler 内建立 Context 与大小限制。
十八、Handler 接口与中间件
Handler同样只有一个方法:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}HandlerFunc 是函数类型,并实现 ServeHTTP,所以普通函数可以适配成 Handler。路由、中间件、压缩、认证、限流与业务处理都可以围绕 Handler 接口组合,无需修改 Server。
中间件的本质是接收一个 Handler,返回另一个 Handler:
func recoverMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if v := recover(); v != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}这只是结构示例。真正的 recovery 中间件还要考虑响应是否已经提交:如果下游先写了 200 和半段 Body 再 panic,此时不能把线上状态改回 500。想保证错误前不提交,需要先缓冲响应,但大响应和流式接口又不适合全量缓冲。工程取舍就藏在这个接口的「只能向前写」里。
Server 级 recover 保护进程,中间件级 recover 才有机会返回统一错误格式、记录请求 ID 和指标。两层职责不同,不应因为标准库会 recover 就完全忽略业务错误处理。
十九、ServeMux 的最长模式匹配
ServeMux内部维护 map[string]muxEntry 和一个按长度排序的切片。注册模式若不以 / 开头,会被当成带 Host 的模式;普通路径模式可以精确匹配,也可以以 / 结尾表示子树。
匹配原则是最具体,也就是最长模式优先:注册 /images/ 和 /images/thumbnails/ 后,后者会处理更深的对应路径。对以斜杠结尾的子树根,ServeMux 还会帮助重定向缺少尾斜杠的请求。
mux := http.NewServeMux()
mux.Handle("/static/", http.StripPrefix("/static/", fileServer))
mux.HandleFunc("/healthz", healthHandler)ServeMux 在匹配前会清理路径中的 .、.. 和重复斜杠,并重定向到规范路径;CONNECT 有特殊处理。它匹配的是 URL path 或 host+path,不解析方法参数,也不自动做复杂变量提取。需要更复杂路由时,可以在 Handler 中明确解析,或使用独立路由器;不要把未提供的语义想象进简单模式。
包级 http.Handle 和 http.HandleFunc 注册到全局 DefaultServeMux。库若在 init 中隐式注册路由,容易造成模式冲突和不可控全局状态。应用通常显式创建 NewServeMux,把依赖和路由集中在组装阶段,测试也更容易隔离。
二十、ResponseWriter 的响应提交状态
ResponseWriter有三个基础方法:
type ResponseWriter interface {
Header() Header
Write([]byte) (int, error)
WriteHeader(statusCode int)
}Handler 看到的是接口,HTTP/1 路径背后通常是内部 response。Header() 返回即将发送的 Header map;WriteHeader 提交状态码;Write 写 Body。如果从未调用 WriteHeader,第一次 Write 会隐式发送 200 OK。所以顺序必须是:先设 Header,再写状态码,再写 Body。
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_, _ = w.Write(payload)一旦 Header 提交,再修改普通 Header 通常不会影响已经发出的响应。第二次调用 WriteHeader 也不会改状态,只会产生多余调用日志。常见错误是:
fmt.Fprintln(w, "failed")
w.WriteHeader(http.StatusBadRequest) // 太晚了,线上已经是 200应改用 http.Error 或先 WriteHeader。
Content-Type 与 Content-Length 的自动处理
若 Handler 写 Body 前没有设置 Content-Type,response 会取最多前 512 字节用 DetectContentType 推断。方便归方便,API 最好显式设置类型,尤其 JSON、下载和安全敏感内容;浏览器的内容嗅探可能产生与预期不同的解释。
小响应在 Handler 返回前全部落进内部缓冲,且没有显式 Flush 或长度时,Server 可以自动计算 Content-Length。内容过大或主动 Flush 后,HTTP/1.1 往往使用 chunked 传输。不要依赖「我第一次 Write 了 100 字节,所以 Content-Length 一定是 100」;后面还可能继续写,真正决策发生在响应状态机中。
WriteHeader 还会处理不允许 Body 的状态码、HEAD、Trailer 和连接关闭。response 之下的 chunkWriter 负责把业务字节变成符合协议边界的输出,再经 bufio.Writer 到连接。ResponseWriter 的三方法接口后面,至少藏了两层 writer。
ResponseWriter 的可选接口
具体 ResponseWriter 还可能实现:
Flusher:把缓冲数据尽快推向客户端;Hijacker:接管底层连接和缓冲读写器;CloseNotifier:观察客户端连接断开;Pusher:发起 HTTP/2 server push。
不能假设所有 ResponseWriter 都实现它们,测试 recorder、中间件包装和 TimeoutHandler 都可能改变能力。使用前应类型断言并处理不支持:
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
flusher.Flush()中间件若用自定义结构包装 ResponseWriter,却没有透传这些可选接口,会让下游流式接口或 WebSocket 升级突然失效。反过来,无条件声明自己实现 Hijacker、实际底层不支持也同样错误。包装器必须诚实反映能力。
二十一、finishRequest:Handler 返回以后还发生什么
response.finishRequest在 Handler 返回后执行。它确保 Header 已写出,刷新 response writer、chunk writer 和连接缓冲,关闭请求 Body,并清理为当前请求准备的 multipart 临时文件。
然后 shouldReuseConnection 检查连接能否承载下一次请求:响应是否要求关闭,Body 写入长度是否与声明一致,写入是否失败,请求 Body 是否过早关闭等。Handler 只负责业务函数返回,连接是否继续存活由这套收尾状态决定。
若 Handler 没有读完请求 Body,Server 会尝试处理剩余数据以争取复用连接,但不会无上限读取上传内容。源码定义一个约 256 KiB 的阈值;未读部分太大时倾向关闭连接,而不是为了 keep-alive 读取完整 Body。
因此入口就应限制并消费请求体:
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
defer r.Body.Close()
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
var input CreateRequest
if err := dec.Decode(&input); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}MaxBytesReader 不只是 io.LimitReader。超过上限时它返回特定错误,并在服务端路径上通知 response 请求过大,使连接处理采取合适策略。大小上限应放在解析之前,否则解析器可能已经消耗大量内存。
还要决定是否只允许一个 JSON 值。第一次 Decode 成功不代表后面没有垃圾或第二个对象;严格 API 可以继续 Decode 并要求 EOF。安全边界来自完整输入契约,不是调用了某个 decoder 就自动拥有。
二十二、transferReader:Body 边界解析
HTTP/1 报文承载在连续字节流上。持久连接连续传输多条消息时,必须准确确定当前 Body 的结束位置,否则剩余字节可能被当成下一条请求行或响应状态行。Content-Length、chunked 和连接关闭分别提供不同的消息边界方式。
transferReader把 Request 与 Response 的传输字段归一到同一套解析逻辑。输入是 Header、状态码、请求方法和协议版本,输出是 Body reader、ContentLength、是否 chunked、是否应关闭连接以及 Trailer。
它先解析 Transfer-Encoding,再调用 fixLength 决定真实长度,接着解析 Trailer,最后构造具体 Body:
- chunked 使用分块 reader,每个块自行声明大小,以零长度块结束;
- 正长度使用
io.LimitReader,确保恰好读到声明长度; - 长度为 0 使用
NoBody; - 响应既无长度也非 chunked 时,只能读到连接关闭,并把连接标记为不可复用。
最后一种方式把 EOF 当消息终点。它适合关闭式传输,却与持久连接冲突:同一个 EOF 不可能既代表当前响应结束,又允许连接继续承载下一条响应。因此一个缺少明确边界的响应会牺牲连接复用。
不包含 Body 的响应
HEAD 响应、1xx、204 No Content 和 304 Not Modified 有特殊规则。即使 Header 中出现某些长度信息,接收方也不能按普通响应体读取。HEAD 的 Content-Length 可以描述同一请求若使用 GET 时可能得到的实体长度,但线上不会跟随那段 Body。
这也是为什么判断 resp.Body == nil 没有意义:Client 保证 Response.Body 非 nil,没有主体时通常是 NoBody。正确逻辑是照常 Close,是否读取由方法、状态和业务决定。自己根据 Content-Length == 0 拼协议状态机,容易漏掉 HEAD 与特殊状态码。
Content-Length 冲突是安全问题
HTTP 请求走私经常利用前后两层对消息边界理解不一致:代理相信一个 Content-Length,后端相信另一个;一层优先 Transfer-Encoding,另一层却优先 Content-Length。攻击者便能把隐藏字节解释成下一条请求。
fixLength 对多个 Content-Length 做加固:值不同直接报错;完全相同的重复值则去重。parseTransferEncoding 只接受单个、值为 chunked 的 Transfer-Encoding;当它存在时删除 Content-Length,避免下游继续看到两套边界。代码注释直接把这里称为 HTTP/1.1 中最敏感的安全表面之一。
标准库的严格解析减少了单个 Go Server 的歧义,但完整链路可能还有 CDN、负载均衡、反向代理和应用网关。各层若使用不同解析规则,风险仍然存在。中间件不应重新组合已经规范化的 Header 与原始字节,也不应为了兼容畸形客户端放宽边界字段。
Trailer 要到 EOF 后才完整
Trailer 位于 chunked Body 末尾。Response 刚返回时,resp.Trailer 可能只包含声明过的键和值为 nil;只有 Body.Read 返回 EOF 后,真正值才可用。读取 Body 的同时并发访问 Trailer 会造成竞态或观察到不完整状态。
_, err := io.Copy(dst, resp.Body)
if err != nil {
return err
}
checksum := resp.Trailer.Get("Digest")如果业务依赖 Trailer 校验完整性,就不能提前停止读取后仍假装校验成功。Body EOF 在这里既是连接复用信号,也是 Trailer 完成信号。
二十三、ConnState:连接生命周期的观测窗口
Server 为连接定义五种主要状态:StateNew、StateActive、StateIdle、StateHijacked 和 StateClosed。conn.setState把状态和当前 Unix 秒打包进一个原子整数,同时按状态把连接加入或移出 Server 的活动集合,最后调用用户配置的 ConnState 钩子。
典型 HTTP/1 keep-alive 连接会经历:
New -> Active -> Idle -> Active -> Idle -> ClosedNew 表示刚接受、尚未读到请求;Active 表示已经读到一个或更多请求字节并正在处理;Idle 表示完成请求、等待下一条;Hijack 后标准库不再管理该连接;Closed 是终态。HTTP/2 上 Active 与 Idle 是整条连接的近似状态,不等同于某一条流,因为一条连接可以同时承载多个流。
ConnState 可用于统计当前连接数、空闲连接数和连接寿命:
var activeConnections int64
srv.ConnState = func(conn net.Conn, state http.ConnState) {
switch state {
case http.StateNew:
atomic.AddInt64(&activeConnections, 1)
case http.StateClosed, http.StateHijacked:
atomic.AddInt64(&activeConnections, -1)
}
}示例中的计数器必须并发安全。钩子由连接处理路径同步调用,里面若做慢日志、网络请求或等待锁,会直接拖慢 Accept 或请求收尾。更适合的工作是更新原子计数、非阻塞投递事件,或记录极少量元数据。
Shutdown 也依赖连接状态判断安静程度:关闭 Listener 后,它扫描活动连接,关闭 Idle,等待 Active 变回 Idle。长期卡在 StateNew 的连接可能妨碍关闭,源码会把超过一段时间仍未读到 Header 的 New 连接视作可关闭的空闲连接。配置 ReadHeaderTimeout 仍然更直接,它既保护日常运行,也让关闭过程更可预测。
Hijacked 是管理权转移。调用 Hijack 后,连接会从 Server 活动集合移除,读写、deadline 和关闭都由调用者负责;Shutdown 无法替你等待。使用 WebSocket 或自定义升级协议时,应用需要自己的连接注册表和退出流程,否则主 HTTP 服务看似优雅关闭,升级连接仍在后台存活。
二十四、表单与 Cookie:便利方法也会推进输入流
Request 提供 ParseForm、ParseMultipartForm、FormValue、PostFormValue、FormFile 等高层方法。它们方便,却不是纯粹查询已有字段,有些调用会读取 Body、分配内存和创建临时文件。
ParseForm总会解析 URL query。对 POST、PUT、PATCH 且 Content-Type 为 application/x-www-form-urlencoded 的请求,它还读取 Body 并解析键值。若 Handler 没先用 MaxBytesReader 限制,内部会使用一个约 10 MB 的默认上限;这个上限是防御兜底,不等于业务接受 10 MB 表单。
解析结果分别进入 PostForm 与 Form。PostForm 只含 Body 表单,Form 同时包含 URL query 和 Body 值,合并时来源优先级可能影响 FormValue 返回哪个值。安全敏感参数应明确从预期来源读取,避免 query 和 Body 中的同名字段造成歧义。
FormValue 在需要时自动调用 ParseMultipartForm 和 ParseForm,却会忽略解析错误,只返回看见的第一个值。它适合简单非关键参数,不适合需要区分「缺失」「格式错误」「Body 过大」的接口。严谨 Handler 应显式调用解析函数、检查 error,再读取对应 map。
multipart 会使用内存和临时磁盘
ParseMultipartForm(maxMemory) 把一定数量的文件数据留在内存,其余部分写入临时文件。maxMemory 不是整个请求的硬上限,文本字段和内部开销还会占空间。要限制总上传大小,应先用 MaxBytesReader 包裹 Body,再解析 multipart。
解析产生的临时文件最终需要删除。Server 在请求完成时会清理 MultipartForm,但 Handler 若脱离正常流程长期保存 multipart.File 或把它交给异步 goroutine,生命周期就变得含糊。需要长期保留的上传应在 Handler 返回前复制到应用拥有的存储,并完成大小、类型和权限校验。
流式大上传可以直接调用 MultipartReader 逐 part 处理,避免先解析整个表单。代价是应用必须自己按顺序消费 part、执行总量和单文件上限,并确保错误时停止读取和清理已写文件。便利 API 与流式 API 的区别,本质仍是由谁管理内存和生命周期。
Cookie 是 Header 上的结构化视图
服务端 r.Cookie(name) 从 Cookie Header 中查找指定名称,r.Cookies() 返回全部可解析 Cookie;http.SetCookie 则把序列化后的值追加到 Set-Cookie。Set-Cookie 不能按照普通逗号列表合并,每个 Cookie 通常占独立 Header 值。
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: token,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})Cookie 属性是浏览器约束的一部分,不替代服务端会话校验。HttpOnly 降低脚本读取风险,Secure 要求安全连接,SameSite 影响跨站发送;服务端仍需签名或随机会话 ID、过期、轮换和撤销。客户端 CookieJar 自动管理收发,也不会替业务创造可信身份。
解析 Cookie、表单和 JSON 的共同原则是:便利函数只能解释格式,不能决定信任;读取之前先设资源上限,解析之后再做字段级验证。
二十五、HTTP/2:在 RoundTripper 与 TLSNextProto 处分叉
HTTP/2 代码被捆绑在 h2_bundle.go,体量很大,但它没有推翻外层抽象。客户端仍通过 RoundTripper 发请求,服务端仍把流交给 Handler。真正分叉发生在 TLS ALPN 协商之后。
默认 Transport 会配置 h2 对应的替代 RoundTripper。dialConn 发现协商结果后,把 TLS 连接交过去,并返回一个 alt 非空的 persistConn 占位;Transport.roundTrip 随后调用 pconn.alt.RoundTrip。HTTP/1 的 readLoop、writeLoop 和一连接一活跃事务模型不再适用,HTTP/2 实现负责帧、流、多路复用、流量控制和头部压缩。
服务端 ServeTLS 会准备 TLS 配置,conn.serve 握手后根据 TLSNextProto 把连接交给 HTTP/2 server。上层 Handler 通常不用区分协议,但行为仍可能不同:HTTP/2 不支持 Hijacker 的裸连接语义,多条流在一条连接上并发,响应刷新与取消对应帧而不是简单关闭 TCP。
自定义 TLSClientConfig、DialContext 或 DialTLS 时,自动 HTTP/2 配置会更保守;ForceAttemptHTTP2 用于明确表达仍希望尝试。直接从默认 Transport Clone 可以保留相应默认值。判断线上是否真正使用 HTTP/2,应查看 resp.Proto、TLS 协商或 trace,不能仅根据 HTTPS URL 判断。
二十六、Server.Shutdown 的优雅关闭流程
Server.Shutdown首先把 Server 标记为关闭,关闭 Listener,让新的 Accept 停止;随后关闭空闲连接,并轮询等待活跃连接回到空闲状态。Context 到期前全部安静下来就返回,否则返回 Context error。
一个完整的 main 可以这样组织:
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
errCh := make(chan error, 1)
go func() {
errCh <- srv.ListenAndServe()
}()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
select {
case sig := <-sigCh:
log.Printf("shutdown on %s", sig)
case err := <-errCh:
if err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("graceful shutdown: %v", err)
}main 不能在 ListenAndServe 返回后立即退出,否则进程结束会中断 Shutdown 正在处理的请求。应由主 goroutine 等待 Shutdown 完成。
Shutdown 不会接管被 Hijack 的连接,例如 WebSocket。Server 已经不知道这些连接何时空闲。可用 RegisterOnShutdown 通知协议层开始关闭,再由应用自己的 WaitGroup 或连接管理器等待。回调本身应启动关闭动作而不是阻塞,因为 Shutdown 会并发调用它们。
Close 与 Shutdown 不同:Close 立即关闭 Listener 和已知活动连接,不等待请求自然完成。超时后的兜底是否调用 Close,取决于部署系统是否还会强制杀进程以及业务能否接受中断。
二十七、TimeoutHandler 的能力边界
TimeoutHandler 用给定时限运行下游 Handler,超时后返回 503 Service Unavailable。它会缓冲下游输出,避免超时前就把半截响应提交给客户端。代价是它不支持 Hijacker 或 Flusher,流式响应不能套这层。
更重要的是,向客户端返回 503 不等于下游工作已经安全停止。Handler 必须观察 r.Context().Done(),数据库和 RPC 也要接收同一个 Context;不支持取消的计算可能继续占 CPU。超时控制需要从入口传播到每一层阻塞操作,不能只在最外面包一层计时器。
Server.WriteTimeout、TimeoutHandler、请求 Context 各有不同边界:
| 机制 | 主要控制对象 | 是否自然传播到下游 |
|---|---|---|
WriteTimeout | 底层连接写 deadline | 否 |
TimeoutHandler | Handler 对外响应期限 | 通过请求 Context 可传播 |
context.WithTimeout | 当前业务调用链 | 是,前提是下游接受 Context |
实际服务常把 Server 网络超时作为资源底线,再由路由或业务 Context 表达接口预算。两层互补,而不是互相替代。
二十八、需要由业务补充的安全边界
net/http 会验证报文语法、限制部分 Header、处理 chunked 边界和连接状态,但它不知道业务允许访问什么。几个常见边界必须由应用补上。
请求体大小与压缩炸弹
服务端用 MaxBytesReader 限制收到的 Body 字节。若业务还接受 gzip 请求体,限制压缩前还是解压后会产生巨大差别;解压流外还要再加解压后上限。客户端自动解压响应同样可能把很小的压缩实体展开成大数据,不能因为线上 Content-Length 小就无上限 ReadAll。
SSRF 与重定向
让用户提供 URL,再用默认 Client 请求,可能产生服务端请求伪造(Server-Side Request Forgery,SSRF)。只校验初始域名不够,DNS 可能解析到内网,重定向也可能跳到环回、云元数据或私网地址。安全客户端需要控制 scheme、端口、解析后的 IP、代理和每次重定向目标,并考虑 DNS 重绑定。CheckRedirect 是其中一个关口,不是完整方案。
反向代理 Header
X-Forwarded-For、X-Forwarded-Proto 等头只有来自可信代理时才可信。客户端可以直接伪造它们。应用应根据明确的代理拓扑覆盖或解析,而不是看到 Header 就接受。转发响应时还要删除 Connection 指定的 hop-by-hop 头,标准库 httputil.ReverseProxy 比手写复制所有 Header 更可靠,但仍需配置错误处理与目标边界。
文件服务与路径
ServeMux 清理 URL path 不等于任意文件访问都安全。把路径直接拼到磁盘根目录会引入目录穿越、符号链接和权限问题。http.FileServer、http.Dir 与 StripPrefix 能处理常见路径规则,但敏感目录仍应使用明确根路径和部署权限隔离。
日志与隐私
完整请求对象包含 Authorization、Cookie、查询参数、客户端证书信息与可能的 Body。源码为了调试暴露许多字段,不代表它们都适合日志。日志中应按允许列表记录方法、路由模板、状态码、耗时和请求 ID,敏感 Header 默认不记录,错误也避免回显内部地址与凭据。
二十九、Handler 与真实网络测试
Handler 单元测试可以使用 httptest.NewRequest 和 httptest.NewRecorder:
func TestHealth(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
healthHandler(rec, req)
resp := rec.Result()
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d", resp.StatusCode)
}
}Recorder 很快,却不是完整网络。它不会复现 TCP 分段、真实连接复用、TLS、客户端提前断开、某些 Flusher/Hijacker 行为和慢速 Body。涉及协议边界时,用 httptest.NewServer 或 NewTLSServer 启动真实 Server,再使用它提供的 Client。
客户端测试最好注入 RoundTripper,而不是改全局 DefaultTransport:
client := &http.Client{
Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"ok":true}`)),
Request: req,
}, nil
}),
}fake Response 的 Body 应始终非 nil,测试还可以包一层记录 Close 是否被调用。否则客户端忘关 Body 的 bug 在测试中完全看不见。
连接池相关测试应复用同一个 Client,通过 Server 的 ConnState、自定义 Listener 或 httptrace 观察连接,而不是依赖端口号猜测。超时测试要给足调度误差,避免断言恰好在某个毫秒发生;更可靠的是用 channel 控制服务器进入某阶段,再触发 Context 取消。
三十、客户端配置示例
把前面的源码结论收拢,一个普通 JSON 上游客户端可以从下面的结构开始:
type APIClient struct {
baseURL *url.URL
client *http.Client
}
func NewAPIClient(rawURL string) (*APIClient, error) {
baseURL, err := url.Parse(rawURL)
if err != nil {
return nil, err
}
tr := http.DefaultTransport.(*http.Transport).Clone()
tr.MaxIdleConns = 100
tr.MaxIdleConnsPerHost = 20
tr.MaxConnsPerHost = 50
tr.ResponseHeaderTimeout = 5 * time.Second
return &APIClient{
baseURL: baseURL,
client: &http.Client{
Transport: tr,
Timeout: 15 * time.Second,
},
}, nil
}
func (c *APIClient) GetUser(ctx context.Context, id string) (*User, error) {
if id == "" || id == "." || id == ".." || strings.Contains(id, "/") {
return nil, errors.New("invalid user id")
}
u := *c.baseURL
u.Path = path.Join(u.Path, "users", id)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
resp, err := c.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
return nil, fmt.Errorf("get user: status %s: %s", resp.Status, body)
}
var user User
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&user); err != nil {
return nil, err
}
return &user, nil
}这个示例表达了几条重要约束:Client 和 Transport 长期复用;调用方 Context 向下传播;总超时与响应头超时分层;状态码由业务判断;错误 Body 有读取上限;成功 JSON 也不允许无限增长。
若上游返回大文件,应移除客户端总超时或改成合适预算,流式写入目标并做大小限制。若请求需要重试,应把 Body 构造成可重放、添加幂等键,并在总 Context 内做有限退避。源码提供机制,业务决定策略。
三十一、服务端配置示例
服务端应显式创建 mux、Server 并实现关闭流程:
type App struct {
db *sql.DB
}
func (a *App) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", a.health)
mux.HandleFunc("/users", a.users)
return requestIDMiddleware(loggingMiddleware(mux))
}
func (a *App) users(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
var input CreateUserRequest
dec := json.NewDecoder(r.Body)
dec.DisallowUnknownFields()
if err := dec.Decode(&input); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
user, err := a.createUser(r.Context(), input)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(user)
}路由显式检查方法,因为 ServeMux 只处理路径;Body 在解析前限长;数据库调用拿请求 Context;Header 在状态码之前提交;内部错误不把数据库细节回显给客户端。再配合前面带网络超时和 Shutdown 的 Server,才是一条完整生命线。
生产系统还应补充访问日志、panic 恢复、并发限制、鉴权、指标和健康检查。中间件顺序会改变语义:request ID 应在日志和 recovery 之前建立,认证失败是否计入业务指标要事先定义,recover 必须覆盖真正可能 panic 的下游。接口都叫 Handler,组合顺序仍是程序逻辑。
三十二、HTTP 请求的错误层次
net/http 没有一个统一的「HTTP 错误」类型,因为失败发生在不同层。把它们全变成一句「调用接口失败」,既难排查,也容易重试错对象。
第一层是构造错误。NewRequest 可能因为方法非法、URL 无法解析或 Context 为 nil 而失败。这时尚未进入网络,重试同样输入没有意义,应修正调用参数。
第二层是获得响应前的传输错误。DNS、拨号、代理、TLS、写请求、等待响应头和取消都可能让 Client.Do 返回 error。高层 Client 通常把它包装成 *url.Error,其中包含操作、去掉密码后的 URL 和底层错误。可用 errors.As 检查类型,再用 errors.Is、net.Error 和 Context 判断取消或超时:
resp, err := client.Do(req)
if err != nil {
var urlErr *url.Error
if errors.As(err, &urlErr) && urlErr.Timeout() {
return fmt.Errorf("upstream timeout: %w", err)
}
return fmt.Errorf("send request: %w", err)
}不要只靠 net.Error.Temporary() 决定重试。即使网络错误看起来临时,Body 可能已经写到服务端;是否可重试还取决于方法、幂等键、GetBody、总预算与业务副作用。Transport 的保守重试只解决它能证明安全的一小部分情况。
第三层是 HTTP 状态。拿到合法 404、429 或 503 时,Do 的 error 仍为 nil。此时 Response 和 Body 都归调用者,必须读取或关闭。是否重试 429 要看 Retry-After 与请求幂等性,是否把 404 当错误则完全是业务决定。状态码不是 Go error,正是 RoundTripper 层次边界的体现。
第四层是 Body 错误。响应头正常不代表主体完整,io.Copy 或 JSON Decode 仍可能遇到超时、连接重置、意外 EOF、gzip 损坏和格式错误。如果 ContentLength 已知,应确认实际消费满足预期;对 JSON 等结构化数据,协议完整与业务字段合法又是两次不同验证。
第五层是清理错误。Body.Close、文件落盘和校验都可能失败。普通响应 Body 的 Close 错误经常无法改变已经得到的业务结果,但上传请求体、文件和压缩流的关闭可能包含重要收尾。是否覆盖主错误,应由资源语义决定,不能默认 Close 永远成功。
服务端的错误边界同样分层。无法解析请求时,Server 自己返回 400 或关闭连接;TLS 握手、Accept 和 Handler 异常写入 Server.ErrorLog;业务校验则由 Handler 选择状态码和响应格式。ErrorLog 不是访问日志,它不会自动记录每个请求、状态和耗时。访问日志应由中间件实现,并在 ResponseWriter 包装中记录第一次状态码与累计字节。
包装错误时保留 %w,让上层仍能识别 Context、URL 和网络错误;对外响应则使用稳定、有限的信息,内部错误细节进入受控日志。一个错误既要供机器判断,也要供人排查,还不能泄露凭据,这三件事需要分别设计。
三十三、源码阅读后的几条判断
读完两条调用链后,可以根据实现机制解释常见工程规则。
第一,复用 Client 和 Transport。连接池属于 Transport,每请求新建会丢掉最昂贵的状态;并发安全本来就是它的契约。
第二,Response.Body 的处理是连接管理的一部分。及时 Close 防泄漏,读到 EOF 帮助 HTTP/1 连接复用,有上限地消费错误 Body 才不会把优化变成攻击面。
第三,超时必须按阶段设计。总超时、拨号、TLS、响应头、Body、服务端读头、读体、写响应和空闲连接各管一段。一个 Timeout 字段无法替代整条时间轴。
第四,Context 取消不是事务回滚。它停止等待和尽力中断 I/O,但副作用可能已经发生。重试与取消都必须回到幂等设计。
第五,Handler 和 RoundTripper 是服务端与客户端的扩展接口。服务端中间件包装 Handler,客户端中间件包装 RoundTripper。包装时要遵守 Body、并发和可选接口契约,而不能只转调方法。
第六,默认值以易用为先,不等于生产边界。DefaultClient 没有总超时,快捷 ListenAndServe 创建的 Server 也没有读写超时。标准库让 Hello World 足够短,资源上限仍由应用负责。
三十四、结尾:从 API 回到资源生命周期
http.Get 和 ListenAndServe 使用默认配置简化了常见调用,但内部状态仍然存在。使用时不能忽略默认值的范围,也不能把 Body 当作已经读入内存的普通字段、把连接池理解成无需管理的全自动机制,或者把 Context 取消理解成业务事务回滚。
客户端的一次请求会穿过 Client 的策略循环,进入 Transport 的连接选择,再由 persistConn 的读写 goroutine 完成线上事务;服务端从 Accept 得到连接,在 conn.serve 中解析请求,把窄小的 Handler 接口交给业务,再由内部 response 完成协议收尾。连接是否复用、超时从哪里返回、Header 何时冻结,都能在这条链上找到具体位置。
net/http 通过接口边界隐藏了大部分传输细节,同时保留了 Transport、Server、Handler 和 RoundTripper 等配置与扩展点。阅读源码的目的不是在业务中重写这些实现,而是确定问题所在的层次。多数应用仍应复用 Client、设置合适的期限、正确处理 Body,并为 Server 配置明确的资源边界。
Reference:package
net/http、client.go、transport.go、request.go、response.go、transfer.go、server.go、header.go、h2_bundle.go、Go Blog:Introducing HTTP Tracing
