到了年末,没有太多事情,总算有时间深度优化自己的 golang http webservice 框架,基于 gin 的。公司目前不少项目都是基于这个 webservice 框架开发的,所以我有责任保持这个框架的性能和稳定性。
在自己仔细读处理 middleware 中一个通用函数 GenerateRequestBody 时发现,之间写的代码太过粗暴,虽然一直能能稳定运行,但是总感觉哪里不对,同时也没有利用 sync.pool,明显这里可以优化,对在高并发的时候有很大帮助。
越看以前自己实现的 GenerateRequestBody 内容,越觉得太过简单,几乎没有什么思考,尤其在 gin middleware 中,这个函数在每一个 http 会话都会命中,同时设置这个函数作为 webservice 框架公开函数,也会被其他小伙伴调用,所以真的需要认真考虑。
废话不多说,先上代码,我们一起看看代码的问题。
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 就可以了。
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 是为了干嘛? 不解!不解!不解!
// 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 归还。
流程图如下:
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 对象 } } }
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 创建对资源的消耗,以及提高了资源的利用效率。