我是 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() 的调用次序逐个往下履行。

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

Next() 与履行次序

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

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())
	}
	...
}

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

这次“作业”的全体思路是:ginResponseBodyBuffer 在 Context 中 创立 bodyBuf,然后由其他的 middleware 函数处理,终究在处理函数中生成 http response,经过阻拦 c.JSON 底层 WriteJSON 函数中的 w.Write 办法,记载http response body 到之前 ginResponseBodyBuffer 生成的 bodyBuf 中。最后数据到 ginLogger 中输出生成日志,将 http response body 输出保存相,之后由 ginResponseBodyBuffer 收回资源。

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

作业 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 后面就可以了。

测验代码

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

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 读取的正确办法,以及如安在现有工程中集成现有的功用。 当然上面一切的内容,仅仅提供了一种解题的可能性,小伙伴应该理解思路,结合自己的使用场景,完善和改进代码。