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