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.BodyResponseWriter。若启动 goroutine 后立刻返回,再由 goroutine 写响应,会产生竞态、截断或静默失败。

服务端通常不需要主动关闭 r.Body,Server 会处理它;但必须限制并消费需要的内容。客户端侧相反:Client.Do 成功返回后,调用者拥有 resp.Body,无论状态码是什么都必须关闭。

ResponseWriter 是状态机而不是普通缓冲区:

  1. 第一次 WriteHeader(code) 提交状态码和当时的头部;
  2. 第一次 Write 若尚未提交,会隐式提交 200 OK
  3. 提交后再改普通响应头或再调用 WriteHeader 已经太晚;
  4. 写入中途发生错误时,通常无法把已发出的 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.Flusherhttp.Hijackerio.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,但包装器必须正确转发 HeaderWriteHeaderWrite,并考虑 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 的建连超时、TLSHandshakeTimeoutResponseHeaderTimeoutExpectContinueTimeout

总预算应向下分配。排队、DNS、连接、TLS、服务端处理、下载 body 和重试都消费同一个预算。错误诊断用 errors.Isnet.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.NewRequestbytes.Readerstrings.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.Serverhttp.Clienthttp.Transport
  • 为请求头、body、响应 body、各网络阶段和整体操作设置合理边界;
  • 在写响应前完成校验,明确状态码,提交后不再幻想能改写错误;
  • Handler 内同步使用 Request 与 ResponseWriter,下游贯穿请求 context;
  • 客户端对任何状态码都关闭 body,并按大小与状态显式处理内容;
  • 连接池按目标主机和并发调节,避免每请求新建 Transport;
  • 重定向验证目标,重试只用于可安全重放且预算足够的操作;
  • 关闭时先摘流量,再等待活跃请求,并单独管理劫持连接和后台任务;
  • httptest.Serverhttptrace、goroutine profile 和 socket 指标定位不同层次问题;
  • 在反向代理、HTTP/1.1、HTTP/2 和真实慢网络下验证,不能只通过本机 happy path。

可靠 HTTP 程序依赖贯穿全程的约束:Handler 只拥有当前请求,ResponseWriter 提交后不可回退,body 有所有者与上限,超时和重试服从端到端预算。


系列导航与关联阅读

官方资料

本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。