提示:本节课最终代码为:feature/s05。
上一节,我们 使用 cobra
创建了一个可以打印 Hello MiniBlog!
的程序,本节课,我们基于上一节的代码,给 miniblog 添加配置读取功能。那么如何添加配置读取功能呢?这里,我会跟你分享下我在开发 Go 项目时的思考过程。根据我的思考过程,带你一步一步实现配置读取功能。
如何选择配置读取功能?
上一节,我介绍了配置的读取方式,那么我们首先需要选择一种优雅的读取方式。要选择读取方式,就要明白读取哪些配置。
通过上一节课的学习,我们知道,对于一个 Go 应用程序,通常需要解析以下类别的配置:命令行选项、命令行参数、配置文件。我们要实现的 Go 项目是一个 Web 服务,而非一个命令行工具,不需要考虑读取命令行参数这类场景,其需要的配置内容都可以通过命令行选项或者配置文件加载到程序中。所以,对于 miniblog,我们需要实现:命令行选项、配置文件 2 种配置读取方式。
提示:命令行工具可能会有子命令,例如
kubectl create
,其中create
是一个命令行参数。
对于一个配置项,我们既可以通过命令行选项,又能够通过配置文件来读取,二者其实是一个彼此可取代的关系,这里我们需要作出取舍。我更倾向于所有的配置都通过配置文件来读取,原因如下:
配置文件更易部署: 我们可以将应用需要的所有配置,聚合在一个配置文件中,部署时,只需要部署、加载这个配置文件即可启动程序,不用配置一大堆命令行选项;
配置文件更易维护: 我们可以将应用需要的所有配置都保存在配置文件中,加上详细的配置说明,不需要的配置可以注释掉。一个具有全量配置项、详细说明的配置文件,更易于理解。并且在修改时,只需要修改配置项的值,而不需要修改配置项名称,更不易出错;
配置文件可以实现热加载能力: 应用程序监听配置文件的变更, 有变更时重新加载程序配置,可以实现配置热加载能力;
配置层次表达更清晰: 命令行参数无法直接表达“层次”,但配置文件可以。层次化的配置表达,更易于理解。因为具有“层次”,所以配置文件也可以表达更复杂的配置内容;
新增一个配置项方便: 新增一个配置项,多数情况下只需要在配置文件中,加一行配置即可,不需要修改源码。
当然,命令行选项,相较于配置文件也有优势:
零成本获知应用程序的配置项: 只需要执行
miniblog -h
就能够知道miniblog
有哪些配置项,以及配置项的说明和默认值;可快速启动程序: 如果应用程序配置项比较少,可以不用编写配置文件,仅通过指定命令行选项即可快速启动程序,例如:
miniapp -addr=:8080
;维护成本低: 不需要维护额外的配置文件(仅限于参数很少的情况下);
可以设置默认值: 通过给命令行选项,设置默认值,可以在程序启动时,只设置需要关心的命令行选项即可启动程序(实际上,在中大型的应用程序中,很多默认值是无法直接使用的,例如:存储相关配置);
可以与 bash 脚本很好的交互: 例如变量替换、变量引用等,例如
miniapp -addr=${ADDR}
。
所以,我们可以得出结论:配置项少的时候(例如:5 个以内),可以从命令行选项中读取。参数较多的时候适合从配置文件读取。考虑到一个应用程序,即使刚开始配置项较少,随着不断的迭代,配置项可能越来越多,使用命令行选项来配置会越来越复杂。这里建议,如果是一个企业级应用,建议一开始就使用配置文件来配置你的应用程序。
提示:当然还有一种方案:命令行选项和配置文件同时使用,设置配置读取优先级,例如:命令行选项 > 配置文件,但根据我的实际开发经验,这种方案其实并不实用,因为需要为较少使用的场景,实现两套配置读取机制。
如果使用配置文件,那么配置文件有多重格式,例如:JSON、YAML、TOML、INI 等,这里强烈建议使用 YAML,理由如下:
YAML 语法简单、格式易读、程序易处理;
YAML 格式可以表达非常丰富、复杂的配置结构;
YAML 格式普适性高,新人零理解成本。
所以最终的结论:使用 YAML 格式的配置文件,来配置你的应用程序,并使用 viper
来读取配置。
如何编码实现配置读取功能?
上面,我们决定采用 YAML 格式的配置文件来配置应用,并且使用 viper 来读取配置文件。那么这部分,我就来展示如何编写代码实现配置文件读取。
学习已有配置文件读取代码示例
功能很明确,但是还是不知道怎么实现,怎么办?答案是 :学习并拷贝已有示例代码 -> 二次开发(优化、功能添加)。
提示:在实际开发中,当你不知道如何实现一个功能时,最高效、有效的开发方式是:先寻找类似的实现案例,然后学习理解、接着迁移代码,最后根据需求添加功能,并优化代码。
通过阅读 现代化的命令行框架-Cobra全解,我们知道可以通过 cobra-cli init --viper
生成一个通过 viper 来配置应用程序的 Demo 应用,例如:cobrademo。通过走读 cobrademo 应用,我们知道 cobrademo 应用加载配置的逻辑如下(为了方便展示,我隐藏掉了部分注释和代码):
var cfgFile string
var rootCmd = &cobra.Command{
Use: "cobrademo",
// ...
}
func Execute() {
// ...
}
func init() {
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.cobrademo.yaml)")
rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}
func initConfig() {
// ...
}
代码解读:
通过以下代码给 cobrademo
应用设置了命令行选项 --config
,该选项用来指定配置文件(默认值为""
):
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.cobrademo.yaml)")
可以通过运行 cobrademo -h
来查看 cobrademo
支持的命令行选项:
$ ./cobrademo -h
...
Flags:
--config string config file (default is $HOME /.cobrademo.yaml)
-h, --help help for cobrademo
...
通过 cobra.OnInitialize(initConfig)
设置了 cobrademo
在运行时执行的回调函数 initConfig
。通过 initConfig
函数名,我们也能猜到该函数用来初始化配置。
再来看下 initConfig
回调函数的实现:
func initConfig() {
if cfgFile != "" {
// Use config file from the flag.
viper.SetConfigFile(cfgFile)
} else {
// Find home directory.
home, err := os.UserHomeDir()
cobra.CheckErr(err)
// Search config in home directory with name ".cobrademo" (without extension).
viper.AddConfigPath(home)
viper.SetConfigType("yaml") viper.SetConfigName(".cobrademo")
}
viper.AutomaticEnv() // read in environment variables that match
// If a config file is found, read it in.
if err := viper.ReadInConfig(); err == nil {
fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
}
}
代码解读:
viper.SetConfigFile(cfgFile)
用来设置 viper 需要读取的配置文件(该配置文件通过--config
参数指定);通过
home, err := os.UserHomeDir()
获取用户主目录,通过viper.AddConfigPath(home)
将用户主目录加入到配置文件的搜索路径中;通过
viper.SetConfigType("yaml")
设置配置文件格式;通过
viper.SetConfigName(".cobrademo")
设置配置文件名;通过
viper.AutomaticEnv()
设置 viper 查找是否有跟配置文件中相匹配的环境变量,如果有,则将该环境变量的值设置为配置项的值;通过
viper.ReadInConfig()
读取设置的配置文件。
所以 initConfig
的执行逻辑为:如果指定了 cfgFile
则直接读取该配置文件,如果没有设置 cfgFile
则在用户主目录中搜索名为 .cobrademo.yaml
的配置文件,如果找到则读取。如果 cfgFile
为空,并且在用户主目录下没有找到 .cobrademo.yaml
配置文件,则调用 viper.ReadInConfig()
读取配置文件时报错。
所以,不难总结出最终的配置文件读取逻辑为:
执行
cobrademo --config=cobrademo.yaml
命令:cobrademo
从--config
命令行选项中读取配置文件:cobrademo.yaml
,并将文件路径保存在cfgFile
全局变量中;然后执行initConfig
函数,initConfig
函数根据配置文件格式,将cobrademo.yaml
文件中的内容读取到 viper 中(其实是保存在了 viper 包的全局变量var v *Viper
中);执行
cobrademo
命令:cobrademo
尝试从--config
命令行选项中读取配置文件,发现--config
没有被设置,所以使用默认值""
;然后执行initConfig
函数,initConfig
函数判断cfgFile
值为空,然后尝试从$HOME
目录下搜索.cobrademo.yaml
文件,如果找到则将文件中的内容读取到 viper 中,如果找不到则读取失败,报错返回。
这里需要注意:通过 cobra
包构建的应用,在运行时,是先读取命令行选项,再运行通过 cobra.OnInitialize
设置的回调函数。
上述代码,我们设置了默认读取的路径和配置文件,通过这种设置默认值的方式,可以在我们开发测试,运行 cobrademo
命令时,不需要添加命令行参数,以此简化启动操作。但在生产环境,不建议使用这种不可见的配置加载方式,更建议使用 cobrademo --config=cobrademo.yaml
这种方式,明确指定加载的配置文件路径。
迁移 Demo 代码
将 cobrademo
代码示例中 viper 读取配置文件相关代码迁移到 miniblog 项目中,迁移后的代码见:feature/s03。
迁移后,我们仍然根据项目自身的情况做了一些小变更:
--config
命令行选项添加短选项名c
,修改--config
选项描述:
cmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "The path to the miniblog configuration file. Empty string for no configuration file.")
添加短选项,可以稍稍提高启动时的参数输入体验。
- 将
initConfig
实现放在helper.go
文件中。
这样做,主要是为了保持 miniblog.go
文件中程序主逻辑清晰。在实际开发中,helper.go
文件一般用来存放一些工具类的函数/方法,类似 util.go
。
- 通过以下代码设置了默认查询的文件名:
miniblog.yaml
。
const (
// defaultConfigName 指定了 miniblog 服务的默认配置文件名.
defaultConfigName = "miniblog.yaml"
)
// initConfig 设置需要读取的配置文件名、环境变量,并读取配置文件内容到 viper 中.
func initConfig() {
if cfgFile != "" {
// 从命令行选项指定的配置文件中读取
viper.SetConfigFile(cfgFile)
} else {
// ...
// 配置文件名称(没有文件扩展名)
viper.SetConfigName(defaultConfigName)
}
// ...
}
将默认文件名保存在 defaultConfigName
常量中,主要是为了后面的修改。
二次开发示例代码
二次开发后的代码见:feature/s04。二次开发的内容如下:
- 通过
viper.AddConfigPath(filepath.Join(home, recommendedHomeDir))
将配置文件搜索路径从$HOME
变更到$HOME/.miniblog
目录,因为搜索$HOME
范围有点大,也没必要; - 通过
viper.AddConfigPath(".")
把当前目录加入到配置文件的搜索路径中。在当前目录中搜索配置文件,在开发测试时很方便; - 通过
viper.SetEnvPrefix("MINIBLOG")
设置读取的环境变量的前缀为MINIBLOG
。设置一个独有的环境变量前缀,可以有效避免环境变量命名冲突; - 通过
replacer := strings.NewReplacer(".", "_")
和viper.SetEnvKeyReplacer(replacer)
两行设置,将viper.Get(key)
key 字符串中.
和-
替换为_
; - 通过以下代码,当配置文件不存在或者内容读取错误时,打印错误信息,方便排障:
if err := viper.ReadInConfig(); err != nil {
fmt.Fprintln(os.Stderr, err)
}
使用 viper 读取配置文件内容
可以使用 viper 提供的各种读取方法,读取 viper 中保存的配置内容,例如,我们可以在 run() 函数中,添加以下代码,打印读取的配置文件内容:
// run 函数是实际的业务代码入口函数.
func run() error {
// 打印所有的配置项及其值
settings, _ := json.Marshal(viper.AllSettings())
fmt.Println(string(settings))
// 打印 db -> username 配置项的值
fmt.Println(viper.GetString("db.username"))
return nil
}
代码解读:
- 使用
viper.AllSettings()
函数返回所有的配置内容; - 使用
viper.Get<Type>(key)
获取指定 key 的配置值,使用.
代表 YAML 文件中的一个缩进,例如db.username
实际取的是username
的值:
db:
host: 127.0.0.1 # MySQL 机器 IP 和端口,默认 127.0.0.1:3306
username: miniblog # MySQL 用户名(建议授权最小权限集)
测试配置读取功能
新建一个测试配置文件 configs/miniblog.yaml
,内容如下:
# MySQL 数据库相关配置
db:
host: 127.0.0.1 # MySQL 机器 IP 和端口,默认 127.0.0.1:3306
username: miniblog # MySQL 用户名(建议授权最小权限集)
password: miniblog1234 # MySQL 用户密码
database: miniblog # miniblog 系统所用的数据库名
max-idle-connections: 100 # MySQL 最大空闲连接数,默认 100
max-open-connections: 100 # MySQL 最大打开的连接数,默认 100
max-connection-life-time: 10s # 空闲连接最大存活时间,默认 10s
log-level: 4 # GORM log level, 1: silent, 2:error, 3:warn, 4:info
提示:给每个配置项添加详细的说明,是一个开发者开发素养的体现。
编译并运行 miniblog
:
$ _output/miniblog -c configs/miniblog.yaml
Using config file: configs/miniblog.yaml
{"db":{"database":"miniblog","host":"127.0.0.1","log-level":4,"max-connection-life-time":"10s","max-idle-connections":100,"max-open-connections":100,"password":"miniblog1234","username":"miniblog"}}
miniblog
可以看到,应用程序能够正确读取配置文件及其中配置项的值。
提示,这里记得修改 .air.toml
配置文件,添加 miniblog
运行时的参数:
args_bin = ["-c", "configs/miniblog.yaml"]
小结
本节课介绍了如何选择最佳的配置文件读取方法,并且介绍了如何给应用程序添加配置读取功能。viper 在配置文件加载和读取方面,提供了很多很强大的功能,本节课介绍的内容,只是最常见的方式。你可以阅读 viper 仓库中的 README 文件,以学习更多的使用方式。
另外,这里也给你布置一个小作业:了解 viper 自定义配置项格式实现方法,并实现编写一个示例代码进行练习。