img

原来 Go 里也可能会有二级指针的问题,神奇

背景

Update module mitchellh/mapstructure to 1.5.0 by StevenACoffman · Pull Request #2111 · 99designs/gql

How to fix shadowing introduced by decodeBasic in v1.3.2? · Issue #290 · mitchellh/mapstructure

将 mapstructure 包升级后出现错误,原来可以正常解析的变量在wrapCaller中一切正常,但在调用它的函数中还是 nil

复现

func TestDecode(t *testing.T) {
   var resp struct {
      Text *string `mapstructure:"text"`
   }

   query := map[string]interface{}{
      "text": "blah",
   }

   if err := wrapper(query, &resp); err != nil {
      fmt.Printf("decode failed: %v", err)
      return
   }
   fmt.Printf("after wrapper:\n%#v\n", resp)
}

func wrapper(query map[string]interface{}, r interface{}) error {
   fmt.Printf("wrapper before unpack:\n%#v\n", r)
   err := unpack(query, &r)
   fmt.Printf("wrapper after unpack:\n%#v\n", r)
   return err
}

func unpack(data interface{}, into interface{}) error {
   d, err := NewDecoder(&DecoderConfig{
      Result:      into,
      TagName:     "json",
      ErrorUnused: true,
      ZeroFields:  true,
   })
   if err != nil {
      return fmt.Errorf("mapstructure: %w", err)
   }
   return d.Decode(data)
}

[email protected]以后版本运行后得到以下结果

wrapper before unpack:
&struct { Text *string "mapstructure:\"text\"" }{Text:(*string)(nil)}
wrapper after unpack:
&struct { Text *string "mapstructure:\"text\"" }{Text:(*string)(0xc00004adf0)}
after wrapper:
struct { Text *string "mapstructure:\"text\"" }{Text:(*string)(nil)}

[email protected]及之前的版本得到的结果

wrapper before unpack:
&struct { Text *string "mapstructure:\"text\"" }{Text:(*string)(nil)}
wrapper after unpack:
&struct { Text *string "mapstructure:\"text\"" }{Text:(*string)(0xc00004add0)}
after wrapper:
struct { Text *string "mapstructure:\"text\"" }{Text:(*string)(0xc00004add0)}

可以看到,更新后 after wrapper 中 Text 的变为了 nil

分析

调试运行,发现在wrapper函数中出现了两个r:参数r(shadowed)和变量r

unpack后变量r的值正常,而参数r的值还是 nil

img

显然参数r的值才是上级函数中resp的值。而在旧版中,依然会出现两个r,但两个值都正常,因此没有出现问题。推测很有可能是 shadowed 导致unpack只更新了变量r

  1. shadowed 是什么?

shadowed,是由变量作用域中重新定义了一个同名变量,原变量被覆盖所导致的。以下代码无法得到预期值,正是因为变量i在 if 中被覆盖了,无法修改原来的i

func TestShadowed(t *testing.T) {
   i := 1
   if true {
      i := 1
      i++
   }
   if i != 2 {
      t.Errorf("expected 2, but got %d", i)
   }
}

常见的 shadowed 还出现在返回值 err 被覆盖等情况中,goland 中会自动检测变量覆盖并以不同的颜色显示,严重时 go 在编译时也会给出错误提示。

  1. 什么导致了 shadowed?

在上面的例子中,出现 shadowed 的原因都是重新定义导致原变量被覆盖。然而,wrapper函数中并没有定义任何新变量,显然参数r(shadowed)另有原因。

猜测可能是resp*struct{}转换成参数中interface{}时 go 进行自动处理,但经过验证单纯的转换并不会导致shadowed的出现,排除。

那么就只剩下err := caller(query, &r)这一行了,把它注释掉后再运行,就只剩一个正常的 r 了,果然是它!

进一步分析发现,是因为&r对 r 进行了取地址操作导致出现shadowed。至此,我们已经得出了一个最简单的解决方法:将&去掉,改为·err := caller(query, r)

  1. 为什么新版没有更新参数 r?

新版的改动:If interface value is not addressable, make copy and set · mitchellh/mapstructure@93663c4

大概就是对 interface,当实际变量的地址无法被获取时,意味着变量是不能更改的,创建一个相同类型的新变量进行替代。(旧版中强行更改那些不能更改的变量会 panic)

在我们的代码中wrapper调用unpack传入的参数&r的类型是&(interface{} | *struct{...})interface{} | **struct{}Decoder.Decode()reflect.ValueOf(d.config.Result).Elem()获取到的还是一个指针(*struct{}),它的地址无法被获取。在旧版中,decode 会顺着指针做进一步解析。而在新版,因为无法获取地址,会直接创建一个新的指针再进行解析,原指针指向的变量(上级函数resp)保持不变,引发 bug。

func TestDecode(t *testing.T) {
   var resp struct {
      Text *string `mapstructure:"text"`
   }

   query := map[string]interface{}{
      "text": "blah",
   }

   if err := wrapper(query, &resp); err != nil {
      fmt.Printf("decode failed: %v", err)
      return
   }
   fmt.Printf("after wrapper:\n%#v\n", resp)
   fmt.Printf("  address of resp: %p\n", &resp)
}

func wrapper(query map[string]interface{}, r interface{}) error {
   fmt.Printf("wrapper before unpack:\n%#v\n", r)
   fmt.Printf("  address of r: %p\n", &r)
   fmt.Printf("  address of resp: %p\n", r)
   err := unpack(query, &r)
   fmt.Printf("wrapper after unpack:\n%#v\n", r)
   fmt.Printf("  address of r: %p\n", &r)
   fmt.Printf("  address of resp: %p\n", r)
   return err
}

func unpack(data interface{}, into interface{}) error {
   d, err := NewDecoder(&DecoderConfig{
      Result:      into,
      TagName:     "json",
      ErrorUnused: true,
      ZeroFields:  true,
   })
   if err != nil {
      return fmt.Errorf("mapstructure: %w", err)
   }
   return d.Decode(data)
}
wrapper before unpack:
&struct { Text *string "mapstructure:\"text\"" }{Text:(*string)(nil)}
  address of r: 0xc000104d50
  address of resp: 0xc000140028
wrapper after unpack:
&struct { Text *string "mapstructure:\"text\"" }{Text:(*string)(0xc000104dc0)}
  address of r: 0xc000104d50
  address of resp: 0xc000140050
after wrapper:
struct { Text *string "mapstructure:\"text\"" }{Text:(*string)(nil)}
  address of resp: 0xc000140028
  1. 为什么会 shadowed?

而在wrapper中,我们对一个指针进行了取地址操作,但指针可以直接取地址吗?reflect.ValueOf(r).CanAddr()告诉我们不能。

在上一部分中,我们说到当变量地址无法被获取时,新版本会创建一个变量的 copy。猜测 go 中可能有类似的机制,在无法取地址时创建一个 copy 并把原来的变量覆盖掉。

使用go tool compile -S wrapper.go查看 go 汇编代码,会发现用到了&r的代码比没用到的复杂不少,而且有调用runtime.newobject,说明 go 在取地址时确实会对无法取地址的变量创建 copy。

解决

如果要避免shadowed,只要将wrapper&去掉,改为·err := caller(query, r)

而对于mapstructure,我们应该在decodeBasic加入对指针的判断,避免对指针进行替换。