likes
comments
collection
share

[gin]:多次读取HTTP请求体中body数据(ShouldBindBodyWith)

作者站长头像
站长
· 阅读数 18

gin 中间件的使用场景非常广泛,gin 的中间件极大生提升了gin框架的灵活性和可扩展性。中间最常见的场景就是接口鉴权功能。通过中间件将各个接口通用的鉴权功能从业务逻辑中剥离出来,实现鉴权功能的复用,提升代码可维护性。通常情况下,鉴权功能需要结合请求结构体中的入参完成,如果 使用中间件,则需要对请求体context中body字节流进行多次读取。

gin对于请求体body字节流多次读取的实现也经历了几个阶段,通过阅读 gin context.go 的源码以及github中的问题及修复提交记录,我们可以看到gin开发者对于这个功能实现的整体脉络及对于gin 高性能web框架的考虑。

1、问题的发现

为了实现接口鉴权的功能,我们开发了一个简单的鉴权中间件,并提供了一个 json 编码格式的 echo 接口方法,同时启动gin服务。具体代码如下:

(1)、鉴权中间件

// 自定义鉴权中间件
func Authorization(auth string) gin.HandlerFunc{
	return func(c *gin.Context) {
		var p common.AuthParam
		var err error
		if err = c.ShouldBindJson(&p,binding.JSON);err != nil{
			errMsg := fmt.Sprintf("bind auth param error: %s", err.Error())
			c.JSON(http.StatusOK, common.Response{
				Code:    common.BadCode,
				Message: errMsg,
				Date:    nil,
			})
			return
		}

		fmt.Printf("the auth param = %+v\n",p)

		if p.Auth != auth{
			errMsg := fmt.Sprintf("auth [%s] error", p.Auth)
			c.JSON(http.StatusOK, common.Response{
				Code:    common.BadCode,
				Message: errMsg,
				Date:    nil,
			})
			return
		}
	}
}

(2)、启动gin

//启动项
func RunGin() {
	r := gin.Default()
	r.Use(Authorization("123456"))

	group := r.Group("/api/v1",)
	group.POST("/json/echo", handler.JsonEchoHandler)

	err := r.Run(configs.GetConfig().Gin.Listen)
	if err != nil {
		log.Fatalf("run gin server error: %s", err.Error())
	}

	log.Println("gin server success, http listening", addr)
}
// JsonEchoHandler的实现
func JsonEchoHandler(c *gin.Context) {
	var p common.EchoRequestParam
	var err error

	defer func() {
		if panicErr := recover(); panicErr != nil {
			errMsg := fmt.Sprintf("JsonEchoHandler panic: %+v", panicErr)
			log.Println("stack:", string(debug.Stack()))
			c.JSON(http.StatusOK, common.Response{
				Code:    common.BadCode,
				Message: errMsg,
				Date:    nil,
			})
			return
		}
	}()

	if err = c.ShouldBindJson(&p,binding.JSON);err != nil{
		errMsg := fmt.Sprintf("bind request JSON error: %s", err.Error())
			c.JSON(http.StatusOK, common.Response{
				Code:    common.BadCode,
				Message: errMsg,
				Date:    nil,
			})
			return
	}

	c.JSON(http.StatusOK, common.Response{
		Code:    common.GoodCode,
		Message: "",
		Date:    fmt.Sprintf("Hello %s", p.Name),
	})

	return
}

执行结果如下:

result = {Code:1 Message:bind request JSON error: EOF Date:<nil>}

2、问题的解决

(1)、在gin.context中缓存的方案

我们通过相关线索查询 github 的历史Issue 信息发现,这个这个问题最早可以追溯到2015年:github.com/gin-gonic/g…, 从下面的相关评论可以看到,在2018年的一次提交中,通过提供一个新的方法 ShouldBindBodyWith 已经解决了这个问题。

该提交解决多次读取body中字节流的思路是:第一次读取时,通过IO读取body数据后将该数据缓存至context的指定key中。第二次读取时,首先从 context 中查看指定key中是否存在数据,如果存在直接读取,如果不存在才通过IO读取数据。具体代码如下:

const BodyBytesKey = "_gin-gonic/gin/bodybyteskey"
...
// ShouldBindBodyWith is similar with ShouldBindWith, but it stores the request
// body into the context, and reuse when it is called again.
//
// NOTE: This method reads the body before binding. So you should use
// ShouldBindWith for better performance if you need to call only once.
func (c *Context) ShouldBindBodyWith(obj any, bb binding.BindingBody) (err error) {
	var body []byte
	if cb, ok := c.Get(BodyBytesKey); ok {
		if cbb, ok := cb.([]byte); ok {
			body = cbb
		}
	}
	if body == nil {
		body, err = ioutil.ReadAll(c.Request.Body)
		if err != nil {
			return err
		}
		c.Set(BodyBytesKey, body)
	}
	return bb.BindBody(body, obj)
}

关于BodyBytesKey的确定,开发者最先使用的是github.com/gin-gonic/gin/bodyBytes,但有人提出这个key第一眼看起来很像一个 import,而且有可能和用户定义的key冲突。所以后来改成了_gin-gonic/gin/bodybyteskey

这一优化方式虽然解决 body 字节流多次读取的问题,但是要求用户每次读取时都必须使用ShouldBindBodyWith方法才可以生效。 因此,当多人合作开发时,如果有人提前使用过ShouldBindBodyWith,而另外一个人不知道,很可能会发生意想不到的错误。例如 Issue 2502

该用户的异常源自于,在中间件中使用 ShouldBindBodyWith 方法读取的 body信息之后,在 Handler逻辑中使用 decoder := json.NewDecoder(c.Request.Body) 方法,或者通过ShouldBindJson 方法读取 body 信息。由于后两者的读取方式都是通过IO读取,而此时IO中的数据已经被ShouldBindBodyWith方法对我并关闭,因此会产生 EOF的异常情况。

(2)、通过IO复制支持body多次读取

基于上述问题,有人提交了新的解决方案 PR#1810PR#2503(closed)。两次提交方案一致,大致思路都是在 ShouldBindBodyWith 方法中实现 body的拷贝,从而实现 通过IO的多次读取。具体实现如下:

func (c *Context) ShouldBindBodyWith(obj any, bb binding.BindingBody) (err error) {
		var body []byte
		if cb, ok := c.Get(BodyBytesKey); ok {
			if cbb, ok := cb.([]byte); ok {
				body = cbb
			}
		}
		if body == nil {
			body, err = ioutil.ReadAll(c.Request.Body)
			if err != nil {
				return err
			}
			// close the body
			c.Request.Body.Close()
			// reset the body to the original unread state
			c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(body))
			c.Set(BodyBytesKey, body)
		}
		return bb.BindBody(body, obj)
	}

目前PR#1810还是开启阶段,持续关注该提交是否会被接收。

3、一些思考

个人并不太支持 PR#1810这种优化方式,原因如下:

  • 1、c.Request.Body数据拷贝的问题,会对该方法的性能有一定影响。
  • 2、context 缓存和 c.Request.Body数据拷贝多一份冗余数据。
  • 3、即使增加了c.Request.Body数据拷贝,误用ShouldBindJson以及decoder := json.NewDecoder(c.Request.Body)等方法,也只能再通过IO读取一次,倒不如规范用法,统一使用ShouldBindBodyWit方法进行多次读取。