0%

go 库学习之 Viper

应用程序通常都需要管理配置,这些配置可以来自命令行参数、环境变量、配置文件等等。Viper 库 是 Go 中一个经典配置管理库,为 Go 应用程序提供完整的配置解决方案。许多开源 Go 项目都使用了 Viper,例如 HugoCilium 等等。这篇文章会对 Viper 库的使用做一个基本介绍。

Viper 简介

Viper 为 Go 应用程序(包括 12-Factor apps)提供完整的配置解决方案,它可以满足应用程序各种配置需求,包括:

  • 默认值设置
  • 从 JSON、TOML、YAML、HCL、envfile、Java properties 格式的配置文件中读取配置
  • 实时监控并重读配置文件(可选)
  • 从环境变量中读取配置
  • 从远端配置系统(etcd、Consul 等)读取配置,并监控变化
  • 从命令行 flags 中读取配置
  • 从 buffer 中读取配置
  • 显式配置值

在现代应用程序中,不应该关心配置文件的具体格式,只需要专注于构建软件本身即可。Viper 可以帮助你实现上述目标:

  • 查找、加载、反序列化配置文件:支持 JSON、TOML 等多种配置文件格式
  • 可以为不同的配置选项提供默认值
  • 支持通过命令行 flags 来覆盖配置选项的值
  • 提供了别名系统,在不破坏现有代码的前提下重命名参数
  • 当用户使用命令行 flags 或配置文件提供的配置值和默认值相同时,可以轻易区分出这两者

Viper 按照如下优先级进行配置解析,优先级从高到低排列:

  • 显式调用 Set 设置值
  • flag
  • env
  • config
  • key/value 存储
  • default

需要注意,Viper 配置的 key 是大小写不敏感的

把值存入 Viper 中

设置默认值

一个优秀的配置系统都会支持默认值,当用户没有显式配置某个配置项时,默认值就有用了。

Viper 通过如下方式设置默认值:

1
2
3
viper.SetDefault("ContentDir", "content")
viper.SetDefault("LayoutDir", "layouts")
viper.SetDefault("Taxonomies", map[string]string{"tag": "tags", "category": "categories"})

从配置文件中读取

Viper 本身也需少量设置,这样才能知道去哪里查找配置文件。Viper 支持多种格式的配置文件,可以从多个路径中查找配置文件,但是目前单个 Viper 实例只支持单个配置文件。Viper 没有提供任何默认的 配置文件搜索路径,而是由应用程序自己决定。

如下是一个使用 Viper 查找、读取配置文件的示例:

1
2
3
4
5
6
7
8
9
viper.SetConfigName("config") // name of config file (without extension)
viper.SetConfigType("yaml") // REQUIRED if the config file does not have the extension in the name
viper.AddConfigPath("/etc/appname/") // path to look for the config file in
viper.AddConfigPath("$HOME/.appname") // call multiple times to add many search paths
viper.AddConfigPath(".") // optionally look for config in the working directory
err := viper.ReadInConfig() // Find and read the config file
if err != nil { // Handle errors reading the config file
panic(fmt.Errorf("fatal error config file: %w", err))
}

如下特别处理了配置文件查找失败的错误:

1
2
3
4
5
6
7
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
// Config file not found; ignore error if desired
} else {
// Config file was found but another error was produced
}
}

写入配置文件

有时我们想保存在运行时所做的所有修改。Viper 提供了一系列接口来实现该目的:

  • WriteConfig:将当前 viper 配置保存到预定义路径中。如果没有预定义路径则出错。会覆盖该路径下已经存在的配置文件
  • SafeWriteConfig:将当前 viper 配置保存到预定义路径中。如果没有预定义路径则出错。不会覆盖该路径下已经存在的配置文件
  • WriteConfigAs:将当前 viper 配置保存到指定路径中,会覆盖该路径下已经存在的配置文件
  • SafeWriteConfigAs:将当前 viper 配置保存到指定路径中,不会覆盖该路径下已经存在的配置文件

示例如下:

1
2
3
4
5
viper.WriteConfig() // writes current config to predefined path set by 'viper.AddConfigPath()' and 'viper.SetConfigName'
viper.SafeWriteConfig()
viper.WriteConfigAs("/path/to/my/.config")
viper.SafeWriteConfigAs("/path/to/my/.config") // will error since it has already been written
viper.SafeWriteConfigAs("/path/to/my/.other_config")

监控并重新读取配置文件

Viper 支持让应用程序在运行时实时读取配置文件。重启服务器以让配置重新生效的日志过去了,viper 可以让应用程序在运行时读取到配置文件的更新。

可以让 viper 实例监听配置更新,并且指定一个回调函数,每次配置更新时都运行该函数:

1
2
3
4
viper.OnConfigChange(func(e fsnotify.Event) {
fmt.Println("Config file changed:", e.Name)
})
viper.WatchConfig()

**在调用 WatchConfig() 之前需要确保所有的配置路径都已经添加了。

从 io.Reader 中读取配置

Viper 预定义了许多配置源,例如文件、环境变量、flags、远端 KV 存储,但是不局限于这些配置源。你可以实现自己的配置源,并提供给 viper:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
viper.SetConfigType("yaml") // or viper.SetConfigType("YAML")

// any approach to require this configuration into your program.
var yamlExample = []byte(`
Hacker: true
name: steve
hobbies:
- skateboarding
- snowboarding
- go
clothing:
jacket: leather
trousers: denim
age: 35
eyes : brown
beard: true
`)

viper.ReadConfig(bytes.NewBuffer(yamlExample))

viper.Get("name") // this would be "steve"```

覆盖设置

这些覆盖值可以来自命令行 flag,也可能来源于自己的程序逻辑:

1
2
3
viper.Set("Verbose", true)
viper.Set("LogFile", LogFile)
viper.Set("host.port", 5899) // set subset

注册并使用别名

别名可以让某个值通过多个 key 引用:

1
2
3
4
5
6
7
viper.RegisterAlias("loud", "Verbose")

viper.Set("verbose", true) // same result as next line
viper.Set("loud", true) // same result as prior line

viper.GetBool("loud") // true
viper.GetBool("verbose") // true

使用环境变量

Viper 完全支持环境变量,从而天然满足 12 factor applications。Viper 提供了 5 个方法来支持环境变量:

  • AutomaticEnv()
  • BindEnv(string...) : error
  • SetEnvPrefix(string)
  • SetEnvKeyReplacer(string...) *strings.Replacer
  • AllowEmptyEnv(bool)

当使用环境变量时,需要注意 Viper 对待环境变量是大小写敏感的

Viper 提供了一种保证变量唯一的机制。SetEnvPrefix 可以告诉 viper 读取变量时都使用一个前缀。这个前缀对 BindEnvAutomaticEnv 方法都有效。

BindEnv 接受一个或多个参数。第一个参数是 key 的名称,之后的参数则是与该 key 绑定的环境变量的名称。如果提供了多个,则优先级按照参数顺序进行匹配。如果没有提供,那么 Viper 自动假定环境变量使用如下格式:prefix + _ + 全大写形式的 key。注意,如果显示提供了环境变量名称,不会自动添加 prefix。例如如果第二个参数是 id,那么 Viper 会查找名称为 ID 的环境变量。

使用环境变量还有一点需要注意,每次访问值的时候都会重新读取读取,而不是在调用 BindEnv 时固定值。

当使用 AutomaticEnv 时,每次调用 viper.Get 时 Viper 都会检查环境变量:所检查的环境变量名称为 prefix + 全大写形式的 key

SetEnvKeyReplacer 允许你使用一个 strings.Replacer 对象来重写 Env 的 key。例如当你使用 Get() 时希望使用 -,但环境变量却使用 _ 作为分隔符,此时该方法就可以发挥作用了。

或者你可以在 NewWithOptions 中使用 EnvKeyReplacer 选项,和 SetEnvKeyReplacer 不同,它可以接受一个 StringReplacer 接口,从而允许你自定义字符串替换逻辑。

默认空的环境变量被认为是 unset 的,因此会继续尝试下一个配置源。如果需要将空的环境变量认为是 set 的,可以使用 SetEnvKeyReplacer 方法。

如下是使用 Env 的示例:

1
2
3
4
5
6
SetEnvPrefix("spf") // will be uppercased automatically
BindEnv("id")

os.Setenv("SPF_ID", "13") // typically done outside of the app

id := Get("id") // 13

使用 Flags

Viper 支持和 flags 绑定,具体来说,Viper 支持 Cobra 库中使用的 Pflags。

BindEnv 类似,并不是 bind 方法调用时设置值,而是在访问时设置值的。所以可以任意早(甚至在 init() 中` 地调用 bind 方法。例如:

1
2
serverCmd.Flags().Int("port", 1138, "Port to run Application server on")
viper.BindPFlag("port", serverCmd.Flags().Lookup("port"))

也可以绑定到一个已经存在的 pflags set 上:

1
2
3
4
5
6
pflag.Int("flagname", 1234, "help message for flagname")

pflag.Parse()
viper.BindPFlags(pflag.CommandLine)

i := viper.GetInt("flagname") // retrieve values from viper instead of pflag

Viper 使用 pflag 包并不妨碍其他包使用标准库中的 flag 包。通过导入这些 flag,pflag 包可以处理那些由 flag 包定义的 flag。该操作由 pflag 包提供的 AddGoFlagSet() 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

import (
"flag"
"github.com/spf13/pflag"
)

func main() {
// using standard library "flag" package
flag.Int("flagname", 1234, "help message for flagname")

pflag.CommandLine.AddGoFlagSet(flag.CommandLine)
pflag.Parse()
viper.BindPFlags(pflag.CommandLine)

i := viper.GetInt("flagname") // retrieve value from viper

// ...
}

如果你不使用 Pflags,Viper 提供了两个接口来绑定其他 flag 包:

  • FlagValue 代表单个 flag,一旦实现该接口,可以让 Viper 绑定它:
1
2
3
4
5
6
7
type myFlag struct {}
func (f myFlag) HasChanged() bool { return false }
func (f myFlag) Name() string { return "my-flag-name" }
func (f myFlag) ValueString() string { return "my-flag-value" }
func (f myFlag) ValueType() string { return "string" }

viper.BindFlagValue("my-flag-name", myFlag{})
  • FlagValueSet 代表一组 flags,实现了该接口后,同样可以让 Viper 绑定它
1
2
3
4
5
6
7
8
9
10
11
12
13
14
type myFlagSet struct {
flags []myFlag
}

func (f myFlagSet) VisitAll(fn func(FlagValue)) {
for _, flag := range flags {
fn(flag)
}
}

fSet := myFlagSet{
flags: []myFlag{myFlag{}, myFlag{}},
}
viper.BindFlagValues("my-flags", fSet)

远端 Key/Value 存储支持

为了在 Viper 中开启远端支持,需要匿名导入 viper/remote 包:

1
import _ "github.com/spf13/viper/remote"

Viper 可以从 Key/Value(例如 etcd、Cousul 等)的路径检索出配置字符串(例如 JSON、TOML 等)。Viper 支持多个主机,使用 ; 分隔:

1
http://127.0.0.1:4001;http://127.0.0.1:4002.

Viper 使用 crypt 来从 K/V 存储中检索配置,这意味着你如果有正确的 gpg 秘钥,你可以将配置值加密存储并自动解密。加密是可选的。crypt 提供了命令行助手,可以直接将配置保存到 K/V 存储中,crypt 默认连接到 etcd(http://127.0.0.1:4001)。

1
2
3
4
$ go get github.com/bketelsen/crypt/bin/crypt
$ crypt set -plaintext /config/hugo.json /Users/hugo/settings/config.json

$ crypt get -plaintext /config/hugo.json

如下是使用 K/V 存储的示例,首先是未加密的示例:

etcd:

1
2
3
viper.AddRemoteProvider("etcd", "http://127.0.0.1:4001","/config/hugo.json")
viper.SetConfigType("json") // because there is no file extension in a stream of bytes, supported extensions are "json", "toml", "yaml", "yml", "properties", "props", "prop", "env", "dotenv"
err := viper.ReadRemoteConfig()

etcd3:

1
2
3
viper.AddRemoteProvider("etcd3", "http://127.0.0.1:4001","/config/hugo.json")
viper.SetConfigType("json") // because there is no file extension in a stream of bytes, supported extensions are "json", "toml", "yaml", "yml", "properties", "props", "prop", "env", "dotenv"
err := viper.ReadRemoteConfig()

Consul:

首先需要在 Consul 中设置一个 Key,其 Value 是你希望保存的配置,例如创建一个 MY_CONSUL_KEY,其 value 如下:

1
2
3
4
{
"port": 8080,
"hostname": "myhostname.com"
}
1
2
3
4
5
6
viper.AddRemoteProvider("consul", "localhost:8500", "MY_CONSUL_KEY")
viper.SetConfigType("json") // Need to explicitly set this to json
err := viper.ReadRemoteConfig()

fmt.Println(viper.Get("port")) // 8080
fmt.Println(viper.Get("hostname")) // myhostname.com

Firestore:

1
2
3
viper.AddRemoteProvider("firestore", "google-cloud-project-id", "collection/document")
viper.SetConfigType("json") // Config's format: "json", "toml", "yaml", "yml"
err := viper.ReadRemoteConfig()

NATS:

1
2
3
viper.AddRemoteProvider("nats", "nats://127.0.0.1:4222", "myapp.config")
viper.SetConfigType("json")
err := viper.ReadRemoteConfig()

当然也可以使用 SecureRemoteProvider。如下是使用加密形式的 K/V 存储的示例:

1
2
3
viper.AddSecureRemoteProvider("etcd","http://127.0.0.1:4001","/config/hugo.json","/etc/secrets/mykeyring.gpg")
viper.SetConfigType("json") // because there is no file extension in a stream of bytes, supported extensions are "json", "toml", "yaml", "yml", "properties", "props", "prop", "env", "dotenv"
err := viper.ReadRemoteConfig()

如下展示了如何 watch etcd 中的变化(未加密):

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
// alternatively, you can create a new viper instance.
var runtime_viper = viper.New()

runtime_viper.AddRemoteProvider("etcd", "http://127.0.0.1:4001", "/config/hugo.yml")
runtime_viper.SetConfigType("yaml") // because there is no file extension in a stream of bytes, supported extensions are "json", "toml", "yaml", "yml", "properties", "props", "prop", "env", "dotenv"

// read from remote config the first time.
err := runtime_viper.ReadRemoteConfig()

// unmarshal config
runtime_viper.Unmarshal(&runtime_conf)

// open a goroutine to watch remote changes forever
go func(){
for {
time.Sleep(time.Second * 5) // delay after each request

// currently, only tested with etcd support
err := runtime_viper.WatchRemoteConfig()
if err != nil {
log.Errorf("unable to read remote config: %v", err)
continue
}

// unmarshal new config into our runtime config struct. you can also use channel
// to implement a signal to notify the system of the changes
runtime_viper.Unmarshal(&runtime_conf)
}
}()

从 Viper 中获取值

Viper 提供了一些方法来基于值的类型获取值,这些函数或方法包括:

  • Get(key string) : any
  • GetBool(key string) : bool
  • GetFloat64(key string) : float64
  • GetInt(key string) : int
  • GetIntSlice(key string) : []int
  • GetString(key string) : string
  • GetStringMap(key string) : map[string]any
  • GetStringMapString(key string) : map[string]string
  • GetStringSlice(key string) : []string
  • GetTime(key string) : time.Time
  • GetDuration(key string) : time.Duration
  • IsSet(key string) : bool
  • AllSettings() : map[string]any

如果配置没有找到,以上函数都会返回一个 零值。所以为了检查 key 是否存在,提供了 IsSet() 方法。另外,即使配置存在,但是将其值转换为指定的类型失败时,也会返回 零值

1
2
3
4
viper.GetString("logfile") // case-insensitive Setting & Getting
if viper.GetBool("verbose") {
fmt.Println("verbose enabled")
}

访问嵌套的 key

以上访问方法支持格式化的路径来检索深层嵌套的 key。例如如果存在如下 json 配置文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"host": {
"address": "localhost",
"port": 5799
},
"datastore": {
"metric": {
"host": "127.0.0.1",
"port": 3099
},
"warehouse": {
"host": "198.0.0.1",
"port": 2112
}
}
}

可以使用如下方法进行嵌套 key 的访问:使用 . 作为 key 路径的分隔符:

1
GetString("datastore.metric.host") // (returns "127.0.0.1")

这个查找过程仍然符合上面介绍的配置源搜索优先级。例如对于该配置文件,datastore.metric.hostdatastore.metric.port 都被正常定义了。如果默认值中有 datastore.metric.protocol,Viper 也可以找到该配置。但是如果 datastore.metric 被某个值直接覆盖了(例如通过 Set()、环境变量等等),那么 datastore.metric 下的所有子 key 都是未定义状态:它们被优先级更高的配置源给 shadowed 了。

Viper 可以在 path 中使用索引访问数组中的元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"host": {
"address": "localhost",
"ports": [
5799,
6029
]
},
"datastore": {
"metric": {
"host": "127.0.0.1",
"port": 3099
},
"warehouse": {
"host": "198.0.0.1",
"port": 2112
}
}
}
1
GetInt("host.ports.1") // returns 6029

最后,如果存在某个 key 匹配该分隔路径,那么会直接返回该 key 的值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"datastore.metric.host": "0.0.0.0",
"host": {
"address": "localhost",
"port": 5799
},
"datastore": {
"metric": {
"host": "127.0.0.1",
"port": 3099
},
"warehouse": {
"host": "198.0.0.1",
"port": 2112
}
}
}
1
GetString("datastore.metric.host") // returns "0.0.0.0"

提取子树

当开发一些可重用的模块时,通常需要提取配置中的某个子集并将其传递给模块。通过这种方式,模块可以多次实例化,每次使用不同的配置。

例如某个应用程序可以根据不同的目的使用不同的 cache 配置:

1
2
3
4
5
6
7
cache:
cache1:
max-items: 100
item-size: 64
cache2:
max-items: 200
item-size: 80

我们可以给模块直接传递 cache 的名称,例如 NewCache("cache1"),但是这种方式在访问 key 时需要进行额外的连接,而且和全局配置也无法隔开。更好的方法是直接传递给 Viper 某个配置的子集:

1
2
3
4
5
6
cache1Config := viper.Sub("cache.cache1")
if cache1Config == nil { // Sub returns nil if the key cannot be found
panic("cache configuration not found")
}

cache1 := NewCache(cache1Config)

在 NewCache 函数内部可以直接访问 max-itemsitem-size keys:

1
2
3
4
5
6
func NewCache(v *Viper) *Cache {
return &Cache{
MaxItems: v.GetInt("max-items"),
ItemSize: v.GetInt("item-size"),
}
}

这样我们的代码更加容易测试,也更易于使用,因为它和全局配置结构解耦了。

反序列化

你也可以将所有或特定的值反序列化到 struct、map 等。Viper 提供了两个方法:

  • Unmarshal(rawVal any) : error
  • UnmarshalKey(key string, rawVal any) : error

例如:

1
2
3
4
5
6
7
8
9
10
11
12
type config struct {
Port int
Name string
PathMap string `mapstructure:"path_map"`
}

var C config

err := viper.Unmarshal(&C)
if err != nil {
t.Fatalf("unable to decode into struct, %v", err)
}

如果需要反序列化的配置本身就包含 .(默认的 key 分隔符),你可以修改分隔符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
v := viper.NewWithOptions(viper.KeyDelimiter("::"))

v.SetDefault("chart::values", map[string]any{
"ingress": map[string]any{
"annotations": map[string]any{
"traefik.frontend.rule.type": "PathPrefix",
"traefik.ingress.kubernetes.io/ssl-redirect": "true",
},
},
})

type config struct {
Chart struct{
Values map[string]any
}
}

var C config

v.Unmarshal(&C)

Viper 也支持反序列化到嵌套的结构体中:

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
/*
Example config:

module:
enabled: true
token: 89h3f98hbwf987h3f98wenf89ehf
*/
type config struct {
Module struct {
Enabled bool

moduleConfig `mapstructure:",squash"`
}
}

// moduleConfig could be in a module specific package
type moduleConfig struct {
Token string
}

var C config

err := viper.Unmarshal(&C)
if err != nil {
t.Fatalf("unable to decode into struct, %v", err)
}

Viper 内部是使用 mapstructure 来反序列化值的,所以可以看到该库所使用的 mapstructure tag。

自定义格式解码

Viper 通过使用 mapstructure decode hooks 来支持自定义格式解码,具体可以参考Decoding custom formats with Viper

序列化成字符串

有时你需要将 viper 中保存的配置序列化成字符串,而不是写入文件中。你可以使用你喜欢格式的序列化器将 AllSettings() 返回的配置进行序列化:

1
2
3
4
5
6
7
8
9
10
11
12
13
import (
yaml "gopkg.in/yaml.v2"
// ...
)

func yamlStringSettings() string {
c := viper.AllSettings()
bs, err := yaml.Marshal(c)
if err != nil {
log.Fatalf("unable to marshal config to YAML: %v", err)
}
return string(bs)
}

Viper 还是 Vipers

viper 包开箱即用,不需要配置或初始化就能直接使用 viper。绝大多数应用程序都希望有一个集中的仓库来管理它们的配置信息,viper 包就是这样做的,这类似于单例模式。以上的实例也都是以 单例风格 演示如何使用 viper。

你可以在应用程序中创建多个 viper 实例,每个 viper 实例都有自己唯一的一组配置和值。每个 viper 实例可以从不同的配置文件、K/V 存储等读取配置。Viper 包提供的所有函数都有对应的方法(操作在某个 viper 实例上)。

例如:

1
2
3
4
5
6
7
x := viper.New()
y := viper.New()

x.SetDefault("ContentDir", "content")
y.SetDefault("ContentDir", "foobar")

//...

当使用多个 viper 实例时,由用户来跟踪不同的 viper 实例。

最后需要注意,viper 不是并发安全的。对某个 viper 的实例的并发读写,需要自己做同步操作

简单示例

当前目录下存在 test.yaml 配置文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
languages:
-
index: 1
name: go
website: https://go.dev/
-
index: 2
name: python
website: https://www.python.org/
-
index: 3
name: rust
website: https://www.rust-lang.org/

如下 go 程序用于对该配置文件进行读取并反序列化为配置结构体:

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
package main

import (
"fmt"

"github.com/spf13/viper"
)

type LanguagesConfig struct {
Languages []struct {
Index int
Name string
Website string
}
}

func main() {
viper.SetConfigName("test")
viper.SetConfigType("yaml")
viper.AddConfigPath(".")

err := viper.ReadInConfig()
if err != nil {
panic(fmt.Errorf("read config file error: %w", err))
}

var languages LanguagesConfig
viper.Unmarshal(&languages)

for _, l := range languages.Languages {
fmt.Println(l.Index, l.Name, l.Website)
}

}

Reference