为什么在中间件里读了一次 body,handler 里的 ShouldBind 就报 EOF?ShouldBindBodyWith 又是怎么解决的、代价多大?这篇从源码到 1 MB 请求体的内存对比,把这件事讲清楚。
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 的生命周期约束:只在当前请求内有效。
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=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。耗时几乎没有差别。所以它的成本主要是内存,请求体越大越明显;只绑定一次时不要用它。