记一次因为结构体复用导致的BUG

结构体复用 BUG#

本文首发作者个人博客:https://y1nhui.com/

前文#

先看一行代码

ws.Route(ws.Get("xxx")).To(handler.xx).Filter(handler.BodyFilter(&param.Crate{}))

咋一看似乎没用问题,为该路由增加校验规则,校验的类型是 param.Crate{}

至于 BodyFilter 函数内部是什么样的,先不谈。先谈本次遇到的问题

环境,BUG 与定位#

环境#

环境是运行在 k8s 集群内的一个 webserver pod。

集群内的流量管理是基于 istio 做的。

BUG 表现#

测试对环境做破坏性测试的时候,为 Create API 传递了一些不合法的值,成功被过滤拦截,并且返回报错。

但是之后测试发现了一个问题,正常的 Create 请求也被拦截,一并返回报错,并且报错内容与之前的报错一致。

定位#

这种问题,很明显就是哪里有了缓存。就是不知道在哪里有了缓存。

排除 istio 缓存#

测试环境的 webserver 是双副本在跑,笔者直接打开两副本的 log,传递正确报文。

发现 podA 与 podB 方便给出了不同反应。

当流量打到 podA 上后,返回了成功报文,对应的创建操作正常运行。而流量打到 podB 后,则会返回测试反馈的现象:错误

istio 的 virtualService 配置 host 是一个 svc,而非具体的 pod 名,既然可以稳定只在 podB 复现问题,基本可以排除是 istio 层面缓存了请求的可能。

因而在这里假定缓存问题出在了 pod 上面,也就是程序本身

排除报文缓存#

接着笔者就在猜想,是不是报文缓存。

如 读取报文内容使用的是拷贝读取,但是对应的字节仍然在缓冲区中,正确返回时应该会清空缓存区,但是错误返回跳过了清空这一步。

于是触发下一次读取时,继续从缓冲区中读到了错误内容。

于是笔者跟进 BodyFilter 函数,看到的第一个函数是


func  BodyFilter(s interface{}) restful.FilterFunction {

 return  func(req *restful.Request, resp *restful.Response, chain *restful.FilterChain) {

     ...

 if  bys, err  =  io.ReadAll(req.Request.Body); err  !=  nil {

 return

    }

 if  err  =  json.Unmarshal(bys, tmpS); err  !=  nil {

 return

    }

 ...

  }

}

坏消息,和缓冲区无关,比较 io.ReadAll 函数是定义在函数内的一个临时缓冲区,当函数返回时就应该等待被 GC 了,不会被复用

好消息是我看到问题了,这里用了一个闭包

定位#

问题一下清晰了,就如同上文提到了,io.ReadAll 不会有缓冲区的问题,是因为它在函数内单独定义了一个临时的缓冲区


func  ReadAll(r Reader) ([]byte, error) {

 b  :=  make([]byte, 0, 512)

 for {

 n, err  :=  r.Read(b[len(b):cap(b)])

 b  =  b[:len(b)+n]

 if  err  !=  nil {

 if  err  ==  EOF {

 err  =  nil

      }

 return  b, err

    }

 if  len(b) ==  cap(b) {

 // Add more capacity (let append pick how much).

 b  =  append(b, 0)[:len(b)]

    }

  }

}

这里的 b := make([]byte, 0, 512), 它本身并没有被返回,所以当函数执行完成后便会在 GC 等待被标记,之后回收

但是我们的 BodyFilter {} 函数,这里却用了一个闭包,代表着本来期望着的临时变量 s interface 永远在那里。它会被不断赋值,但是不被置空。

这样也就解释了,为什么这个 bug 在过去曾经被提及,但是最后并未被处理的原因了。

比如我们使用一个结构体


type  Account  struct {

Name  string

Test

}

type  Test  struct {

 Test  string

}

这里的 test 它并不是必传的。 测试在第一次传递了一个错误的 test 进来,导致了报错。同时在我们的内存中,s 对应的 Account {} 被永远赋予了一个错误的 test 的值。

直到某次传入参数中,附带了一个正确的 test,将它纠正。

而如果测试只是传递一个错误的 Name ,在下一次赋值又会将错误的 Name 给覆盖了,然后一切正常。

修复方法#

临时变量 (未验证)#

最开始的想法是 手动创建一个 tmpS:=s 不就 ok 了


func  BodyFilter(s interface{}) restful.FilterFunction {

 tmpS:=s

 return  func(req *restful.Request, resp *restful.Response, chain *restful.FilterChain) {

     ...

 if  bys, err  =  io.ReadAll(req.Request.Body); err  !=  nil {

 return

    }

 if  err  =  json.Unmarshal(bys, tmpS); err  !=  nil {

 return

    }

 ...

  }

}

但是这一步被同事制止了,表示这里是否有可能获取到一个 nil 的指针对象

tmpS = &Account{} -> nil

笔者还未来得及验证,事后如果有验证将会补充到评论区

函数参数#

这里我们的传入其实主要是为了绑定 body 与它的对应的 validator 的条件

那么我们完全可以将 s 的类型变为一个会返回 interface 的方法


func  RequestBodyParamFilterTest(s func() interface{}) restful.FilterFunction {

 return  func(req *restful.Request, resp *restful.Response, chain *restful.FilterChain) {

 tmpS  :=  s()

 ...

  }

}

ws.Route(ws.Get("xxx")).To(handler.xx).Filter(handler.BodyFilter(func() interface{} {

 return  &Create{}

  }))

这样,简洁方便。唯一的问题就是 handler.BodyFilter 函数调用的地方太多, 设计多个仓库,改动略大

通过反射创建#

还有一种方法就是基于反射在函数内 new 一个新的结构体

这里参考了一个第三方库的写法,可惜的是在写本博客的时候已经有些想不起来是哪个库了


func  alloc(s interface{}) interface{} {

 vv  :=  reflect.ValueOf(s)

 if  vv.Kind() ==  reflect.Ptr {

 return  reflect.New(vv.Elem().Type()).Interface()

  }

 return  reflect.New(vv.Type()).Interface()

}

这个方法看起来有些难以理解,但是改动最小,在做了一些 单测验证可用后便作为改动方法合入了代码中

结语#

本文总结了前段时间遇到了一个因为结构体复用在校验方法中的 bug 复现,定位于修复的方法。

虽然最后写出来发现这个困扰了 一个下午的问题,其实根因倒是蛮简单的。

很多 bug 的解决反而是简单的,最大的难点在于如何定位。

结尾预留给笔者自己和读者两个点的问题

一个是上文的直接等于,是否可能出现指向 nil 的新变量

另一个是,此处或许可以用泛型的方法优化 BodyFilter() 函数,如果要优化的话,应该怎么做

本作品采用《CC 协议》,转载必须注明作者和本文链接
y1nhui