Go 里读取 request body 后,下游拿不到参数:别让日志中间件把请求吃掉

很多 Go Web 项目都会加请求日志:记录 method、path、耗时,再顺手把 body 也打出来。问题通常就出在这个"顺手"上。

http.Request.Body 不是普通字符串,它是一个只能往前读的流。中间件里先 io.ReadAll(r.Body),后面的 handler 再去 json.NewDecoder(r.Body).Decode(&req),读到的就是空内容。这个问题不玄学,也不是框架 bug。它只是 body 被前面的人读完了。

下面用标准库写一个最小例子,Gin、Echo、Fiber 里遇到的现象本质一样。

一个会把 body 吃掉的中间件

go 复制代码
package main

import (
    "encoding/json"
    "io"
    "log"
    "net/http"
)

type CreateUserReq struct {
    Name string `json:"name"`
}

func badLog(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        b, _ := io.ReadAll(r.Body)
        log.Printf("body=%s", string(b))

        // 这里没有把 r.Body 放回去,后面的 handler 已经没东西可读了
        next.ServeHTTP(w, r)
    })
}

func createUser(w http.ResponseWriter, r *http.Request) {
    var req CreateUserReq
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "bad json: "+err.Error(), http.StatusBadRequest)
        return
    }
    w.Write([]byte("ok: " + req.Name))
}

复现步骤很简单:

bash 复制代码
curl -i -X POST http://127.0.0.1:8080/users \
  -H 'content-type: application/json' \
  -d '{"name":"alice"}'

中间件日志能看到 {"name":"alice"},但 handler 返回 400,常见错误是 EOF。这类 bug 很烦,因为日志看起来"明明有 body",真正的业务代码却拿不到。

正确做法:读完以后把流重新塞回去

如果请求体不大,可以读出来后再用 io.NopCloser(bytes.NewReader(b)) 还原。

go 复制代码
package main

import (
    "bytes"
    "io"
    "log"
    "net/http"
)

func safeLog(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.Body == nil {
            next.ServeHTTP(w, r)
            return
        }

        b, err := io.ReadAll(r.Body)
        if err != nil {
            http.Error(w, "read body failed", http.StatusBadRequest)
            return
        }
        _ = r.Body.Close()

        log.Printf("body=%s", string(b))
        r.Body = io.NopCloser(bytes.NewReader(b))

        next.ServeHTTP(w, r)
    })
}

验证可以写成一个 httptest,比靠浏览器点接口稳得多。

go 复制代码
package main

import (
    "net/http"
    "net/http/httptest"
    "strings"
    "testing"
)

func TestSafeLogDoesNotConsumeBody(t *testing.T) {
    h := safeLog(http.HandlerFunc(createUser))

    req := httptest.NewRequest(
        http.MethodPost,
        "/users",
        strings.NewReader(`{"name":"alice"}`),
    )
    req.Header.Set("content-type", "application/json")

    rr := httptest.NewRecorder()
    h.ServeHTTP(rr, req)

    if rr.Code != http.StatusOK {
        t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
    }
    if !strings.Contains(rr.Body.String(), "alice") {
        t.Fatalf("unexpected body=%s", rr.Body.String())
    }
}

运行:

bash 复制代码
go test ./...

如果把 safeLog 换成前面的 badLog,这个测试会失败。这个对照足够说明问题,不需要靠猜。

但别把所有 body 都完整打进日志

把流放回去只是第一步。生产环境里更大的坑是日志内容本身。

场景 建议
登录、注册、改密 不记录原始 body,最多记录字段名和请求 ID
上传文件 不读完整 body,容易把内存打爆
普通 JSON 接口 限制最大读取长度,超出就截断
排查线上问题 只对指定 path、trace id 或临时开关生效

一个比较稳的写法是限制日志读取大小,比如只读前 4KB。注意,这里的限制是为了日志,不是替代接口层的请求体大小限制。

go 复制代码
const maxLogBody = 4 << 10 // 4KB

func readBodyForLog(r *http.Request) ([]byte, error) {
    if r.Body == nil {
        return nil, nil
    }

    limited := io.LimitReader(r.Body, maxLogBody+1)
    b, err := io.ReadAll(limited)
    if err != nil {
        return nil, err
    }
    _ = r.Body.Close()

    r.Body = io.NopCloser(bytes.NewReader(b))
    if len(b) > maxLogBody {
        return append(b[:maxLogBody], []byte("...[truncated]")...), nil
    }
    return b, nil
}

这段代码还有一个细节:如果你真的要让下游继续读取完整 body,不能只把截断后的内容放回 r.Body。上面这个函数适合"小请求体日志"。如果接口可能接收大 JSON,就应该先限制业务请求体大小,或者用 TeeReader 写到临时 buffer,但仍然要想清楚内存上限。

我会怎么检查这类问题

遇到"日志里有参数,handler 里参数为空",我通常先看四件事:

  • 中间件里有没有 io.ReadAll(r.Body)、ShouldBindBodyWith 之外的读取逻辑;
  • 读完以后有没有把 r.Body 重新赋值;
  • 有没有多个中间件重复读取 body,前一个还原了,后一个又没还原;
  • 日志里有没有敏感字段,比如 password、token、authorization。

这类问题不大,但很容易藏在"统一日志""统一签名校验""统一审计"这些公共逻辑里。业务 handler 看起来没错,真正动过 body 的地方在更前面。

最后给一个比较保守的结论:请求日志可以记录 body,但不要默认完整记录。开发环境随便一点问题不大,线上最好加白名单、长度限制和脱敏规则。能用测试把中间件行为锁住,也别只靠联调时看一眼日志。

相关推荐
大然Ryan11 分钟前
我用 Go 和 AI 做了一个 macOS 磁盘清理工具:Vole(开源)
go
我不会起名字3223 小时前
seq 空洞排查:Mongo 的 51 只是预留上界,Redis CURR 才是已消耗边界
redis·后端·mongodb·go·即时通讯·im·故障排查
FungLeo4 小时前
成为全栈·Vue 管理后台篇·用 fetch 建一层可控的请求内核
前端·javascript·vue.js·http·fetch·formdata·接口封装
wuyk55511 小时前
Spider-第03讲-HTTP速通与读懂响应
网络·爬虫·网络协议·http
喵个咪1 天前
GoWind 拆解:全链 ID 类型体系——int64 定版与前端字符串化契约
前端·后端·go
喵个咪1 天前
GoWind CMS 拆解:发布审批——多级推进、逐级角色与全程留痕
前端·后端·go
喵个咪1 天前
GoWind Flow 拆解:一个 Go 工作流引擎与它的双模式设计器
前端·后端·go
喵个咪1 天前
GoWind CMS 拆解:搜索三板斧——高亮、联想、同义词
后端·搜索引擎·go
喵个咪1 天前
GoWind 拆解:AI Agent 编排引擎——波次并行、子图递归与人工审批
人工智能·后端·go