gin正确多次读取http request body内容实现详解
自在的LEE 人气:0事件背景
到了年末,没有太多事情,总算有时间深度优化自己的 golang http webservice 框架,基于 gin 的。公司目前不少项目都是基于这个 webservice 框架开发的,所以我有责任保持这个框架的性能和稳定性。
在自己仔细读处理 middleware 中一个通用函数 GenerateRequestBody 时发现,之间写的代码太过粗暴,虽然一直能能稳定运行,但是总感觉哪里不对,同时也没有利用 sync.pool,明显这里可以优化,对在高并发的时候有很大帮助。
越看以前自己实现的 GenerateRequestBody 内容,越觉得太过简单,几乎没有什么思考,尤其在 gin middleware 中,这个函数在每一个 http 会话都会命中,同时设置这个函数作为 webservice 框架公开函数,也会被其他小伙伴调用,所以真的需要认真考虑。
前置知识
GenerateRequestBody 函数分析
废话不多说,先上代码,我们一起看看代码的问题。
func GenerateRequestBody(c *gin.Context) string { body, err := c.GetRawData() // 读取 request body 的内容 if err != nil { body = utils.StringToBytes("failed to get request body") } c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(body)) // 创建 io.ReadCloser 对象传给 request body return utils.BytesToString(body) // 返回 request body 的值 }
咋一看好像没有什么,我们不妨更深入代码一探究竟。
github.com/gin-gonic/gin@v1.8.1/context.go
// GetRawData returns stream data. func (c *Context) GetRawData() ([]byte, error) { return ioutil.ReadAll(c.Request.Body) }
ReadAll 会把 request body 中的所有的字节全部读出,然后返回一个 []byte 数组。
src/io/ioutil/ioutil.go
// NopCloser returns a ReadCloser with a no-op Close method wrapping // the provided Reader r. // // As of Go 1.16, this function simply calls io.NopCloser. func NopCloser(r io.Reader) io.ReadCloser { return io.NopCloser(r) }
src/io/io.go
// NopCloser returns a ReadCloser with a no-op Close method wrapping // the provided Reader r. func NopCloser(r Reader) ReadCloser { return nopCloser{r} } type nopCloser struct { Reader } func (nopCloser) Close() error { return nil }
ioutil.NopCloser 实际就是一个包装接口,把 Reader 接口封装成一个带有 Close 方法的对象,而且 Close 方法是一个直接返回的空函数。所以这里就有一个问题,如果你想调用 Close 关闭这个 io.ReadCloser 对象。我只能在边上,呵呵呵,你懂我的意思。
回归正题,这些代码大家应该看起来很眼熟才对。没错,这是网络上 gin 框架多次读取 http request body 中内容的解决方案。 能想像很多小伙伴就是 copy + paste 了事,流量小或者没有什么大规模应用场景下没有什么问题。如果流量大了?应用规模很多?那怎办?
gin 如何正确多次读取 http request body 的内容呢? 正确的姿势是什么呢?
追本溯源
gin 只不过是一个 router 框架,真正的 http 请求处理是 golang 中的 net/http 包来负责的。要找到 gin 如何正确多次读取 http request body 内容的方法,就一定要往下追。
写过 golang http client 的小伙伴都知道,需要手动执行 resp.Body.Close() 这样的方法释放连接。要不然会因为底层 tcp 端口耗尽,导致无法创建连接。我们通过一个简单例子看下:
package main import ( "fmt" "io/ioutil" "log" "net/http" ) func main() { resp, _ := doGet("http://www.baidu.com") defer resp.Body.Close() //go的特殊语法,main函数执行结束前会执行 resp.Body.Close() fmt.Println(resp.StatusCode) //有http的响应码输出 if resp.StatusCode == http.StatusOK { //如果响应码为200 body, err := ioutil.ReadAll(resp.Body) //把响应的body读出 if err != nil { //如果有异常 fmt.Println(err) //把异常打印 log.Fatal(err) //日志 } fmt.Println(string(body)) //把响应的文本输出到console } } /** 以GET的方式请求 **/ func doGet(url string) (r *http.Response, e error) { resp, err := http.Get(url) if err != nil { fmt.Println(resp.StatusCode) fmt.Println(err) log.Fatal(err) } return resp, err }
通过上面的代码,我们能看到 defer resp.Body.Close() 的代码,它就是要主动关闭连接。那么也有一个类似的问题,golang 中 net/http 包的 server 代码是不是也要主动管理连接呢?
类似如下:
bodyBytes, _ := ioutil.ReadAll(req.Body) req.Body.Close() // 这里调用Close req.Body = ioutil.NopCloser(bytes.NewBuffer(bodyBytes))
但是官方的代码注释里却写不需要在处理函数里调用 Close:Request.Body:"The Server will close the request body. The ServeHTTP Handler does not need to."
感觉好奇怪,golang 中 net/http 包的 server 自己能关闭 request,那跟上面类似执行 req.Body = ioutil.NopCloser(bytes.NewBuffer(bodyBytes)) 替换了 req.Body 原有内容,那么 golang 中 net/http 包的 server 还能正确关闭以前的 req.Body 嘛?如果不能关闭,那么类似 GenerateRequestBody 函数这样的执行过程,必然在大并发下,必然导致内存泄露和大量 GC 回收,影响服务响应。
值得深入
带着上面的问题,在网上寻找了很久,没有能找到解决问题的方法,也没有人把为什么说清楚。没有思路,在各种不确定的假设上,提供一个公司级的底层 webservice 框架,必然被公司技术委员会的主席们挑战。
说到这里,一不做二不休,直接干就是,往下肝。 顺着服务的启动流程找到了 golang 中 net/http 包的 server.go 文件,然后一个一个方法慢慢趴,直到找到了 func (c *conn) serve(ctx context.Context) {} 这个函数,总算看到了具体内容。
src/net/http/server.go
// Serve a new connection. func (c *conn) serve(ctx context.Context) { ... for { w, err := c.readRequest(ctx) // 读取 request 内容 ... } ... // HTTP cannot have multiple simultaneous active requests.[*] // Until the server replies to this request, it can't read another, // so we might as well run the handler in this goroutine. // [*] Not strictly true: HTTP pipelining. We could let them all process // in parallel even if their responses need to be serialized. // But we're not going to implement HTTP pipelining because it // was never deployed in the wild and the answer is HTTP/2. inFlightResponse = w serverHandler{c.server}.ServeHTTP(w, w.req) // 处理请求 inFlightResponse = nil w.cancelCtx() if c.hijacked() { return } w.finishRequest() // 关闭请求 ... }
看到这里,想要解决问题只要看两个函数 finishRequest 和 readRequest 就可以了。
finishRequest 函数分析
func (w *response) finishRequest() { ... // Close the body (regardless of w.closeAfterReply) so we can // re-use its bufio.Reader later safely. w.reqBody.Close() // 关闭 request body ???,在这里? ... }
是这里? 就在这里关闭了? 但是这里是 response 啊,不是 request。 继续点开看看 response 结构体是什么?
// A response represents the server side of an HTTP response. type response struct { ... req *Request // request for this response reqBody io.ReadCloser ... }
这里有一个 req 是 Request 的指针,那么还有一个 reqBody 作为 io.ReadCloser 是为了干嘛? 不解!不解!不解!
readRequest 函数分析
// Read next request from connection. func (c *conn) readRequest(ctx context.Context) (w *response, err error) { ... req, err := readRequest(c.bufr) if err != nil { if c.r.hitReadLimit() { return nil, errTooLarge } return nil, err } ... w = &response{ ... req: req, reqBody: req.Body, ... } ... }
看到这里,突然这个世界晴朗了,所有的事情好像都明白了。心细的小伙伴一定看出来眉目了,很有可能真是:一拍大腿的提高。
readRequest 读取到 req 信息后,在创建 response 的对象时,同时将 req 赋值给了 response 中的 req 和 reqBody。 也就是说 req.Body 和 reqBody 指向了同一个对象。 换句话说,我改变了 req.Body 的指向,reqBody 还保留着最初的 io.ReadCloser 对象的引用。 不管我怎么改变 req.Body 的值,哪怕是指向了 nil,也不会影响 server 调用 finishRequest() 函数来关闭 io.ReadCloser 对象,因为 finishRequest 内部调用的是 reqBody。
得出结论
middleware 中的 req.Body 和 response 中的 reqBody 是两个变量。初期,req.Body 和 reqBody 中存放了同一个地址。但是,当 req.body = io.NoCloser 时,只是改变了 req.Body 中的指针,而 reqBody 仍旧指向原始请求的 body,故不需要在 middleware 中执行关闭。
在 golang 开发提交记录中也找到了类似的说明,并解决了这个问题。所以说在 Go 1.6 之后已经不用担心这个问题了。
提交信息:
net/http: don't panic after request if Handler sets Request.Body to nil。
大致的意思是,不用再担心把 req.Body 设置 nil,其实也就是不用再担心重置 req.Body 了,更加不用手动关闭 req.Body。
上手开发
搞清楚了 golang 中 net/http 包的 server 中对请求的 request body 处理流程,那么 gin 这边也好开发了。 首先我们回到之前的 GenerateRequestBody 函数。
func GenerateRequestBody(c *gin.Context) string { body, err := c.GetRawData() // 读取 request body 的内容 if err != nil { body = utils.StringToBytes("failed to get request body") } c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(body)) // 创建 io.ReadCloser 对象传给 request body return utils.BytesToString(body) // 返回 request body 的值 }
虽然不需要每次关闭 c.Request.Body 了,但是我们要注意,没调用一次都会调用 bytes.NewBuffer 和 ioutil.NopCloser 一次。ioutil.NopCloser 这个还好是一个包装,之前我们看到了相关的代码。但是 bytes.NewBuffer 是一个重量级的家伙,我第一反应是不是可以用 sync.pool 来缓存这个这部分的代码?
实际当然是可以的,但是 GenerateRequestBody 是一个函数,c.Request.Body 新的指向在随后的 gin handler 中也要用,明显在 GenerateRequestBody 内部对 sync.pool 执行 Get 和 Put 明显不合适。
怎么解决呢?也很简单,在 gin 的框架 http request 会话是跟 Context 对象绑定的,所以直接在 Context 操作,并将 sync.pool Get 对象放入 Context,然后在 Context 销毁之前对 sync.pool 执行 Put 归还。
流程图如下:
gin Middleware 代码
func ginRequestBodyBuffer() gin.HandlerFunc { return func(c *gin.Context) { var b *RequestBodyBuff // 创建缓存对象 b = bodyBufferPool.Get().(*RequestBodyBuff) b.bodyBuf.Reset() c.Set(ConstRequestBodyBufferKey, b) // 下一个请求 c.Next() // 归还缓存对象 if o, ok := c.Get(ConstRequestBodyBufferKey); ok { b = o.(*RequestBodyBuff) b.bodyBuf.Reset() // bytes.Buffer 要 reset,但是 slice 就不能,这个做 io.CopyBuffer 用的 c.Set(ConstRequestBodyBufferKey, nil) // 释放指向 RequestBodyBuff 对象 bodyBufferPool.Put(o) // 归还对象 c.Request.Body = nil // 释放指向创建的 io.NopCloser 对象 } } }
新 GenerateRequestBody 代码
func GenerateRequestBody(c *gin.Context) string { var b *RequestBodyBuff if o, ok := c.Get(ConstRequestBodyBufferKey); ok { b = o.(*RequestBodyBuff) } else { b = newRequestBodyBuff() } body, err := boostio.ReadAllWithBuffer(c.Request.Body, b.swapBuf) // 读取 request body 的内容,此时 body 的 []byte 是全新的一个数据 copy if err != nil { body = utils.StringToBytes("failed to get request body") boost.Logger.Errorw(utils.BytesToString(body), "error", err) } _, err = b.bodyBuf.Write(body) // 把内容重新写入 bytes.Buffer if err != nil { c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(body)) boost.Logger.Errorw(utils.BytesToString(body), "error", err) } else { c.Request.Body = ioutil.NopCloser(b.bodyBuf) } return utils.BytesToString(body) }
测试代码
对开发好的代码执行循环测试,用短链接测试。
while true;do curl -i http://127.0.0.1:8080/yy/; done
总结
我们通过上面的操作和使用,基本确认了 golang 中 net/http 包中对 request body 的处理流程。 通过简单的开发,我们实现了 gin 正确多次读取 http request body 内容的方法,同时加入了 sync.pool 支持。减少了频繁 bytes.NewBuffer 创建对资源的消耗,以及提高了资源的利用效率。
加载全部内容