Go应用构建工具(2)–viper
1. 概述
基本上所有的后端应用,都是需要用到配置项,可能小的项目配置项不多会选择命令行来传递,但是对于大项目来说,配置项可能会很多,全都用命令行传递那就麻烦死了,而且不好维护。
所以基本上都是会选择将配置项保存在配置文件中,在程序启动时加载和解析。
而Viper是Go生态中目前最受欢迎的配置相关的包,Viper能满足我们对配置的各种需求,能处理不同格式的配置文件。
viper支持:
- 设置默认值
- 从
JSON,TOMAL,YAML,HCL,envfile和java properties
等配置文件中读取配置信息 - 实时监控和重新读取配置文件(可选)
- 从环境变量中读取
- 从远程配置系统(etcd或Consul)中获取配置信息,并监控变化
- 从命令行中读取配置
- 从buffer中读取配置
- 显示配置值
由上面可知,viper支持从不同的位置读取配置,但不同的位置是具有不同的优先级的,优先级高的配置会覆盖掉优先级低的相同配置,以下是优先级的排序(从高到低):
- 显式调用Set方法设置
- 命令行flag
- 环境变量
- 配置文件
- key/value存储
- 默认值
注意:目前的Viper配置的Key是不区分大小写的。(目前正在讨论是否将这一选项设置为可选)
2. 使用Viper
- 读取配置文件
Viper读取配置文件,至少需要知道去哪里查找配置文件,它支持搜索多个路径,但当前一个Viper实例值支持单个配置文件;
Viper默认不配置任何搜索路径,将决策权交给应用程序;
Viper支持JSON,TOML,YAML,HCL,INI,envfile和java properties等配置文件
示例如下:
首先在当前目录下创建一个config目录,在config目录下新建一个config.yaml文件:
mysql:
database: persons
password: 123456
port: 3306
url: 127.0.0.1
user: root
redis:
port: 6379
url: 127.0.0.1
root:
loglevel: debug
name: app
port: 8080
package main
import (
"fmt"
"github.com/spf13/viper"
)
func main() {
viper.SetConfigName("config") // 指定配置文件名(没有扩展名)
viper.SetConfigType("yaml") // 如果配置文件没有扩展名,则需要指定配置文件的格式,让viper知道如何解析文件
viper.AddConfigPath("/etc/appname/") // 指定搜索路径
viper.AddConfigPath("$HOME/.appname") // 可以调用多次,指定多个搜索路径
viper.AddConfigPath("./config") // 当前工作目录下的config文件夹
// 也可以使用SetConfigFile直接指定(上面的都不需要了)
// viper.SetConfigFile("./config/config.yaml")
err := viper.ReadInConfig() // 查找并读取配置文件
if err != nil { // 处理错误
panic(fmt.Errorf("fatal error config file: %w", err))
}
fmt.Printf("redis.Port = %v\n", viper.Get("redis.port"))
}
这里有几个点要注意:
- Viper支持配置多个搜索路径,但是需要注意添加的顺序,viper会根据添加的路径顺序搜索,如果找到了则停止搜索。
- 如果直接使用
SetConfigFile
指定了配置文件路径和名字,必须显示带上文件扩展名,否则无法解析SetConfigName
设置的配置文件名是不带扩展名的,在搜索时viper会加上扩展名- 使用
SetConfigName
时,如果同一个目录下存在两个同名,但扩展名不一样的配置文件,比如config目录下存在:config.json和config.yaml,viper加载时只会是config.json文件;如果设置的扩展名是yaml,那么则是将config.json文件按照yaml解析
对于第4点的原因,跟踪了下源码,有一个切片,viper在加载时会按顺序匹配:var SupportedExts = []string{"json", "toml", "yaml", "yml", "properties", "props", "prop", "hcl", "tfvars", "dotenv", "env", "ini"}
func (v *Viper) searchInPath(in string) (filename string) {
v.logger.Debug("searching for config in path", "path", in)
for _, ext := range SupportedExts {
v.logger.Debug("checking if file exists", "file", filepath.Join(in, v.configName+"."+ext))
if b, _ := exists(v.fs, filepath.Join(in, v.configName+"."+ext)); b {
v.logger.Debug("found file", "file", filepath.Join(in, v.configName+"."+ext))
return filepath.Join(in, v.configName+"."+ext)
}
}
if v.configType != "" {
if b, _ := exists(v.fs, filepath.Join(in, v.configName)); b {
return filepath.Join(in, v.configName)
}
}
return ""
}
对于配置文件没找到的错误处理也可以像这样子:
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
}
}
// Config file found and successfully parsed
Note:
从1.6版本起配置文件可以不带有扩展名,通过编程方式指定。譬如对于home目录下的配置文件是没有扩展名的,比如.bashrc
- 写入配置文件
通常我们使用配置文件大部分是用于读取配置,但是有时候希望保存运行时所做的修改,因此Viper提供了以下几个方法:
WriteConfig
:将当前的viper配置写入预定义的路径(如果路径存在),这个方法会覆盖当前的配置文件(如果文件存在),预定义路径不存在则会报错SafeWriteConfig
:与WriteConfig
功能相似,不同之处是如果配置文件存在则不会覆盖WriteConfigAs
:将当前的viper配置写入到指定的文件路径,如果文件存在则会覆盖SafeWriteConfigAs
:与WriteConfigAs
功能相似,不同之处是如果文件不存在不会覆盖
Note:带有safe的方法不会覆盖文件,当文件不存在时会新建
示例如下:
viper.WriteConfig() // 将当前配置写入“viper.AddConfigPath()”和“viper.SetConfigName”设置的预定义路径
viper.SafeWriteConfig()
viper.WriteConfigAs("/path/to/my/.config")
viper.SafeWriteConfigAs("/path/to/my/.config") // 因为该配置文件写入过,所以会报错
viper.SafeWriteConfigAs("/path/to/my/.other_config")
- 建立默认值
Viper支持value默认值(key不需要默认值)。
示例如下:
viper.SetDefault("ContentDir", "content")
viper.SetDefault("LayoutDir", "layouts")
viper.SetDefault("Taxonomies", map[string]string{"tag": "tags", "category": "categories"}
- 监听和重新读取配置文件
viper支持在运行时实时读取配置文件(就是热加载)。
只需要简单的告诉viper实例watchConfig即可监听,另外也可以提供一个回调方法给viper,用于每次有变动调用。
注意:在调用
WatchConfig()
之前需要确认已经添加了配置文件的搜索路径
以下是示例:
viper.OnConfigChange(func(e fsnotify.Event) {
fmt.Println("Config file changed:", e.Name)
})
viper.WatchConfig()
PS:回调方法里的参数fsnotify.Event只有改动的文件名和操作两个字段,感觉获取了也没啥用?
不太建议使用,比如修改了端口号,服务没有重启,服务还是监听在老的端口上,反而造成混淆
- 从io.Reader中读取配置
viper预先定义了一些配置源,比如配置文件,环境变量,flag和远程kv存储,但是我们还可以不使用这些,使用自己的配置源提供给viper。
具体看下示例(感觉用的也不多):
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"
-
显示设置配置
viper通过viper.Set()
函数来进行显示设置配置,这种方式的优先级是最高的!
比如:viper.Set("redis.port",6666)
-
注册和使用别名
别名允许一个值对用多个键
viper.RegisterAlias("loud", "Verbose") // 给Verbose注册一个别名loud
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支持环境变量,提供了以下5个方法来操作环境变量:
AutomaticEnv()
BindEnv(string...): error
SetEnvPrefix(string)
SetEnvKeyReplacer(string...) *strings.Replacer
AllowEmptyEnv(bool)
warning 注意
需要注意一点,使用环境变量时,viper是区分大小写的
viper提供了一种机制保证了环境变量是唯一的:SetEnvPrefix
,通过SetEnvPrefix
,可以告诉viper在读取环境变量时加上指定的前缀,BindEnv
和AutomaticEnv
也会使用这个前缀。
BindEnv
有一个或多个参数,第一个参数是key名称,剩下的参数代表环境变量绑定到这个key,环境变量名称区分大小写;
如果环境变量的名称没有提供,那么viper会自动的认为环境变量匹配以下规则:prefix + _ + key name
全大写,比如前缀为viper,BindEnv第一个参数为username,那么绑定的环境变量就是:VIPER_USERNAME;
如果提供了环境变量的名称(第二个参数),那么viper不会自动添加前缀,比如第二个参数是id,那么viper将会查找环境变量ID
一个重要的事情需要注意:每次访问环境变量的值时都会读取它,viper并不会在调用BindEnv时将值固定
AutomaticEnv
是一个强大的助手,特别是配合SetEnvPrefix
一起使用时。
当调用viper.Get时,viper会随时检查环境变量,它遵循这个规则:它会检查环境变量名称是否与key的大写名称匹配(如果key有设置了前缀则带上前缀)
SetEnvKeyReplacer
允许我们使用strings.Replacer
在一定程度上去重写环境变量的Key,比如当我们希望使用Get方法时用-
分隔,但环境变量是以_
分隔的,这种情况会比较有用。
比如,我们有环境变量USER_NAME
=zhangsan,但是我们在调用viper.Get时希望是这样调用:viper.Get("user-name),那我们就可以这样子调用方法:
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) //会用_替换-
viper.Get("user-name")
一般来说空的环境变量会被认为是未设置的,并返回到下一个配置源,如果要将空的环境变量认为是已设置的,那么就可以使用AllowEmptyEnv
方法
os.Setenv("VIPER_USER_NAME", "zhangsan")
viper.AutomaticEnv()
viper.SetEnvPrefix("VIPER")
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
viper.BindEnv("user-name")
fmt.Println("user-name = ", viper.Get("user-name"))
- 使用flag
viper可以绑定flag,确切的说,viper支持在cobra
库中使用pflag。
与BindEnv
类似,当调用绑定方法时不会设置值,而是在访问它时才会设置,这意味我们可以根据需要尽早绑定,即使是在init方法中。
对于单个flag,使用BindPflag()
方法,比如:
var port = pflag.Int("port", 8888, "Input redis port")
func main() {
// 读取配置文件
// viper.SetConfigFile("./config/config.yaml") // 指定配置文件路径
viper.SetConfigName("config") // 配置文件名称(无扩展名)
viper.SetConfigType("yaml") // 如果配置文件的名称中没有扩展名,需要此配置
viper.AddConfigPath("/home") // 设置配置文件搜索路径
viper.AddConfigPath("./config") // 设置多个搜索路径
viper.SetDefault("redis.port", 65536)
// viper.Set("redis.port", 6666)
// 查找并读取配置文件
err := viper.ReadInConfig()
if err != nil {
panic(fmt.Errorf("Fatal error config file: %w", err))
}
pflag.Parse()
viper.BindPFlag("redis.port", pflag.CommandLine.Lookup("port"))
redisPort := viper.Get("redis.port")
fmt.Println("redis.port = ", redisPort)
}
也可以绑定一组现有的pflags(flag.FlagSet)
var port = pflag.Int("redis.port", 8888, "Input redis port")
var url = pflag.String("redis.url", "127.0.0.1", "Input redis url")
func main() {
// 读取配置文件
viper.SetConfigName("config") // 配置文件名称(无扩展名)
viper.SetConfigType("yaml") // 如果配置文件的名称中没有扩展名,需要此配置
viper.AddConfigPath("/home") // 设置配置文件搜索路径
viper.AddConfigPath("./config") // 设置多个搜索路径
viper.SetDefault("redis.port", 65536)
// viper.Set("redis.port", 6666)
// 查找并读取配置文件
err := viper.ReadInConfig()
if err != nil {
panic(fmt.Errorf("Fatal error config file: %w", err))
}
pflag.Parse()
viper.BindPFlags(pflag.CommandLine)
redisPort := viper.Get("redis.port")
redisUrl := viper.GetString("redis.url")
fmt.Println("redis.port = ", redisPort)
fmt.Println("redis.port = ", redisUrl)
}
执行如下:
lucas@Z-NB-0406:~/workspace/test/pflagtest$ go run my_viper.go
redis.port = 63793
redis.port = 127.0.0.1
lucas@Z-NB-0406:~/workspace/test/pflagtest$ go run my_viper.go --redis.port 6666 --redis.url 10.2.3.222
redis.port = 6666
redis.port = 10.2.3.222
在viper中使用pflag并不会影响其他包使用标准库的flag包,pflag包可以通过导入来处理flag包定义的flag,这是通过pflag包提供的AddGoFlagSet()
实现的(pflag那篇文章有介绍)
-
viper从远处kv存储获取(这部分目前我没etcd/Consul环境,暂时没去学习)
-
从Viper中获取值
viper提供了一些方法去获取值,这些方法根据值的类型区分,主要存在以下方法:
Get(key string) interface{}
Get<Type>(key string) <Type>
,比如GetString(),GetBool()
AllSettings() map[string]interface{}
IsSet(key string) bool
需要注意的一个点是:每一个Get方法当找不到key时都会返回对应类型的零值,要检查key是否存在,可以使用
IsSet()
方法
- viper的读取key值的几种情况
- 访问嵌套的key
比如有以下JSON配置文件:
{
"host": {
"address": "localhost",
"port": 5799
},
"datastore": {
"metric": {
"host": "127.0.0.1",
"port": 3099
},
"warehouse": {
"host": "198.0.0.1",
"port": 2112
}
}
}
viper支持使用.
来嵌套访问,比如:GetString("datastore.metric.host")
这遵循上面建立的规则,搜索路径将按优先级遍历其余配置,直到找到为止,比如在当前配置文件中没找到,就会继续向后查找,比如查找默认值
比如,在上面的配置文件中,同时定义了datastore.metric.host
和datastore.metric.port
(可以被覆盖),如果在默认值中定义了datastore.metric.protocol
,viper也会找到它
然而,如果datastore.metric
被直接覆盖了(flag,环境变量或者调用Set()等等),那么它的所有子key都会变成未定义,它们被高优先级别的配置遮盖了。
viper还可以通过使用number访问数组索引,比如:
{
"host": {
"address": "localhost",
"ports": [
5799,
6029
]
},
"datastore": {
"metric": {
"host": "127.0.0.1",
"port": 3099
},
"warehouse": {
"host": "198.0.0.1",
"port": 2112
}
}
}
使用: viper.GetInt("host.ports.1") // 返回6029
最后,如果存在于分隔的键路径匹配的键,则直接返回这个键的值,比如:
{
"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
}
}
}
GetString("datastore.metric.host") // returns "0.0.0.0"
- 提取子树
在开发可重用模块时,提取配置的子集并将其传递给模块通常是有用的。
通过这种方式,模块可以使用不同的配置进行多次实例化。
比如,一个应用可能使用多个不同的cache配置
cache:
cache1:
max-items: 100
item-size: 64
cache2:
max-items: 200
item-size: 80
假如我们现在有一个NewCache方法:
func NewCache(v *Viper) *Cache {
return &Cache {
MaxItems: v.GetInt("max-items"),
ItemSize: v.GetInt("item-size"),
}
}
那么我们就可以通过提取子树获取对应配置传递给这个NewCache方法了:
cache1Config := viper.Sub("cache.cache1")
if cache1Config == nil {
panic("cache configuration not found")
}
cache1 := NewCache(cache1Config)
- 反序列化
viper支持将配置解析到struct或map等等,可以使用以下两个方法:Unmarshal(rawVal interface{}) error
UnmarshalKey(key string, rawVal interface{}) error
比如:
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)
}
如果想要解析的那些键刚好包含了.
(默认的键分隔符)的配置,就需要修改分隔符了:
v := viper.NewWithOptions(viper.KeyDelimiter("::"))
v.SetDefault("chart::values", map[string]interface{}{
"ingress": map[string]interface{}{
"annotations": map[string]interface{}{
"traefik.frontend.rule.type": "PathPrefix",
"traefik.ingress.kubernetes.io/ssl-redirect": "true",
},
},
})
type config struct {
Chart struct{
Values map[string]interface{}
}
}
var C config
v.Unmarshal(&C)
viper还支持解析到嵌入的结构中:
/*
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使用
github.com/mitchellh/mapstructure
这个包来解析值,所以我们需要将viper反序列化到自定义的结构体变量中时,要使用mapstructure的tags
- 序列化为字符串
有时候我们可能需要将viper的所有配置序列化到一个字符串中,而不是将他们写入文件。我们也可以选择自己喜欢的格式序列化AllSettings()
返回的所有配置。
示例如下:
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)
}