阿猫的博客

阿猫的博客

记一个无关痛痒的功能改进和对 Unmarshal 的一些了解

Go
576
2023-03-22

有如下代码:

type Config struct {
   App Application `yaml:"application"`
}

type Application struct {
   AppName    string `yaml:"app_name"`
   ServerHost  string `yaml:"server_host"`
   ServerPort   string `yaml:"server_port"`
   RunMode     string `yaml:"run_mode"`
}

// 从指定目录取指定环境和类型的配置文件
cfg := configlib.NewLocalFileWithOptions(
   configlib.WithPath("configs"),
   configlib.WithConfigName(base_flags.FlagCluster),
   configlib.WithConfigType(configlib.ConfigTypeYaml),
)
config = &Config{}
// 将配置加载进结构体
if err := cfg.LoadToObject(config); err != nil {
   panic(err)
}
log.Infof(context.Background(), "配置加载成功: %+v", config)

以及以下的配置:

application:
 app_name: common
 server_host: 127.0.0.1
 server_port: "1234"
 run_mode: debug

在调试中,发现 config 没有值。一开始怀疑文件路径不对或者没加载到配置文件,尝试了一下,如果没读取到文件,会报文件不存在的错。事情没有这么简单,于是继续看这个 cfg.LoadToObject 函数的实现。

func (c *config) LoadToObjectNew(v interface{}) error {
   err := c.Load()
   if err != nil {
      return err
   }
   err = c.adapter.GetData(v)
   if err != nil {
      return err
   }
   return nil
}

其中 c.Load 包装了一下用 viper 读配置文件的方法,看了一下没什么大问题,没有报错,用 debugger 看也能看到 c 实际上是有值的,所以问题聚焦在 GetData 这个方法上。

func (a *fileProvider) GetData(obj interface{}) error {
   by, err := json.Marshal(a.vp.AllSettings())
   if err != nil {
      return err
   }
   if err = json.Unmarshal(by, obj); err != nil {
      return err
   }
   return nil
}

好,到这里问题其实已经解决了 80%:这个方法从 a.vp (*viper.Viper) 中拿到一个map[string]interface{},然后把这个先是 json.Marshal ,再 json.Unmarshal 回最终需要的obj 上,因为一路上都用的 json,之前的配置结构体没有 json 的 tag,自然是没有值的。尝试了一下,给它加上 json 的 tag,马上就好了。

本来到这里其实就可以下班了,活了,能用。但是老觉得有些别扭,我明明是一个 yaml 的配置文件,凭啥我要多写一个 json 的 tag?而且,理论上 viper 支持以下类型的配置文件,有其他需求的时候怎么办?封装这个的作者也没有留下文档,记录这个一定需要 json tag 的坑,下次有人再使用肯定又会踩到,所以感觉还是需要优化一下。

"yaml", "yml", "json", "toml", "hcl", "tfvars", "ini", "properties", "props", "prop", "dotenv", "env"

从上面的代码可以看到,GetData 做的就是将读取到的配置加载进一个结构体。之前出的问题是,因为使用了 json 包,如果没有 json 的 tag,就读不到了。

我的思路是,应该会有一个通用的 Unmarshal ,可以根据来源的类型 Unmarshal 回对应的 tag 中。于是查了一下 viper 的文档,果然有这么一个方法 viper.Unmarshalviper package - github.com/spf13/viper - Go Packages

回到刚才有问题的代码,我尝试把这个 json marshal 完再 unmarshal 的方法替换成 viper.Unmarshal ,如下。

func (c *config) LoadToObjectNew(v interface{}) error {
  return a.vp.Unmarshal(obj)
}

再试了一下,还是不行。

这时候看到基础库里,有一个单元测试,于是进行了一些实验,发现如下现象。

  • 单测中删掉结构体上的所有 json tag,除了结构体成员名称与实际名称不同的都能读到值;
  • 在 yaml 的例子中,如果没有 json tag 所有值都读不到。
  • 在 yaml 的例子中,如果有部分字段有 json tag,有 tag 的字段能读到值。

感觉关键还是这个 tag。接着往下看,viper.Unmarshal 这个方法主要是包装了一下 mapstructure.Decode ,再往下,这个函数主要是用反射来做映射的,一切都跟预想中的差不多。回忆了一下之前看映射的时候的知识,映射是可以拿到 struct tag 的,猜想 unmarshal 应该会跟这个有关,查了一下,发现了这个 What are the use(s) for struct tags in Go? - Stack Overflow

于是继续去查 mapstructure 包的文档,直接搜 tag 。看到如下:

You can change the behavior of mapstructure by using struct tags. The default struct tag that mapstructure looks for is “mapstructure” but you can customize it using DecoderConfig.

再搜 DecoderConfig ,感觉离答案越来越近了。果然让我找到有一个 TagName 的选项,见 mapstructure package - github.com/mitchellh/mapstructure - Go Packages。如果在 viper.Unmarshal 的时候,让 mapstructure 的 decoder 根据输入文件的类型去找 tag,不就能解决了吗?于是根据文档,把这个 GetData 改成了如下模样。

func (a *fileProvider) GetData(obj interface{}) error {
   unmarshalOptions := viper.DecoderConfigOption(func(decoderConfig *mapstructure.DecoderConfig) {
      decoderConfig.TagName = a.opts.configType
   })
   return a.vp.Unmarshal(obj, unmarshalOptions)
}

中间这里还遇到了一个坑,当时输入 decoderConfig. 的时候编辑器怎么都不提示有 TagName 这个字段,一看文档的版本是 v1.5.0,去 go.mod 里面看版本是 v1.4.1 。更新了一下 mapstructure 的版本就解决了。

再试,果然活了。

一些在踩坑过程中了解到的东西

一开始怀疑 jsoniterencoding/json 实现上有所不同,查阅文档发现它的 marshal 和 unmarshal 宣称跟原来的是 dropin replacement,姑且相信它。

查阅 json 的文档,json package - encoding/json - Go Packagesjson package - encoding/json - Go Packages,有以下几个点需要注意。

  • The encoding of each struct field can be customized by the format string stored under the “json” key in the struct field’s tag. The format string gives the name of the field, possibly followed by a comma-separated list of options. The name may be empty in order to specify options without overriding the default field name.
  • As a special case, if the field tag is “-”, the field is always omitted.
  • (对于最底层嵌套的结构体,以下规则成立)
    1. Of those fields, if any are JSON-tagged, only tagged fields are considered, even if there are multiple untagged fields that would otherwise conflict.
    1. If there is exactly one field (tagged or not according to the first rule), that is selected.
    1. Otherwise there are multiple fields, and all are ignored; no error occurs.
  • To unmarshal JSON into a struct, Unmarshal matches incoming object keys to the keys used by Marshal (either the struct field name or its tag), preferring an exact match but also accepting a case-insensitive match. By default, object keys which don’t have a corresponding struct field are ignored (see Decoder.DisallowUnknownFields for an alternative).

总结一下,在没有 tag 的时候,会按以下方式取值:

  • 精确匹配
  • 大小写不敏感匹配

如果没匹配上,不会报错(坑)。这个解释了单测实验中的现象。

以上这几段话还是有点不理解,以及 MarshalUnmarshal 有很详尽的规则,有时间可以细看一下。

yaml.Marshalyaml.Unmarshal 的行为又有所不同,它的匹配策略是默认按照字段小写匹配。详见yaml package - gopkg.in/yaml.v2 - Go Packagesyaml package - gopkg.in/yaml.v2 - Go Packages