Go 基础体系 · 第 31/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。
Go net/http 基础:从请求生命周期到可靠客户端
本文以 Go 1.26.4 为基准。net/http 同时提供服务端和客户端:请求会经过监听、连接复用、头部解析、路由、body、取消和响应提交;客户端还要处理 DNS、建连、TLS、连接池与响应体所有权。可靠实现的关键,是为每个阶段建立时间、大小和资源边界。
本文只讨论标准库 HTTP 传输与 Handler 生命周期。JSON 字段契约、通用 io.Reader 组合、鉴权协议、框架路由以及可观测性系统分别属于相邻主题;示例会用到它们,但不会以链接替代这里必须掌握的 HTTP 语义。
1. Server、Handler 与 ServeMux 各自负责什么
http.Server 管理监听、连接和服务器级超时;http.Handler 处理一条请求;http.ServeMux 根据方法和路径选择 Handler。核心接口只有一个方法:
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
Go 1.26.4 的 ServeMux 支持 "GET /articles/{id}" 形式的模式,并通过 r.PathValue("id") 取得路径变量。更具体的模式优先;冲突模式会在注册时 panic,适合让配置错误尽早暴露。GET 模式也匹配 HEAD,标准库会抑制响应 body,但 Handler 仍应生成与 GET 一致的状态和头部。
不要把所有工作塞入匿名 Handler。解析 HTTP 输入、调用领域函数、把领域结果映射成 HTTP 响应应是三个清楚步骤,这样协议边界可测试,业务函数也不会依赖 ResponseWriter。
2. 一条请求的对象所有权与生命周期
服务端收到的 *http.Request 只在 ServeHTTP 调用期间有效。Handler 可以读取请求并同步写响应,但返回后不得继续使用 Request.Body 或 ResponseWriter。若启动 goroutine 后立刻返回,再由 goroutine 写响应,会产生竞态、截断或静默失败。
服务端通常不需要主动关闭 r.Body,Server 会处理它;但必须限制并消费需要的内容。客户端侧相反:Client.Do 成功返回后,调用者拥有 resp.Body,无论状态码是什么都必须关闭。
ResponseWriter 是状态机而不是普通缓冲区:
- 第一次
WriteHeader(code)提交状态码和当时的头部; - 第一次
Write若尚未提交,会隐式提交200 OK; - 提交后再改普通响应头或再调用
WriteHeader已经太晚; - 写入中途发生错误时,通常无法把已发出的 200 改成 500。
因此应先完成可能失败的校验,再设置头部和状态,最后编码响应。大响应若需要流式传输,要接受“传输中途失败只能中断连接或写协议内错误帧”的现实。
3. 请求 body 是流:限制、解码与完整性
Content-Length 只是对端声明,可能缺失或不可信。服务端应使用 http.MaxBytesReader 给 body 设置硬上限;超过限制时读取返回 *http.MaxBytesError。仅使用 io.LimitReader 会截断流,却不直接区分“刚好到上限”和“实际超限”。
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
var input createArticleRequest
if err := decoder.Decode(&input); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
http.Error(w, "body must contain one JSON value", http.StatusBadRequest)
return
}
解析前检查媒体类型时要用 mime.ParseMediaType,不能简单比较字符串,因为合法值可能是 application/json; charset=utf-8。空 body、多个 JSON 值、未知字段、超限和语义校验失败是不同错误;对外可保持稳定错误码,对内日志保留分类,但不要回显包含敏感输入的底层错误。
4. ResponseWriter 的可选能力与协议差异
历史代码常通过类型断言取得 http.Flusher、http.Hijacker 或 io.ReaderFrom。并非所有 ResponseWriter 都实现这些接口,middleware 包装器也可能意外丢失能力。Go 提供 http.NewResponseController(w) 统一访问刷新、劫持和逐响应 deadline 等能力;操作不支持时会返回错误。
Flush 只要求把已缓冲数据向下推进,不保证客户端应用已经读取。HTTP/1.1 的流式响应可能使用 chunked encoding;HTTP/2 有自己的帧和流控。Hijacker 主要属于 HTTP/1.x,HTTP/2 通常不支持。普通 SSE 或流式 JSON 不应依赖劫持连接,而应使用标准响应流、正确 Content-Type、Flush 和请求 context。
5. Middleware 是有顺序的 Handler 包装
middleware 的类型通常是 func(http.Handler) http.Handler。若按 logging(recovering(auth(mux))) 组合,请求从外到内进入,再从内到外返回。顺序会改变语义:请求 ID 应在日志之前生成;恢复 panic 的层应覆盖可能 panic 的内部层;鉴权拒绝后,业务 Handler 不会执行。
func requestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" {
id = strconv.FormatInt(time.Now().UnixNano(), 36)
}
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), requestIDKey{}, id)))
})
}
context key 应使用包内自定义类型,避免键冲突。Middleware 若要记录状态与字节数,可包装 ResponseWriter,但包装器必须正确转发 Header、WriteHeader、Write,并考虑 Flusher 等可选能力。更稳妥的原则是只包装确实需要的行为,并为流式与 HTTP/2 路径写测试。
6. Context 取消传递的是停止信号
服务端请求 context 会在客户端连接断开、HTTP/2 流取消、Handler 返回等情况下取消;服务器关闭也会影响正在进行的请求。下游数据库和 HTTP 调用应接收 r.Context(),这样上游已经放弃时不会继续浪费资源。
context 取消不等于 goroutine 被强制杀死。计算循环、阻塞在不支持 context 的调用、把任务丢进无界队列,都可能继续运行。长操作应在自然边界检查 ctx.Done(),依赖也要提供 context-aware API。
不要把请求 context 保存给 Handler 返回后的后台任务。确实需要异步执行时,应先复制所需的不可变值,并由独立服务生命周期 context、队列和关闭协议管理。也不要用 context.Background() 随手切断取消链;那通常会制造关闭时无法收敛的工作。
7. Server 超时分别保护哪个阶段
生产服务应显式构造 http.Server:
srv := &http.Server{
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 20,
}
ReadHeaderTimeout 限制读取请求头,直接防御慢速头部攻击;ReadTimeout 进一步覆盖请求读取阶段,但对允许长时间上传的接口可能太粗;WriteTimeout 限制响应写阶段,却不适合无限期流式响应;IdleTimeout 限制 keep-alive 连接等待下一请求的时间。它们不是业务 Handler 的精确执行预算,业务预算仍应用 context deadline 表达。
超时值必须根据负载、最大合法 body、最低可接受带宽和下游预算决定。全设得很短会误杀慢客户端,全不设置则可能让连接和 goroutine 被慢请求长期占用。公网服务还应在反向代理、负载均衡器和应用三层核对超时方向,避免外层先断开而内层仍工作。
8. 优雅关闭不是关闭监听就结束
ListenAndServe 在正常 Shutdown 后返回 http.ErrServerClosed,它不应被当成故障。关闭流程通常是:收到信号,先从就绪检查或负载均衡摘流量,再用有期限的 context 调用 Shutdown,等待活跃 Handler 返回,最后关闭独立后台任务。
shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
return fmt.Errorf("shutdown HTTP server: %w", err)
}
Shutdown 不会等待被劫持的连接,也不知道应用自行启动的 goroutine。WebSocket、后台消费者和异步刷新器需要单独登记并关闭。如果期限耗尽,应明确是强制退出还是继续等待;容器终止宽限期必须大于应用的关闭预算。
9. Client、Transport 与连接池的边界
http.Client 是并发安全的高层策略对象,http.Transport 管理连接池、代理、压缩、DNS 后的建连与 TLS。两者都应长期复用。每次请求新建 Transport 会失去 keep-alive,增加 DNS/TCP/TLS 成本,并可能耗尽临时端口。
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.MaxIdleConns = 100
transport.MaxIdleConnsPerHost = 20
transport.IdleConnTimeout = 90 * time.Second
transport.ResponseHeaderTimeout = 3 * time.Second
client := &http.Client{
Transport: transport,
Timeout: 5 * time.Second,
}
从默认 Transport Clone 可以保留代理、HTTP/2 尝试等合理默认值。MaxConnsPerHost 是活跃加拨号中的每主机硬上限,过低会排队;MaxIdleConnsPerHost 过低则使突发流量频繁重连。池大小要依据目标主机数、并发和下游容量调整,不是越大越好。
10. 客户端超时是一组预算,不是一个数字
Client.Timeout 覆盖从开始请求到读完响应 body 的总时间,简单而有用;请求 context deadline 也能约束全程,二者先到者生效。Transport 还提供阶段性边界:DialContext 的建连超时、TLSHandshakeTimeout、ResponseHeaderTimeout、ExpectContinueTimeout。
总预算应向下分配。排队、DNS、连接、TLS、服务端处理、下载 body 和重试都消费同一个预算。错误诊断用 errors.Is 和 net.Error 判断类别,不依赖错误字符串。
超时后无法确定服务端是否执行了有副作用操作:请求可能在响应返回前已成功提交。因而 POST 超时不能简单视为“未发生”,需要幂等键、状态查询或业务补偿。
11. 响应 body、状态码与连接复用
Client.Do 只把传输错误作为 error 返回;404、429、500 都是成功收到的 HTTP 响应,err 通常为 nil,调用者必须检查 resp.StatusCode。读取不可信响应要设置大小上限,并在错误信息中保留截断后的安全摘要。
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20+1))
if err != nil {
return fmt.Errorf("read response: %w", err)
}
if len(body) > 1<<20 {
return errors.New("response body exceeds 1 MiB")
}
为了让 HTTP/1.x 连接回到池中,通常要把 body 读到 EOF 并关闭。若响应巨大且已决定放弃,不应为复用连接无界 drain;关闭即可,接受该连接可能不能复用。HTTP/2 在同一连接上复用流,细节不同,但关闭 body 的所有权规则不变。
12. 重定向、重试与幂等边界
Client 默认最多跟随若干重定向,并可能在重定向时改变方法或移除敏感头。对 webhook、内部控制面或只允许固定目的地的请求,应设置 CheckRedirect,验证新 URL 的 scheme、host 和跳转次数,防止凭证泄漏或 SSRF 边界被跳转绕过。
标准 Transport 只会在很有限、可判断安全的条件下重试网络失败。应用层重试必须同时满足:操作具有幂等语义或携带服务端支持的幂等键;错误确实可重试;还有剩余 deadline;采用有上限的指数退避和抖动;遵守 Retry-After。不要重试明确的参数错误,也不要在请求 body 无法重新生成时重试。
Request.GetBody 允许重建 body,http.NewRequest 对 bytes.Reader、strings.Reader 等已知类型会自动设置它。大上传不能为了可重试而全部复制进内存,应在协议层设计分块、断点或可重放来源。
13. 常见故障与诊断顺序
连接数和端口持续升高。 检查是否每次创建 Transport、是否忘记关闭响应 body、是否因未读完 body 导致无法复用;用连接池指标、httptrace 和系统 socket 状态交叉确认。
Handler 已返回但客户端迟迟收不到。 检查代理缓冲、应用是否 Flush、客户端是否按行读取,以及 HTTP/2 流控;不要只在本机直连环境判断。
大量 context deadline exceeded。 先分清是调用者 deadline、Client 总超时,还是建连/TLS/响应头阶段超时。httptrace.ClientTrace 能观察 DNS、连接获取、建连、TLS 和首字节时间;它适合采样诊断,不宜给每个请求输出高量日志。
服务关闭卡住。 抓 goroutine profile,定位哪些 Handler 没响应 context、哪些 body 读写没有期限,以及是否存在劫持连接或独立后台 goroutine。
偶发空响应或错误状态不对。 检查是否先写 body 后写状态、panic recovery 是否发生在部分响应已经提交后、多个 goroutine 是否并发写同一 ResponseWriter。使用 httptest.ResponseRecorder 验证普通路径,再用真实 httptest.Server 验证连接与取消语义。
14. 可运行综合示例:有界服务端与复用客户端
下面程序监听随机本地端口,注册查询与慢请求路由,使用显式 Server 超时和复用 Client。它先验证正常响应和 ETag 条件请求,再用短 context 触发可识别的超时,最后优雅关闭。代码不依赖外部网络。
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"time"
)
type article struct {
ID string `json:"id"`
Title string `json:"title"`
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /articles/{id}", getArticle)
mux.HandleFunc("GET /slow", slow)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
panic(err)
}
srv := &http.Server{
Handler: mux,
ReadHeaderTimeout: 2 * time.Second,
IdleTimeout: 30 * time.Second,
}
serveErr := make(chan error, 1)
go func() { serveErr <- srv.Serve(listener) }()
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.MaxIdleConnsPerHost = 4
client := &http.Client{Transport: transport, Timeout: 2 * time.Second}
defer transport.CloseIdleConnections()
baseURL := "http://" + listener.Addr().String()
etag := fetch(client, baseURL+"/articles/42", "")
fetch(client, baseURL+"/articles/42", etag)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/slow", nil)
if err != nil {
panic(err)
}
_, err = client.Do(req)
fmt.Printf("slow request timed out: %v\n", errors.Is(err, context.DeadlineExceeded))
shutdownCtx, stop := context.WithTimeout(context.Background(), time.Second)
defer stop()
if err := srv.Shutdown(shutdownCtx); err != nil {
panic(err)
}
if err := <-serveErr; !errors.Is(err, http.ErrServerClosed) {
panic(err)
}
}
func getArticle(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
etag := `"article-` + id + `-v1"`
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("ETag", etag)
if err := json.NewEncoder(w).Encode(article{ID: id, Title: "HTTP boundaries"}); err != nil {
return // 响应可能已经提交,只能结束传输。
}
}
func slow(w http.ResponseWriter, r *http.Request) {
select {
case <-time.After(200 * time.Millisecond):
_, _ = io.WriteString(w, "done\n")
case <-r.Context().Done():
return
}
}
func fetch(client *http.Client, url, etag string) string {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
panic(err)
}
if etag != "" {
req.Header.Set("If-None-Match", etag)
}
resp, err := client.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
if err != nil {
panic(err)
}
fmt.Printf("status=%d body=%s", resp.StatusCode, body)
return resp.Header.Get("ETag")
}
运行结果中第一次请求为 200,第二次携带 ETag 后为 304,慢请求报告 context deadline。这个示例刻意没有加入重试:是否能重试必须由业务幂等契约决定,不能由通用 HTTP helper 猜测。
15. 工程检查清单
- 显式创建并长期管理
http.Server、http.Client和http.Transport; - 为请求头、body、响应 body、各网络阶段和整体操作设置合理边界;
- 在写响应前完成校验,明确状态码,提交后不再幻想能改写错误;
- Handler 内同步使用 Request 与 ResponseWriter,下游贯穿请求 context;
- 客户端对任何状态码都关闭 body,并按大小与状态显式处理内容;
- 连接池按目标主机和并发调节,避免每请求新建 Transport;
- 重定向验证目标,重试只用于可安全重放且预算足够的操作;
- 关闭时先摘流量,再等待活跃请求,并单独管理劫持连接和后台任务;
- 用
httptest.Server、httptrace、goroutine profile 和 socket 指标定位不同层次问题; - 在反向代理、HTTP/1.1、HTTP/2 和真实慢网络下验证,不能只通过本机 happy path。
可靠 HTTP 程序依赖贯穿全程的约束:Handler 只拥有当前请求,ResponseWriter 提交后不可回退,body 有所有者与上限,超时和重试服从端到端预算。
系列导航与关联阅读
- 系列入口:Go 完整技术体系学习路线:从语法、并发到框架、中间件与 AI
- 上一篇:Go 时间处理:time.Time、Duration、时区、Timer 与 Ticker
- 下一篇:Go database/sql 基础:连接池、事务、Context 与 NULL
- 延伸:Go context 完整指南:取消、超时、Deadline 与 Value
- 延伸:Go I/O 抽象:io.Reader、Writer、Copy 与流式处理
- 延伸:Go JSON 编解码:结构体标签、Decoder、数字与未知字段
官方资料
本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论