我们的基础RPC服务已经正常运行,我们再来看下一个特性:配置文件的加载。
首先,我们要正确地认识到配置文件的重要性:在程序交付后,变更代码的成本很大;相对而言,变更配置文件的成本就比较小。但有的同学又走了另一个极端,也就是将大量的逻辑放入到配置文件中,导致配置文件膨胀,本质上就是将部分本应在代码中维护的内容转移到了配置文件,导致配置文件也很难维护。
今天,我们先将重点放到加载配置文件库的技术选型,顺便分享一些常见的问题。
一个基础的加载配置文件示例
在Go
语言中,用官方库就能快速实现配置文件的加载,下面就是一个简单的代码实现:
1 | b, err := ioutil.ReadFile("config.json") |
关键的实现分为两块:
- 读取文件中的数据
- 将数据解析到Go程序的对象中,作为可识别的数据结构,这里指定了数据类型为
json
v0.2.0:实现加载静态配置文件
项目链接 https://github.com/Junedayday/micro_web_service/tree/v0.2.0
目标
从配置文件中解析数据到程序中,并具备更高的可读性和扩展性。
关键技术点
- 命令行参数与配置文件的差异
- github.com/spf13/viper的介绍
- 使用viper库的推荐方式
目录构造
github.com/spf13/viper
中存在一个全局变量var v *Viper
(点击查看),如果我们调用默认的viper包,其实就是将参数解析到这个全局变量中。
在具体的项目中,更推荐的方式是将这个变量保存到内部项目中,作为一个项目中的全局变量,所以我们会新建一个viper.New()
。配置参数会被全局调用,为了保证不会发生循环依赖,我们就需要一个专门的package
来保存这个全局变量,这里对应项目中的internal/config/viper.go
。
1 | --- micro_web_service 项目目录 |
1.命令行参数与配置文件的差异
命令行参数类似于./demo --config=a.yaml --http_port=8080 --grpc_port=8081
,Go
语言中自带flag
包可供解析,开源的有pflag
和相关的扩展工具,如cobra
。
而配置文件则是将参数从文件解析到内存中,一般用读取文件+反序列化工具来使用。
同样是解析参数到程序里,我们该选择哪种方案呢?我们从可读性和可维护性来对比下:
- 可读性:命令行参数是扁平化的,可读性远不如格式化后的配置文件
- 可维护性:配置文件增加了一个维护项,但成本不高
所以,我个人倾向于的方案是:
- 命令行参数:用于维护极少量关键性的参数,如配置文件的路径
- 配置文件:维护绝大多数的参数
在某些极端的场景中,比如提供一个纯二进制文件作为工具,那不得不把所有配置参数都放入到命令行参数中。这并不是我们微服务框架要讨论的场景。所以,接下来我们重点讨论配置文件的加载。
关于pflag相关的内容,在后续程序复杂到一定阶段后会引入。
2.github.com/spf13/viper的介绍
对比上面的方案,我们来看一个业内使用最广的Go语言配置加载库github.com/spf13/viper
的实现,对应的代码如下:
1 | viper.SetConfigName("config") // config file name without file type |
而在获取配置文件时,又采用key-value形式的语法:
1 | viper.GetInt("server.grpc.port") |
详细的特性我会在Go语言技巧系列里说明,今天我们聚焦于工程侧的宏观特性,来聊聊这个库的优劣势:
可选的参数序列化
从viper
库的源码中(点击跳转),我们可以看到它支持多种本地文件类型与远程k-v数据库:
1 | // SupportedExts are universally supported extensions. |
我们先忽略远程的存储,先看一下最常用的几个序列化的库:
- JSON: 官方自带的
encoding/json
- TOML: 开源的
github.com/pelletier/go-toml
- YAML: 官方推荐的
gopkg.in/yaml.v2
- INI:官方推荐的
gopkg.in/ini.v1
在这四种技术选型时,我个人倾向于选择JSON
和YAML
。进一步斟酌时,虽然JSON
的适用范围最广,但当配置文件复杂到一定程度时,JSON
格式的配置文件很难通过换行来约束,当存在大量的嵌套时,可读性很差。所以,我个人比较推荐使用YAML
格式的配置文件,一方面通过强制的换行约束,可读性很棒;另一方面云原生相关技术大量使用了YAML
作为配置文件,尤其是Kubernetes
中各种定义。
例如,我们将服务的端口改造到配置文件里,就成了:
1 | server: |
对应的Go语言代码为:
1 | viper.GetInt("server.http.port") |
可扩展的获取参数方法
viper
库提供的获取参数方式为viper.Get{type}("{level1}.{level2}.{level3}...")
的格式。随着配置文件的扩大,也只需新增Get方法即可。
从获取参数的方法来看,它的设计分为3种:
- 基本类型,直接提供Get{具体类型}的方法,如
GetInt
,GetString
; - 任意类型,提供
Get(key string) interface{}
,自行转化 - 复杂类型的反序列化,提供
UnmarshalKey
等方法,更方便地获取复杂结构
我个人建议各位只使用第一类的方法,将配置文件这个模块做到最简化。毕竟,配置文件的复杂化很容易引入各种问题,占用大量的排查故障的时间。如果你的系统必须引入一套非常复杂的配置,那么我更建议将它独立成一个专门的服务,用来维护这套配置。
3.使用viper库的推荐方式
如果你仔细地阅读viper相关的代码,你会发现它有很多超酷的特性。但今天,我想告诉各位:请克制地使用进阶的特性,最棒的特性往往是简洁的。
我们对照着官方的README文件中介绍的特性进行讲解。
尽量避免手动设置的参数值
用viper.SetDefault
函数可以给某些参数设置默认值,如果只是少数的几个参数还是很容易维护的。但如果设置的值过多了,就会给阅读代码的人带来困扰:这个参数是来自配置文件,还是在代码某处手动设置的?也就是存在二义性,增加了排查问题的复杂度。
明确配置文件的来源
1 | viper.AddConfigPath("/etc/appname/") // path to look for the config file in |
viper
支持多个配置文件的路径,这虽然带来了便利性,但如果多个文件路径中都存在配置文件,那究竟以哪个为准?这也是一个二义性的问题,所以我个人更建议只设置一个,而这个路径由flag
传入。
静态配置与动态配置的分离
viper
提供了接口viper.WatchConfig()
,可以监听文件的变化,然后做相应的调整。这个特性很酷,我们可以用它实现热加载。但这个特性很容易让人产生混淆:例如发生了某个BUG,如何确定当时的配置文件情况?其实,这就需要引入一定的版本管理机制。
我更建议采用静态配置和动态配置分离的方案,也就是配置文件负责静态的、固定的配置,一旦启动后只加载一次;而动态的配置放在带版本管理的配置中心里,具备热加载的特性。
所以,我不太建议在配置文件这里引入监听文件变化的特性。
核心代码示例
main.go
从flag
中解析出配置文件路径,传到config
包中用于解析。
1 | var configFilePath = flag.String("c", "./", "config file path") |
internal/config/viper.go
加载的代码并不多,尽量保证配置信息的简洁易懂。
1 | // 全局Viper变量 |
配置使用方
1 | config.Viper.GetInt("server.grpc.port") |
使用viper库的注意事项
在使用viper
获取配置时,我们需要手动组装key
,也就是"{level1}.{level2}.{level3}..."
这种形式。这时,我们只能对照着原始配置文件逐个填充字段,一不小心填错、就会发生奇怪的问题。而如果采用的是将配置文件解析到结构体的方法,就能很容易地避免这个问题。
考虑到扩展性,官方库推荐的是手动组装key的方式,所以需要大家在认真查看这个key
是否有效。
总结
加载静态配置文件是一个很常见的功能,viper
提供了一套完整方案,兼具简洁和扩展性;与此同时,我们要学会克制,不要看到了viper
中提供的各种特性、就想着应用到实际项目中,也就是常说的:手里拿了个锤子,看啥都是钉子。
Github: https://github.com/Junedayday/code_reading
Blog: http://junes.tech/
Bilibili: https://space.bilibili.com/293775192
公众号: golangcoding