如何让 gin 正确读取 http response body 内容,并多次使用

我是 LEE,老李,一个在 IT 行业摸爬滚打 17 年的技术老兵。

事件背景

最近业务研发反应了一个需求:能不能让现有基于 gin 的 webservice 框架能够自己输出 response 的信息,尤其是 response body 内容。因为研发在 QA 环境开发调试的时候,部署应用大多数都是 debug 模式,不想在每一个 http handler 函数中总是手写一个日志去记录 response body 内容,这样做不但发布正式版本的时候要做清理,同时日常代码维护也非常麻烦。如果 gin 的 webservice 框架能够自己输出 response 的信息到日志并记录下来,这样查看历史应用运行状态、相关请求信息和定位请求异常时也比较方便。

针对这样的需求,思考了下确实也是如此。平常自己写服务的时候,本地调试用 Mock 数据各种没有问题,但是一但进入到环境联合调试的时候就各种问题,检查服务接口在特定时间内出入参数也非常不方便。如果 webservice 框架能够把 request 和 response 相关信息全量作为日志存在 Elasticsearch 中,也方便回溯和排查。

要实现这个需求,用一个通用的 gin middleware 来做这个事情太合适了。并制作一个开关,匹配 GIN_MODE 这个环境变量,能够在部署时候自动开关这个功能,可以极大减少研发的心智负担。

既然有这么多好处,说干就干。

心智负担

通过对 gin 的代码阅读,发现原生 gin 框架没有提供类似的功能,也说就要自己手写一个。翻越了网上的解决方案,感觉都是浅浅说到了这个事情,但是没有比较好的,且能够应用工程中的。所以一不做二不休,自己整理一篇文章来详细说明这个问题。我相信用 gin 作为 webservice 框架的小伙伴应该不少。

说到这里,又要从原代码看起来,那么产生 response 的地方在哪里? 当然是 http handler 函数。

这里先举个例子:

func Demo(c *gin.Context) {
    var r = []string{"lee", "demo"}
    c.JSON(http.StatusOK, r)
}

这个函数返回内容为:["lee","demo"] 。但是为了要将这个请求的 request 和 response 内容记录到日志中,就需要编写类似如下的代码。

func Demo(c *gin.Context) {
    var r = []string{"lee", "demo"}
    c.JSON(http.StatusOK, r)

    // 记录相关的内容
    b, _ := json.Marshal(r)
    log.Println("request: ", c.Request)
    log.Println("resposeBody: ", b)
}

各位小伙伴,尝试想想每一个 http handler 函数都要你写一遍,然后要针对运行环境是 QA 还是 Online 做判断,或者在发布 Online 时候做代码清理。我想研发小伙伴都会说:NO!! NO!! NO!!

前置知识

最好的办法是将这个负担交给 gin 的 webservice 框架来处理,研发不需要做相关的逻辑。居然要这么做,那么就要看看 gin 的 response 是怎么产生的。

用上面提到的 c.JSON 方法来举例。

github.com/gin-gonic/gin@v1.8.1/context.go

// JSON serializes the given struct as JSON into the response body.
// It also sets the Content-Type as "application/json".
func (c *Context) JSON(code int, obj any) {
    c.Render(code, render.JSON{Data: obj})
}

这个 c.JSON 实际是 c.Render 的一个包装函数,继续往下追。

github.com/gin-gonic/gin@v1.8.1/context.go

// Render writes the response headers and calls render.Render to render data.
func (c *Context) Render(code int, r render.Render) {
    c.Status(code)

    if !bodyAllowedForStatus(code) {
        r.WriteContentType(c.Writer)
        c.Writer.WriteHeaderNow()
        return
    }

    if err := r.Render(c.Writer); err != nil {
        panic(err)
    }
}

c.Render 还是一个包装函数,最终是用 r.Render 向 c.Writer 输出数据。

github.com/gin-gonic/gin@v1.8.1/render/render.go

// Render interface is to be implemented by JSON, XML, HTML, YAML and so on.
type Render interface {
    // Render writes data with custom ContentType.
    Render(http.ResponseWriter) error
    // WriteContentType writes custom ContentType.
    WriteContentType(w http.ResponseWriter)
}

r.Render 是一个渲染接口,也就是 gin 可以输出 JSON,XML,String 等等统一接口。 此时我们需要找 JSON 实现体的相关信息。

github.com/gin-gonic/gin@v1.8.1/render/json.go

// Render (JSON) writes data with custom ContentType.
func (r JSON) Render(w http.ResponseWriter) (err error) {
    if err = WriteJSON(w, r.Data); err != nil {
        panic(err)
    }
    return
}

// WriteJSON marshals the given interface object and writes it with custom ContentType.
func WriteJSON(w http.ResponseWriter, obj any) error {
    writeContentType(w, jsonContentType)
    jsonBytes, err := json.Marshal(obj)
    if err != nil {
        return err
    }
    _, err = w.Write(jsonBytes) // 写入 response 内容,内容已经被 json 序列化
    return err
}

追到这里,真正输出内容的函数是 WriteJSON,此时调用 w.Write(jsonBytes) 写入被 json 模块序列化完毕的对象。而这个 w.Write 是 http.ResponseWriter 的方法。那我们就看看 http.ResponseWriter 到底是一个什么样子的?

net/http/server.go

// A ResponseWriter may not be used after the Handler.ServeHTTP method
// has returned.
type ResponseWriter interface {
    ...

    // Write writes the data to the connection as part of an HTTP reply.
    //
    // If WriteHeader has not yet been called, Write calls
    // WriteHeader(http.StatusOK) before writing the data. If the Header
    // does not contain a Content-Type line, Write adds a Content-Type set
    // to the result of passing the initial 512 bytes of written data to
    // DetectContentType. Additionally, if the total size of all written
    // data is under a few KB and there are no Flush calls, the
    // Content-Length header is added automatically.
    //
    // Depending on the HTTP protocol version and the client, calling
    // Write or WriteHeader may prevent future reads on the
    // Request.Body. For HTTP/1.x requests, handlers should read any
    // needed request body data before writing the response. Once the
    // headers have been flushed (due to either an explicit Flusher.Flush
    // call or writing enough data to trigger a flush), the request body
    // may be unavailable. For HTTP/2 requests, the Go HTTP server permits
    // handlers to continue to read the request body while concurrently
    // writing the response. However, such behavior may not be supported
    // by all HTTP/2 clients. Handlers should read before writing if
    // possible to maximize compatibility.
    Write([]byte) (int, error)

    ...
}

哦哟,最后还是回到了 golang 自己的 net/http 包了,看到 ResponseWriter 是一个 interface。那就好办了,就不怕你是一个接口,我只要对应的实现体给你不就能解决问题了吗?好多人都是这么想的。

说得轻巧,这里有好几个问题在面前:

  1. 什么样的 ResponseWriter 实现才能解决问题?
  2. 什么时候传入新的 ResponseWriter 覆盖原有的 ResponseWriter 对象?
  3. 怎样做代价最小,能够减少对原有逻辑的入侵。能不能做到 100% 兼容原有逻辑?
  4. 怎么做才是最高效的做法,虽然是 debug 环境,但是 QA 环境不代表没有流量压力

解决思路

带着上章中的问题,要真正的解决问题,就需要回到 gin 的框架结构中去寻找答案。

追本溯源

gin 框架中的 middleware 实际是一个链条,并按照 Next() 的调用顺序逐一往下执行。

Next() 与执行顺序

middleware 执行的顺序会从最前面的 middleware 开始执行,在 middleware function 中,一旦执行 Next() 方法后,就会往下一个 middleware 的 function 走,但这并不表示 Next() 后的内容不会被执行到,相反的,Next()后面的内容会等到所有 middleware function 中 Next() 以前的程式码都执行结束后,才开始执行,并且由后往前逐一完成。

举个例子,方便小伙伴理解:

func main() {
    router := gin.Default()
    router.GET("/api", func(c *gin.Context) {
        fmt.Println("First Middle Before Next")
        c.Next()
        fmt.Println("First Middle After Next")
    }, func(c *gin.Context) {
        fmt.Println("Second Middle Before Next")
        c.Next()
        fmt.Println("Second Middle After Next")
    }, func(c *gin.Context) {
        fmt.Println("Third Middle Before Next")
        c.Next()
        fmt.Println("Third Middle After Next")
        c.JSON(http.StatusOK, gin.H{
            "message": "pong",
        })
    })
}

Console 执行结果如下:

// Next 之前的内容会「由前往后」並且「依序」完成
First Middle Before Next
Second Middle Before Next
Third Middle Before Next

// Next 之后的內容会「由后往前」並且「依序」完成
Third Middle After Next
Second Middle After Next
First Middle After Next

通过上面的例子,我们看到了 gin 框架中的 middleware 中处理流程。为了让 gin 的 webservice 框架在后续的 middleware 中都能轻松获得 func(c *gin.Context) 产生的 { "message": "pong" }, 就要结合上一章找到的 WriteJSON 函数,让其输出到 ResponseWriter 的内容保存到 gin 的 Context 中 (gin 框架中,每一个 http 回话都与一个 Context 对象绑定),这样就可以在随后的 middleware 能够轻松访问到 response body 中的内容。

上手开发

还是回到上一章中的 4 个核心问题,我想到这里应该有答案了:

  1. 构建一个自定义的 ResponseWriter 实现,覆盖原有的 net/http 框架中 ResponseWriter,并实现对数据存储。 -- 回答问题 1
  2. 拦截 c.JSON 底层 WriteJSON 函数中的 w.Write 方法,就可以对框架无损。 -- 回答问题 2,3
  3. 在 gin.Use() 函数做一个开关,当 GIN_MODE 是 release 模式,就不注入这个 middleware,这样第 1,2 就不会存在,而是原有的 net/http 框架中 ResponseWriter -- 回答问题 3,4

说到了这么多内容,我们来点实际的。

第 1 点代码怎么写

type responseBodyWriter struct {
    gin.ResponseWriter  // 继承原有 gin.ResponseWriter
    bodyBuf *bytes.Buffer  // Body 内容临时存储位置,这里指针,原因这个存储对象要复用
}

// 覆盖原有 gin.ResponseWriter 中的 Write 方法
func (w *responseBodyWriter) Write(b []byte) (int, error) {
    if count, err := w.bodyBuf.Write(b); err != nil {  // 写入数据时,也写入一份数据到缓存中
        return count, err
    }
    return w.ResponseWriter.Write(b) // 原始框架数据写入
}

第 2 点代码怎么写

创建一个 bytes.Buffer 指针 pool

type bodyBuff struct {
    bodyBuf *bytes.Buffer
}

func newBodyBuff() *bodyBuff {
    return &bodyBuff{
        bodyBuf: bytes.NewBuffer(make([]byte, 0, bytesBuff.ConstDefaultBufferSize)),
    }
}

var responseBodyBufferPool = sync.Pool{New: func() interface{} {
    return newBodyBuff()
}}

创建一个 gin middleware,用于从 pool 获得 bytes.Buffer 指针,并创建 responseBodyWriter 对象覆盖原有 gin 框架中 Context 中的 ResponseWriter,随后清理对象回收 bytes.Buffer 指针到 pool 中。

func ginResponseBodyBuffer() gin.HandlerFunc {
    return func(c *gin.Context) {
        var b *bodyBuff
        // 创建缓存对象
        b = responseBodyBufferPool.Get().(*bodyBuff)
        b.bodyBuf.Reset()
        c.Set(responseBodyBufferKey, b)

        // 覆盖原有 writer
        wr := responseBodyWriter{
            ResponseWriter: c.Writer,
            bodyBuf:        b.bodyBuf,
        }
        c.Writer = &wr

        // 下一个
        c.Next()

        // 归还缓存对象
        wr.bodyBuf = nil
        if o, ok := c.Get(responseBodyBufferKey); ok {
            b = o.(*bodyBuff)
            b.bodyBuf.Reset()
            responseBodyBufferPool.Put(o)     // 归还对象
            c.Set(responseBodyBufferKey, nil) // 释放指向 bodyBuff 对象
        }
    }
}

第 3 点代码怎么写

这里最简单了,写一个 if 判断就行了。

func NewEngine(...) *Engine {
    ...

    engine := new(Engine)

    ...

    if gin.IsDebugging() {
        engine.ginSvr.Use(ginResponseBodyBuffer())
    }

    ...
}

看到这里,有的小伙伴就会问了, 你还是没有说怎么输出啊,我抄不到作业呢。也是哦,都说到这里了,感觉现在不给作业抄,怕是有小伙伴要掀桌子。

作业 1:日志输出 middleware 代码编写

func GenerateResponseBody(c *gin.Context) string {
    if o, ok := c.Get(responseBodyBufferKey); ok {
        return utils.BytesToString(o.(*bodyBuff).bodyBuf.Bytes())
    } else {
        return "failed to get response body"
    }
}

func ginLogger() gin.HandlerFunc {
    return func(c *gin.Context) {
        // 正常处理系统日志
        path := GenerateRequestPath(c)
        requestBody := GenerateRequestBody(c)

        // 下一个
        c.Next()

        // response 返回
        responseBody := GenerateResponseBody(c)

        // 日志输出
        log.Println("path: ", path, "requestBody: ", requestBody, "responseBody", responseBody)
    }
}

作业 2:日志输出 middleware 安装

func NewEngine(...) *Engine {
    ...

    engine := new(Engine)

    ...

    if gin.IsDebugging() {
        engine.ginSvr.Use(ginResponseBodyBuffer(), ginLogger())
    }

    ...
}

这里只要把 ginLogger 放在 ginResponseBodyBuffer 这个 middleware 后面就可以了。

测试代码

Console 内容输出

$ curl -i http://127.0.0.1:8080/xx/
HTTP/1.1 200 OK
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: Content-Type, AccessToken, X-CSRF-Token, Authorization, Token
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Origin: *
Access-Control-Expose-Headers: Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers, Content-Type
Content-Type: application/json; charset=utf-8
X-Request-Id: 1611289702609555456
Date: Fri, 06 Jan 2023 09:12:56 GMT
Content-Length: 14

["lee","demo"]

服务日志输出

{"level":"INFO","time":"2023-01-06T17:12:56.074+0800","caller":"server/middleware.go:78","message":"http access log","requestID":"1611289702609555456","status":200,"method":"GET","contentType":"","clientIP":"127.0.0.1","clientEndpoint":"127.0.0.1:62865","path":"/xx/","latency":"280.73µs","userAgent":"curl/7.54.0","requestQuery":"","requestBody":"","responseBody":"[\"lee\",\"demo\"]"}

总结

我们通过上面代码的讲解和编写,基本了解了 gin 的 webservice 框架中 response body 读取的正确方法,以及如何在现有工程中集成现有的功能。 当然上面所有的内容,仅仅提供了一种解题的可能性,小伙伴应该理解思路,结合自己的应用场景,完善和改进代码。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,607评论 6 507
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,239评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,960评论 0 355
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,750评论 1 294
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,764评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,604评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,347评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,253评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,702评论 1 315
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,893评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,015评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,734评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,352评论 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,934评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,052评论 1 270
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,216评论 3 371
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,969评论 2 355