目录

Golang 程序配置管理

通常情况下,我们的应用程序会涉及到很多配置,比如 HTTP/RPC 监听端口、日志相关配置、数据库配置等。这些配置可以来自不同的配置源,例如本地配置文件、环境变量、命令行参数,或是 Consul 一类的远程配置中心,同时一项配置可能在多个不同的配置源中同时存在,需要进行优先级处理。这篇文章介绍使用 koanf 在 Golang 程序中进行配置管理。

viper 是 Golang 程序中被广泛使用的配置解析库,是一个开箱即用的配置 SKD,但是在使用过程中,viper 也暴露出来一些问题:

  • 强制规定配置 key 为小写格式,破坏了 TOML、HCL 等配置文件原有的语义定义:forcibly lowercasing keys
  • 强制规定配置源的优先级:default precedence order
  • File、CLI、ENV 等配置解析实现硬编码在代码库中,没有提供在应用层面新增 Parser 或定制解析过程的 API,无法进行扩展;
  • 一次拉取所有的第三方依赖,即使没有使用到对应的配置源和 Parser,viper 依然会拉取相应的依赖,例如 ETCD、Consul、gRPC 等:Why all the new dependencies?

viper 的代码库实现很复杂,短时间内很难了解它的设计思路。viper 对外没有暴露可扩展的语义,在实际使用过程中,如果遇到无法覆盖的应用场景,往往需要在业务层进行单独处理,会对业务逻辑增加额外的侵入性。因此需要一个更加轻量级,且易于扩展的配置管理实现。

概述

knanf 是一个轻量且易于扩展定制的 Golang 配置库,v2 版本通过独立的 Golang Module,将外部依赖项都与核心分离,并且可以根据需要单独安装,使得其核心代码只有一千余行:

  • JSON、Yaml 等数据格式的解析扩展,只需要实现Parser接口,独立于 Parser 目录中:koanf/parsers

    1
    2
    3
    4
    
    type Parser interface {
    	Unmarshal([]byte) (map[string]interface{}, error)
    	Marshal(map[string]interface{}) ([]byte, error)
    }
    
  • Consul、File、Env 等配置源的数据解析,只需要实现Provider接口,独立 Provider 目录中:koanf/providers

    1
    2
    3
    4
    
    type Provider interface {
    	ReadBytes() ([]byte, error)
    	Read() (map[string]interface{}, error)
    }
    
  • koanf 提供了 Provider 与 Parser 接口,通过实现对应的接口,可以接入更多的配置解析方式;

  • koanf 自身并未规定配置源的优先级,koanf 会按照 Provider 的调用顺序,覆盖已有的值;

koanf 的基础操作可以参考 knanf/readme,文中将不再赘述,以下内容将会在了解使用方式的基础上进行介绍。

配置优先级

koanf 会按照 Provider 的调用顺序,覆盖已有的值。因此我们可以通过封装统一的配置解析步骤,达到默认优先级的效果。例如下面的代码实现了一个 Parse() 函数,其参数有配置路径与数据格式,以及对应的 Parser(在示例中,支持本地文件与 Consul):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
func Parse(configPath, format string, reader ParserFunc, opts ...OptionFunc) error {
	var parser koanf.Parser

	switch format {
	case ConfigFormatYAML:
		parser = kyaml.Parser()
	case ConfigFormatJSON:
		parser = kjson.Parser()
	default:
		return fmt.Errorf("unsupported config format: %s", format)
	}

	if err := reader(configPath, parser); err != nil {
		return err
	}
}

// OptionFunc is the option function for config.
type OptionFunc func(map[string]any)

// ParserFunc Parse config option func
type ParserFunc func(path string, parser koanf.Parser) error

func ReadFromFile(filePath string, parser koanf.Parser) error {
	if err := k.Load(kfile.Provider(filePath), parser); err != nil {
		// Config file was found but another error was produced
		return fmt.Errorf("error loading config from file [%s]: %w", filePath, err)
	}

	return nil
}

// ReadFromConsul read config from consul with format
func ReadFromConsul(configPath string, parser koanf.Parser) error {
	if err := k.Load(kconsul.Provider(kconsul.Config{}), parser); err != nil {
		return fmt.Errorf("error loading config from consul: %w", err)
	}

	return nil
}

配置数据可以以不同的数据格式与存储方式进行组织,得益于 koanf 优秀的解耦思想,我们可以很容易的支持内部的配置中心

在容器化部署的情况下,我们还会根据环境变量来修改当前程序的某些配置项,以便于在不修改镜像内容的情况下,进行动态配置。ENV Provider 通过统一前缀来标识所需要解析的环境变量,并提供了一个可选的 callback 函数,让用户自定义环境变量名称的处理逻辑。在示例代码中,我们自定义的回调函数去除了统一前缀,并将所有大写字符转为小写:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
	// Second, read config from environment variables,
	// Parse environment variables and merge into the loaded config.
	// "PUDDING" is the prefix to filter the env vars by.
	// "." is the delimiter used to represent the key hierarchy in env vars
	// The (optional, or can be nil) function can be used to transform
	// the env var names, for instance, to lowercase them
	if err := k.Load(kenv.Provider("PUDDING/", defaultDelim, func(s string) string {
		return strings.ReplaceAll(strings.ToLower(
			strings.TrimPrefix(s, "PUDDING/")), "/", ".")
	}), nil); err != nil {
		return fmt.Errorf("error loading config from env: %w", err)
	}

CLI 优先级

CLI 优先级是一个特殊的场景,他具有默认值,但是可以被命令行参数覆盖。由于 koanf repo 内并未实现 Golang 标准库 flag 的配置解析。因此需要自己实现一个 Provider。

为了便于理解,以下代码段仅贴出了关键部分。Golang flagSet 提供了VisitAll方法,以便于调用方能够访问 flagSet 中所有被命令行参数设置与未被设置的 flag。同时标准库中的所有 flag Value 类型都实现了flag.Getter方法,能够获取该 flag 的当前值,以及该值的字符串类型表示方法。我们将当前值与初始值进行比对,就能感知到命令行参数是否被设置。同时 koanf 也实现了 Exist 方法,用来判断 key 是否已经存在:

  • 如果命令行参数没有设置,并且低优先级的配置文件也没有设置,那么就使用 CLI 默认值;
  • 如果命令行参数没有设置,但是低优先级的配置文件设置了,那么就使用低优先级的配置文件的值;
  • 如果命令行参数设置了,那么就使用命令行参数的值;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// Read reads the flag variables and returns a nested conf map.
func (p *Flag) Read() (map[string]any, error) {
	mp := make(map[string]any)

	p.flagSet.VisitAll(func(f *flag.Flag) {
		var (
			key   string
			value any
		)

		if p.cb != nil {
			key, value = p.cb(f.Name, f.Value)
		} else {
			// All Value types provided by flag package satisfy the Getter interface
			// if user defined types are used, they must satisfy the Getter interface
			getter, ok := f.Value.(flag.Getter)
			if !ok {
				panic(fmt.Sprintf("flag %s does not implement flag.Getter", f.Name))
			}
			key, value = f.Name, getter.Get()
		}

		// if the key is set, and the flag value is the default value, skip it
		if p.ko.Exists(key) && f.Value.String() == f.DefValue {
			return
		}

		mp[key] = value
	})

	return maps.Unflatten(mp, p.delim), nil
}

最后Read方法的返回值是一个map[string]any哈希表,koanf 会将该哈希表与已有的配置进行合并覆盖。

配置 Watch

配置的Watch方法并不包含在 koanf 的核心模块中,而是由 Provider 独自实现。我们需要向 Provider 注册一个回调函数,当有配置变更事件到来时,加载新的配置信息,并让业务模块感知到配置的变化。

1
2
3
4
5
6
7
8
	f := file.Provider("mock/mock.json")
	if err := k.Load(f, json.Parser()); err != nil {}

	f.Watch(func(event interface{}, err error) {
		k.Load(f, json.Parser())
		k.Print()
    // trigger reload config
	})

配置反序列化

koanf 所有的配置都会被保存在一个全局的 map 中,但是 map 是一个无类型的容器,是一个宽松的数据结构,不利于做类型检查与数据校验。 在实际使用过程中,我们往往需要将配置反序列化为结构体,以便于在代码中使用。koanf 提供了Unmarshal方法,可以将配置反序列化为结构体,因此我们需要在结构体中添加mapstructure标签,以便于 koanf 反序列化时能够正确的解析字段:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
type BaseConfig struct {
	HostDomain string `json:"host_domain" yaml:"host_domain" mapstructure:"host_domain"`
	GRPCPort int `json:"grpc_port" yaml:"grpc_port" mapstructure:"grpc_port"`
	HTTPPort int `json:"http_port" yaml:"http_port" mapstructure:"http_port"`
	EnableTLS bool `json:"enable_tls" yaml:"enable_tls" mapstructure:"enable_tls"`
}

func UnmarshalToStruct(path string, c any) error {
	if err := k.UnmarshalWithConf(path, c, koanf.UnmarshalConf{Tag: "mapstructure"}); err != nil {
		return fmt.Errorf("failed to unmarshal config: %w", err)
	}

	return nil
}

总结

koanf 最精髓的设计之处在于,将 Parser、Provider 与 Core 解耦,通过插件的形式引入,每一个模块都可以根据需要单独安装。并且自定义实现 Provider 的成本低廉,能够便捷地进行功能扩展。