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,但不要默认完整记录。开发环境随便一点问题不大,线上最好加白名单、长度限制和脱敏规则。能用测试把中间件行为锁住,也别只靠联调时看一眼日志。

相关推荐
用户3301448676310 小时前
03. Size Class 分级机制
go
用户3301448676310 小时前
04. mallocgc:分配总入口
go
程序员爱钓鱼14 小时前
Go 编程实战:函数 Function——参数、返回值与代码复用
后端·面试·go
XUEYUAN52121 天前
Cloudflare 防护机制深度剖析与跨境数据采集工程化实践
服务器·网络·网络协议·tcp/ip·http
TlSfoward1 天前
TLS指纹库的数据质量与误判治理 TLSFOWARD TLS指纹库
爬虫·网络协议·http
名字还没想好☜1 天前
Go 1.23 range-over-func 迭代器实战:自定义可迭代类型、提前退出与惰性求值
开发语言·后端·golang·go·迭代器
赫媒派1 天前
Go 1.27 来了:泛型方法补齐,JSON 提速不踩坑
后端·go·敏捷开发
meilindehuzi_a1 天前
React JWT 登录鉴权实战:从无状态 HTTP 到路由守卫、Zustand 与 Axios 拦截器
前端·react.js·http
小满zs2 天前
Go语言第九章(错误处理)
后端·go
程序员爱钓鱼2 天前
Go 编程实战:指针 Pointer——理解地址、取址与解引用
后端·面试·go