go gin正确读取http response body内容并多次使用详解

发布时间:

最近业务研发反映了一个需求:能不能让现有基于 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() 的调用顺序逐一往下执行。

go gin正确读取http response body内容并多次使用详解

Next() 与执行顺序

go 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 NextSecond Middle Before NextThird Middle Before Next

// Next 之后的內容会「由后往前」並且「依序」完成Third Middle After NextSecond Middle After NextFirst 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 回收资源。

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

测试代码

go 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.73µs","userAgent":"curl/7.54.0","requestQuery":"","requestBody":"","responseBody":"[\"lee\",\"demo\"]"}

总结

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