bigywater.tech
Gin · 24

请求体只能读一次:ShouldBindBodyWith 的缓存

为什么在中间件里读了一次 body,handler 里的 ShouldBind 就报 EOF?ShouldBindBodyWith 又是怎么解决的、代价多大?这篇从源码到 1 MB 请求体的内存对比,把这件事讲清楚。

EOF
同一个请求第二次 ShouldBindJSON 的错误
23 字节
ShouldBindBodyWith 缓存到 c.Keys[BodyBytesKey] 的内容 = 整个 body
+2.25 MB
1.06 MB 的 JSON 请求体上 ShouldBindBodyWith 比 ShouldBindJSON 多分配的内存

缓存的实现

实测 · 本机执行输出
  context.go 的 ShouldBindBodyWith(Gin v1.12.0):
     928 | func (c *Context) ShouldBindBodyWith(obj any, bb binding.BindingBody) (err error) {
     929 |     var body []byte
     930 |     if cb, ok := c.Get(BodyBytesKey); ok {
     931 |         if cbb, ok := cb.([]byte); ok {
     932 |             body = cbb
     933 |         }
     934 |     }
     935 |     if body == nil {
     936 |         body, err = io.ReadAll(c.Request.Body)
     937 |         if err != nil {
     938 |             return err
     939 |         }
     940 |         c.Set(BodyBytesKey, body)
     941 |     }
     942 |     return bb.BindBody(body, obj)
     943 | }
  context.go:  47 | const BodyBytesKey = "_gin-gonic/gin/bodybyteskey"

请求体是一个只能读一次的流。ShouldBindBodyWith 的做法很直接:第一次把整个 body ReadAll 出来,存进 c.Keys 的一个固定 key,之后每次都用缓存解析。所以它也受 Keys 的生命周期约束:只在当前请求内有效。

哪些读法会让 Body 失效

实测 · 本机执行输出
    ShouldBindJSON x2:      200 第 1 次 err=<nil> a={Name:ann Age:30} | 第 2 次 err=EOF b={Name: Age:0}
    ShouldBindBodyWith x2:  200 第 1 次 err=<nil> a={Name:ann Age:30} | 第 2 次 err=<nil> b={Name:ann Age:30} | 缓存了 23 字节
    JSON 再 YAML(同一份 body,{...} 也是合法 YAML): 200 JSON err=<nil> a={Name:ann Age:30} | YAML err=<nil> b={Name:ann Age:30}
    GetRawData 之后再绑定:   200 GetRawData 读到 23 字节 err=<nil>;之后 ShouldBindJSON err=EOF a={Name: Age:0}

任何读完 Request.Body 的操作都会让后面的 ShouldBindJSON 得到 EOF:再来一次 ShouldBindJSON、GetRawData、io.ReadAll。ShouldBindBodyWith 可以连续用不同格式解析同一份缓存(JSON 与 YAML 都成功,因为 JSON 是 YAML 的子集)。

中间件里读 body

实测 · 本机执行输出
    中间件还原 Body=false: 200 中间件读到 23 字节;handler 绑定 err=EOF a={Name: Age:0}
    中间件还原 Body=true : 200 中间件读到 23 字节;handler 绑定 err=<nil> a={Name:ann Age:30}

验签、审计日志等中间件常要读 body。读完之后一定要把 c.Request.Body 换成一个新的 io.NopCloser(bytes.NewReader(b));否则 handler 里的绑定拿到的就是 EOF。如果后面的所有绑定都用 ShouldBindBodyWith,就不需要手工还原。

代价

实测 · 本机执行输出
    请求体 1057791 字节(约 1.06 MB),20000 个对象
    第 1 轮
    ShouldBindJSON(流式)                 中位 11.03 ms/请求,11.91 MB/请求分配
    ShouldBindBodyWith(JSON)           中位 11.10 ms/请求,14.16 MB/请求分配
    ReadAll + json.Unmarshal(对照)       中位 9.84 ms/请求,11.91 MB/请求分配
    第 2 轮
    ShouldBindJSON(流式)                 中位 11.06 ms/请求,11.91 MB/请求分配
    ShouldBindBodyWith(JSON)           中位 11.24 ms/请求,14.16 MB/请求分配
    ReadAll + json.Unmarshal(对照)       中位 9.82 ms/请求,11.91 MB/请求分配

流式的 ShouldBindJSON 与 ReadAll + Unmarshal 的分配相同(11.91 MB),ShouldBindBodyWith 还要多留一份 body 并经过 ReadAll 的扩容,约多 2.25 MB。耗时几乎没有差别。所以它的成本主要是内存,请求体越大越明显;只绑定一次时不要用它。

分步演示

Body 只能读一次
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew)/ Gin v1.12.0(go.mod 里 require,源码在模块缓存里);用 go run / go test 直接编译运行,需要库时经代理从 proxy.golang.org 下载;HTTP 实验用 httptest 或本机回环端口(9700 以上);计时用 time.Now / testing.B,数字每次运行会有小幅抖动。前三节用 httptest 直接调用 ServeHTTP;第四节用 gin.CreateTestContext 与 testing.Benchmark,7 轮中位数,整个实验跑了两轮;JSON 编解码用 Gin 默认的 encoding/json;请求体 1057791 字节
    • 没有验证:真实网络里流式解码与先读完再解码的差别(这里 body 在内存里);其他 JSON 库(sonic、jsoniter、go-json)下的分配;MaxBytesReader 与 ShouldBindBodyWith 的组合;表单、multipart 请求(它们由 ParseForm 缓存,不受这个限制)
    京ICP备2026067502号-1京公网安备11010502063487号