如何讓 gin 正確讀取 http response body 內(nèi)容寥枝,并多次使用

我是 LEE盖奈,老李混坞,一個(gè)在 IT 行業(yè)摸爬滾打 17 年的技術(shù)老兵。

事件背景

最近業(yè)務(wù)研發(fā)反應(yīng)了一個(gè)需求:能不能讓現(xiàn)有基于 gin 的 webservice 框架能夠自己輸出 response 的信息钢坦,尤其是 response body 內(nèi)容究孕。因?yàn)檠邪l(fā)在 QA 環(huán)境開發(fā)調(diào)試的時(shí)候,部署應(yīng)用大多數(shù)都是 debug 模式爹凹,不想在每一個(gè) http handler 函數(shù)中總是手寫一個(gè)日志去記錄 response body 內(nèi)容厨诸,這樣做不但發(fā)布正式版本的時(shí)候要做清理,同時(shí)日常代碼維護(hù)也非常麻煩逛万。如果 gin 的 webservice 框架能夠自己輸出 response 的信息到日志并記錄下來泳猬,這樣查看歷史應(yīng)用運(yùn)行狀態(tài)、相關(guān)請(qǐng)求信息和定位請(qǐng)求異常時(shí)也比較方便宇植。

針對(duì)這樣的需求得封,思考了下確實(shí)也是如此。平常自己寫服務(wù)的時(shí)候指郁,本地調(diào)試用 Mock 數(shù)據(jù)各種沒有問題忙上,但是一但進(jìn)入到環(huán)境聯(lián)合調(diào)試的時(shí)候就各種問題,檢查服務(wù)接口在特定時(shí)間內(nèi)出入?yún)?shù)也非常不方便闲坎。如果 webservice 框架能夠把 request 和 response 相關(guān)信息全量作為日志存在 Elasticsearch 中疫粥,也方便回溯和排查。

要實(shí)現(xiàn)這個(gè)需求腰懂,用一個(gè)通用的 gin middleware 來做這個(gè)事情太合適了梗逮。并制作一個(gè)開關(guān),匹配 GIN_MODE 這個(gè)環(huán)境變量绣溜,能夠在部署時(shí)候自動(dòng)開關(guān)這個(gè)功能慷彤,可以極大減少研發(fā)的心智負(fù)擔(dān)。

既然有這么多好處怖喻,說干就干底哗。

心智負(fù)擔(dān)

通過對(duì) gin 的代碼閱讀,發(fā)現(xiàn)原生 gin 框架沒有提供類似的功能锚沸,也說就要自己手寫一個(gè)跋选。翻越了網(wǎng)上的解決方案,感覺都是淺淺說到了這個(gè)事情哗蜈,但是沒有比較好的前标,且能夠應(yīng)用工程中的坠韩。所以一不做二不休,自己整理一篇文章來詳細(xì)說明這個(gè)問題候生。我相信用 gin 作為 webservice 框架的小伙伴應(yīng)該不少同眯。

說到這里,又要從原代碼看起來唯鸭,那么產(chǎn)生 response 的地方在哪里须蜗? 當(dāng)然是 http handler 函數(shù)。

這里先舉個(gè)例子:

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

這個(gè)函數(shù)返回內(nèi)容為:["lee","demo"] 目溉。但是為了要將這個(gè)請(qǐng)求的 request 和 response 內(nèi)容記錄到日志中明肮,就需要編寫類似如下的代碼。

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

    // 記錄相關(guān)的內(nèi)容
    b, _ := json.Marshal(r)
    log.Println("request: ", c.Request)
    log.Println("resposeBody: ", b)
}

各位小伙伴缭付,嘗試想想每一個(gè) http handler 函數(shù)都要你寫一遍柿估,然后要針對(duì)運(yùn)行環(huán)境是 QA 還是 Online 做判斷,或者在發(fā)布 Online 時(shí)候做代碼清理陷猫。我想研發(fā)小伙伴都會(huì)說:NO!! NO!! NO!!

前置知識(shí)

最好的辦法是將這個(gè)負(fù)擔(dān)交給 gin 的 webservice 框架來處理秫舌,研發(fā)不需要做相關(guān)的邏輯。居然要這么做绣檬,那么就要看看 gin 的 response 是怎么產(chǎn)生的足陨。

用上面提到的 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})
}

這個(gè) c.JSON 實(shí)際是 c.Render 的一個(gè)包裝函數(shù)娇未,繼續(xù)往下追墨缘。

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 還是一個(gè)包裝函數(shù),最終是用 r.Render 向 c.Writer 輸出數(shù)據(jù)零抬。

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 是一個(gè)渲染接口镊讼,也就是 gin 可以輸出 JSON,XML平夜,String 等等統(tǒng)一接口蝶棋。 此時(shí)我們需要找 JSON 實(shí)現(xiàn)體的相關(guān)信息。

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 內(nèi)容忽妒,內(nèi)容已經(jīng)被 json 序列化
    return err
}

追到這里玩裙,真正輸出內(nèi)容的函數(shù)是 WriteJSON,此時(shí)調(diào)用 w.Write(jsonBytes) 寫入被 json 模塊序列化完畢的對(duì)象锰扶。而這個(gè) w.Write 是 http.ResponseWriter 的方法。那我們就看看 http.ResponseWriter 到底是一個(gè)什么樣子的寝受?

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 是一個(gè) interface很澄。那就好辦了京闰,就不怕你是一個(gè)接口颜及,我只要對(duì)應(yīng)的實(shí)現(xiàn)體給你不就能解決問題了嗎?好多人都是這么想的蹂楣。

說得輕巧俏站,這里有好幾個(gè)問題在面前:

  1. 什么樣的 ResponseWriter 實(shí)現(xiàn)才能解決問題?
  2. 什么時(shí)候傳入新的 ResponseWriter 覆蓋原有的 ResponseWriter 對(duì)象痊土?
  3. 怎樣做代價(jià)最小肄扎,能夠減少對(duì)原有邏輯的入侵。能不能做到 100% 兼容原有邏輯赁酝?
  4. 怎么做才是最高效的做法犯祠,雖然是 debug 環(huán)境,但是 QA 環(huán)境不代表沒有流量壓力

解決思路

帶著上章中的問題酌呆,要真正的解決問題衡载,就需要回到 gin 的框架結(jié)構(gòu)中去尋找答案。

追本溯源

gin 框架中的 middleware 實(shí)際是一個(gè)鏈條隙袁,并按照 Next() 的調(diào)用順序逐一往下執(zhí)行痰娱。

Next() 與執(zhí)行順序

middleware 執(zhí)行的順序會(huì)從最前面的 middleware 開始執(zhí)行,在 middleware function 中菩收,一旦執(zhí)行 Next() 方法后梨睁,就會(huì)往下一個(gè) middleware 的 function 走,但這并不表示 Next() 后的內(nèi)容不會(huì)被執(zhí)行到坛梁,相反的而姐,Next()后面的內(nèi)容會(huì)等到所有 middleware function 中 Next() 以前的程式碼都執(zhí)行結(jié)束后,才開始執(zhí)行划咐,并且由后往前逐一完成拴念。

舉個(gè)例子,方便小伙伴理解:

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 執(zhí)行結(jié)果如下:

// Next 之前的內(nèi)容會(huì)「由前往后」並且「依序」完成
First Middle Before Next
Second Middle Before Next
Third Middle Before Next

// Next 之后的內(nèi)容會(huì)「由后往前」並且「依序」完成
Third Middle After Next
Second Middle After Next
First Middle After Next

通過上面的例子褐缠,我們看到了 gin 框架中的 middleware 中處理流程政鼠。為了讓 gin 的 webservice 框架在后續(xù)的 middleware 中都能輕松獲得 func(c *gin.Context) 產(chǎn)生的 { "message": "pong" }, 就要結(jié)合上一章找到的 WriteJSON 函數(shù),讓其輸出到 ResponseWriter 的內(nèi)容保存到 gin 的 Context 中 (gin 框架中队魏,每一個(gè) http 回話都與一個(gè) Context 對(duì)象綁定)公般,這樣就可以在隨后的 middleware 能夠輕松訪問到 response body 中的內(nèi)容。

上手開發(fā)

還是回到上一章中的 4 個(gè)核心問題胡桨,我想到這里應(yīng)該有答案了:

  1. 構(gòu)建一個(gè)自定義的 ResponseWriter 實(shí)現(xiàn)官帘,覆蓋原有的 net/http 框架中 ResponseWriter,并實(shí)現(xiàn)對(duì)數(shù)據(jù)存儲(chǔ)昧谊。 -- 回答問題 1
  2. 攔截 c.JSON 底層 WriteJSON 函數(shù)中的 w.Write 方法刽虹,就可以對(duì)框架無損。 -- 回答問題 2呢诬,3
  3. 在 gin.Use() 函數(shù)做一個(gè)開關(guān)涌哲,當(dāng) GIN_MODE 是 release 模式胖缤,就不注入這個(gè) middleware,這樣第 1阀圾,2 就不會(huì)存在哪廓,而是原有的 net/http 框架中 ResponseWriter -- 回答問題 3,4

說到了這么多內(nèi)容初烘,我們來點(diǎn)實(shí)際的涡真。

第 1 點(diǎn)代碼怎么寫

type responseBodyWriter struct {
    gin.ResponseWriter  // 繼承原有 gin.ResponseWriter
    bodyBuf *bytes.Buffer  // Body 內(nèi)容臨時(shí)存儲(chǔ)位置,這里指針账月,原因這個(gè)存儲(chǔ)對(duì)象要復(fù)用
}

// 覆蓋原有 gin.ResponseWriter 中的 Write 方法
func (w *responseBodyWriter) Write(b []byte) (int, error) {
    if count, err := w.bodyBuf.Write(b); err != nil {  // 寫入數(shù)據(jù)時(shí)综膀,也寫入一份數(shù)據(jù)到緩存中
        return count, err
    }
    return w.ResponseWriter.Write(b) // 原始框架數(shù)據(jù)寫入
}

第 2 點(diǎn)代碼怎么寫

創(chuàng)建一個(gè) 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()
}}

創(chuàng)建一個(gè) gin middleware,用于從 pool 獲得 bytes.Buffer 指針局齿,并創(chuàng)建 responseBodyWriter 對(duì)象覆蓋原有 gin 框架中 Context 中的 ResponseWriter剧劝,隨后清理對(duì)象回收 bytes.Buffer 指針到 pool 中。

func ginResponseBodyBuffer() gin.HandlerFunc {
    return func(c *gin.Context) {
        var b *bodyBuff
        // 創(chuàng)建緩存對(duì)象
        b = responseBodyBufferPool.Get().(*bodyBuff)
        b.bodyBuf.Reset()
        c.Set(responseBodyBufferKey, b)

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

        // 下一個(gè)
        c.Next()

        // 歸還緩存對(duì)象
        wr.bodyBuf = nil
        if o, ok := c.Get(responseBodyBufferKey); ok {
            b = o.(*bodyBuff)
            b.bodyBuf.Reset()
            responseBodyBufferPool.Put(o)     // 歸還對(duì)象
            c.Set(responseBodyBufferKey, nil) // 釋放指向 bodyBuff 對(duì)象
        }
    }
}

第 3 點(diǎn)代碼怎么寫

這里最簡單了抓歼,寫一個(gè) if 判斷就行了讥此。

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

    engine := new(Engine)

    ...

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

    ...
}

看到這里,有的小伙伴就會(huì)問了, 你還是沒有說怎么輸出啊谣妻,我抄不到作業(yè)呢萄喳。也是哦,都說到這里了蹋半,感覺現(xiàn)在不給作業(yè)抄他巨,怕是有小伙伴要掀桌子。

作業(yè) 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) {
        // 正常處理系統(tǒng)日志
        path := GenerateRequestPath(c)
        requestBody := GenerateRequestBody(c)

        // 下一個(gè)
        c.Next()

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

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

作業(yè) 2:日志輸出 middleware 安裝

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

    engine := new(Engine)

    ...

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

    ...
}

這里只要把 ginLogger 放在 ginResponseBodyBuffer 這個(gè) middleware 后面就可以了减江。

測(cè)試代碼

Console 內(nèi)容輸出

$ 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"]

服務(wù)日志輸出

{"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\"]"}

總結(jié)

我們通過上面代碼的講解和編寫染突,基本了解了 gin 的 webservice 框架中 response body 讀取的正確方法,以及如何在現(xiàn)有工程中集成現(xiàn)有的功能辈灼。 當(dāng)然上面所有的內(nèi)容份企,僅僅提供了一種解題的可能性,小伙伴應(yīng)該理解思路巡莹,結(jié)合自己的應(yīng)用場(chǎng)景司志,完善和改進(jìn)代碼。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末降宅,一起剝皮案震驚了整個(gè)濱河市骂远,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌腰根,老刑警劉巖激才,帶你破解...
    沈念sama閱讀 211,348評(píng)論 6 491
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡贸营,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,122評(píng)論 2 385
  • 文/潘曉璐 我一進(jìn)店門岩睁,熙熙樓的掌柜王于貴愁眉苦臉地迎上來钞脂,“玉大人,你說我怎么就攤上這事捕儒”校” “怎么了?”我有些...
    開封第一講書人閱讀 156,936評(píng)論 0 347
  • 文/不壞的土叔 我叫張陵刘莹,是天一觀的道長阎毅。 經(jīng)常有香客問我,道長点弯,這世上最難降的妖魔是什么扇调? 我笑而不...
    開封第一講書人閱讀 56,427評(píng)論 1 283
  • 正文 為了忘掉前任,我火速辦了婚禮抢肛,結(jié)果婚禮上狼钮,老公的妹妹穿的比我還像新娘。我一直安慰自己捡絮,他們只是感情好熬芜,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,467評(píng)論 6 385
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著福稳,像睡著了一般涎拉。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上的圆,一...
    開封第一講書人閱讀 49,785評(píng)論 1 290
  • 那天鼓拧,我揣著相機(jī)與錄音,去河邊找鬼略板。 笑死毁枯,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的叮称。 我是一名探鬼主播种玛,決...
    沈念sama閱讀 38,931評(píng)論 3 406
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼瓤檐!你這毒婦竟也來了赂韵?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,696評(píng)論 0 266
  • 序言:老撾萬榮一對(duì)情侶失蹤挠蛉,失蹤者是張志新(化名)和其女友劉穎祭示,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體谴古,經(jīng)...
    沈念sama閱讀 44,141評(píng)論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡质涛,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,483評(píng)論 2 327
  • 正文 我和宋清朗相戀三年稠歉,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片汇陆。...
    茶點(diǎn)故事閱讀 38,625評(píng)論 1 340
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡怒炸,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出毡代,到底是詐尸還是另有隱情阅羹,我是刑警寧澤,帶...
    沈念sama閱讀 34,291評(píng)論 4 329
  • 正文 年R本政府宣布教寂,位于F島的核電站捏鱼,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏酪耕。R本人自食惡果不足惜导梆,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,892評(píng)論 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望迂烁。 院中可真熱鬧问潭,春花似錦、人聲如沸婚被。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,741評(píng)論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽址芯。三九已至灾茁,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間谷炸,已是汗流浹背北专。 一陣腳步聲響...
    開封第一講書人閱讀 31,977評(píng)論 1 265
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留旬陡,地道東北人拓颓。 一個(gè)月前我還...
    沈念sama閱讀 46,324評(píng)論 2 360
  • 正文 我出身青樓,卻偏偏與公主長得像描孟,于是被迫代替她去往敵國和親驶睦。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,492評(píng)論 2 348