请求日志读取 Body 后,业务代码解析 JSON 得到 EOF;客户端再次发送同一个请求,服务端收到的 Body 变成空数据。根因相同:Body 是数据流,不是可以重复访问的字节数组。
Body 为什么只能读一次
http.Request.Body 的类型是 io.ReadCloser:
type ReadCloser interface {
io.Reader
io.Closer
}
每次调用 Read,读取位置都会向后移动。读到 EOF 后不会自动回到开头。所谓“多次读取”,实际是先保存原始数据,再创建新的 Reader。
服务端和客户端的处理方式不同:服务端读取并恢复 r.Body;客户端保存原始数据,通过 GetBody 或重新创建 Request 获得新 Body。
服务端读取并恢复 Body
日志、验签和审计中间件会先读取请求体。读取完成后,必须用缓存的数据重建 r.Body,否则后续 Handler 只能读到 EOF。
下面把请求体限制在 1 MiB,避免无限制读取占满内存:
func captureBody(w http.ResponseWriter, r *http.Request) ([]byte, error) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
data, err := io.ReadAll(r.Body)
closeErr := r.Body.Close()
if err != nil { return nil, err }
if closeErr != nil { return nil, closeErr }
r.Body = io.NopCloser(bytes.NewReader(data))
return data, nil
}
复制发生在 io.ReadAll(r.Body):它把原 Body 的内容读入新的 []byte。随后,bytes.NewReader(data) 基于这份副本创建一个从头读取的新 Reader,并放回 r.Body。io.NopCloser 只补充 Close 方法。
MaxBytesReader 在超限时返回 *http.MaxBytesError,应用需要据此返回 413:
func writeBodyError(w http.ResponseWriter, err error) {
status := http.StatusBadRequest
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) { status = http.StatusRequestEntityTooLarge }
http.Error(w, http.StatusText(status), status)
}
下面是完整的日志中间件。它不记录原始敏感数据,只记录 Body 的 SHA-256 摘要:
func bodyLog(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
data, err := captureBody(w, r)
if err != nil { writeBodyError(w, err); return }
sum := sha256.Sum256(data)
slog.Info("request", "body_sha256", fmt.Sprintf("%x", sum))
next.ServeHTTP(w, r)
})
}
captureBody 是第一次读取,next.ServeHTTP 中的 JSON 解析是第二次读取。第二次能够成功,是因为 captureBody 已把新 Reader 放回 r.Body。
如果第三个消费者还要读取 Body,再用 data 重建一次 r.Body。更推荐让日志、验签等逻辑共享 data,只把恢复后的 r.Body 留给参数解析。服务端不会使用 Request.GetBody。
客户端通过 GetBody 重放
客户端也不能重复读取同一个 req.Body。第一次发送使用 req.Body,需要重放时调用 req.GetBody() 创建新 Body。
把 *bytes.Reader 直接传给 http.NewRequest,标准库会自动设置 GetBody:
data := []byte(`{"name":"gopher"}`)
reader := bytes.NewReader(data)
req, err := http.NewRequest("POST", "https://example.com", reader)
if err != nil {
panic(err)
}
fmt.Println(req.GetBody != nil) // true
针对 *bytes.Reader,标准库会保存 Reader 的初始状态。其核心逻辑如下:
snapshot := *reader
req.GetBody = func() (io.ReadCloser, error) {
reader := snapshot
return io.NopCloser(&reader), nil
}
每调用一次 GetBody,都会复制初始状态,得到读取位置独立的新 Reader。先封装一次安全读取:
func readBody(req *http.Request) ([]byte, error) {
body, err := req.GetBody()
if err != nil { return nil, err }
defer body.Close()
return io.ReadAll(body)
}
连续读取两次并检查错误:
data1, err := readBody(req)
if err != nil { panic(err) }
data2, err := readBody(req)
if err != nil { panic(err) }
fmt.Println(string(data1), string(data2))
两次输出都是 {"name":"gopher"}。这不是把旧 Body 倒回去,而是基于同一份 data 创建了两个新 Body。
GetBody 主要供标准库处理 307、308 重定向和满足条件的底层网络重试。业务层处理 500、429 或超时时,应保存原始数据,每次重新创建 Request,不要重复调用 client.Do(req) 发送同一个 Request。
Request.Clone 对 Body 只做浅拷贝,不能实现重放。调用 NewRequest 前也不要自行添加 io.NopCloser,否则标准库无法识别内部的 *bytes.Reader,不会自动设置 GetBody。
生产环境的边界
-
服务端应使用
http.MaxBytesReader限制请求体大小,超限返回413 Request Entity Too Large。 -
密码、Token 和身份证号等敏感字段必须脱敏,二进制或 multipart 请求不应直接写入日志。
-
大文件和流式请求不适合完整缓存,可使用临时文件、流式处理或明确禁止重放。
-
GetBody只解决数据重放,不保证业务幂等。扣款、创建订单等操作仍需服务端校验幂等键。
写在最后
Go HTTP Body 只能顺序消费一次。服务端的解决方法是“读取、缓存、重建 r.Body”,客户端的解决方法是“保存原始数据、创建新 Body”。真正提供重复读取能力的是新的 Reader,而不是 io.NopCloser。