我是 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。那就好办了,就不怕你是一个接口,我只要对应的完成体给你不就能处理问题了吗?好多人都是这么想的。
说得轻盈,这儿有好几个问题在面前:
- 什么样的 ResponseWriter 完成才能处理问题?
- 什么时分传入新的 ResponseWriter 掩盖原有的 ResponseWriter 目标?
- 怎样做代价最小,可以削减对原有逻辑的侵略。能不能做到 100% 兼容原有逻辑?
- 怎么做才是最高效的做法,虽然是 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 个核心问题,我想到这儿应该有答案了:
- 构建一个自定义的 ResponseWriter 完成,掩盖原有的 net/http 结构中 ResponseWriter,并完成对数据存储。 — 答复问题 1
- 阻拦 c.JSON 底层 WriteJSON 函数中的 w.Write 办法,就可以对结构无损。 — 答复问题 2,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())
}
...
}
看到这儿,有的小伙伴就会问了, 你仍是没有说怎么输出啊,我抄不到作业呢。也是哦,都说到这儿了,感觉现在不给作业抄,怕是有小伙伴要掀桌子。
这次“作业”的全体思路是:ginResponseBodyBuffer 在 Context 中 创立 bodyBuf,然后由其他的 middleware 函数处理,终究在处理函数中生成 http response,经过阻拦 c.JSON 底层 WriteJSON 函数中的 w.Write 办法,记载http response body 到之前 ginResponseBodyBuffer 生成的 bodyBuf 中。最后数据到 ginLogger 中输出生成日志,将 http response body 输出保存相,之后由 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.73s","userAgent":"curl/7.54.0","requestQuery":"","requestBody":"","responseBody":"[\"lee\",\"demo\"]"}
总结
咱们经过上面代码的解说和编写,根本了解了 gin 的 webservice 结构中 response body 读取的正确办法,以及如安在现有工程中集成现有的功用。 当然上面一切的内容,仅仅提供了一种解题的可能性,小伙伴应该理解思路,结合自己的使用场景,完善和改进代码。