请求日志读取 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.Bodyio.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 主要供标准库处理 307308 重定向和满足条件的底层网络重试。业务层处理 500429 或超时时,应保存原始数据,每次重新创建 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