你好,我是轩脉刃。
上一节课,我们已经定义好了配置文件服务的接口,这节课就来实现这些接口。先来规划配置文件服务目录,按照上一节课分析的,多个配置文件按类别放在不同配置文件夹中,在框架文件夹中,我们将配置文件接口代码写在框架文件夹下的contract/config.go文件中,将具体实现放在provider/config/目录中。
不过设计优于实现,动手之前我们先思考下实现这个接口要如何设计。
首先,要读取一下配置文件夹中的文件。上节课说了,最终的配置文件夹地址为,应用服务的 ConfigFolder 下的环境变量对应的文件夹,比如 ConfigFolder/development。但是还有一个问题,就是配置文件的格式的选择。
目前市面上的配置文件格式非常多,但是很难说哪种配置文件比较好,完全是不同平台、不同时代下的产物。比如Windows开发的配置常用INI、Java开发配置常用Properties,我这里选择了使用YAML格式。
YAML格式是在Golang的项目中比较通用的一种格式,比如Kubernetes、Docker、Swagger等项目,都是使用YAML作为其配置文件的。YAML配置文件除了能表达基础类型比如string、int、float 之外,也能表达复杂的数组、结构等数据类型。
目前最新的YAML版本为1.2版本,配置的说明文档在官网上。它提供多种语言的解析库,其中go-yaml 就是非常通用的一个Go解析库,这个库的封装性非常好。
我们通过第一节课讲的快速阅读一个库的命令 go doc github.com/go-yaml/yaml |grep '^func'
,可以看出来这个库对外提供的方法非常明确,一共三个方法:
// 序列化
func Marshal(in interface{}) (out []byte, err error)
// 反序列化
func Unmarshal(in []byte, out interface{}) (err error)
// 严格反序列化
func UnmarshalStrict(in []byte, out interface{}) (err error)
我们选择Unmarshal的函数进行反序列化,因为这样能提高框架对配置文件的容错性和易用性。好,读取配置文件的格式和对应工具搞定,下一步就是想清楚怎么替换了。
在上一节课说的环境变量服务中,存放了包括.env中设置的环境变量,那么我们自然会希望使用上这些环境变量,把配置文件中有的字段使用环境变量替换掉。那么这里在配置文件中就需要有一个“占位符”。这个占位符表示当前这个字段去环境变量中进行阅读。
这个占位符的设计只有一个要求:够特别。只要这个占位符能和其他配置文件字符区分开就行,所以这里设计占位符为比较有语义的“env(XXXX)”。比如app/config/development/database.yaml 文件中的数据库密码,使用占位符表示如下:
mysql:
hostname: 127.0.0.1
username: yejianfeng
password: env(DB_PASSWORD)
timeout: 1
readtime: 2.3
要实现这个功能,其实也很简单,可以在读取YAML配置文件内容之后,进行完整的文本匹配,将所有环境变量env(xxx) 的字符替换为环境变量。我们应该能设计出替换文本的函数。
在框架目录的provider/config/service.go中,可以先实现这个方法。
// replace 表示使用环境变量maps替换context中的env(xxx)的环境变量
func replace(content []byte, maps map[string]string) []byte {
if maps == nil {
return content
}
// 直接使用ReplaceAll替换。这个性能可能不是最优,但是配置文件加载,频率是比较低的,可以接受
for key, val := range maps {
reKey := "env(" + key + ")"
content = bytes.ReplaceAll(content, []byte(reKey), []byte(val))
}
return content
}
读取并解析完配置文件内容,接下来就要根据path来解析某个配置项了。上一节课说,我们使用点号分割的路径读取方式,比如database.mysql.password 表示在配置文件夹中的database.yaml文件,其中的mysql配置,对应的是数据结构中的password字段。
那这种根据path来读取字段应该怎么实现呢?
在获取配置项的时候,我们已经通过go-yaml库将配置文件解析到一个map数据结构中了,而这个map数据结构的子项,明显也有可能是一个map数据结构。所以按照path路径查找,这明显应该是一个函数递归逻辑。
还是用刚才的database.mysql.password举例,可以拆分为3个结构。database 去根map中寻找;如果有这个key,就拿着mysql.password的path,去 database这个key对应的value中进行寻找;而递归寻找到了最后一级path为password,发现这个path没有下一级了,就停止递归。
详细的代码方法如下,同样存放在框架目录的provider/config/service.go中。
// 查找某个路径的配置项
func searchMap(source map[string]interface{}, path []string) interface{} {
if len(path) == 0 {
return source
}
// 判断是否有下个路径
next, ok := source[path[0]]
if ok {
// 判断这个路径是否为1
if len(path) == 1 {
return next
}
// 判断下一个路径的类型
switch next.(type) {
case map[interface{}]interface{}:
// 如果是interface的map,使用cast进行下value转换
return searchMap(cast.ToStringMap(next), path[1:])
case map[string]interface{}:
// 如果是map[string],直接循环调用
return searchMap(next.(map[string]interface{}), path[1:])
default:
// 否则的话,返回nil
return nil
}
}
return nil
}
// 通过path获取某个元素
func (conf *HadeConfig) find(key string) interface{} {
...
return searchMap(conf.confMaps, strings.Split(key, conf.keyDelim))
}
想通了以上三个核心实现难点,我们就可以着手整体代码实现了。
首先,在框架文件夹的provider/config/service.go 中,创建一个配置文件服务HadeConfig。它有几个属性:folder代表配置本地配置文件所在的文件夹;keyDelim代表路径中的分割符号,也就是点;envMaps存放所有的环境变量;而confMaps存放每个配置解析后的结构,confRaws存放每个配置的原始文件信息。
// HadeConfig 表示hade框架的配置文件服务
type HadeConfig struct {
c framework.Container // 容器
folder string // 文件夹
keyDelim string // 路径的分隔符,默认为点
...
envMaps map[string]string // 所有的环境变量
confMaps map[string]interface{} // 配置文件结构,key为文件名
confRaws map[string][]byte // 配置文件的原始信息
}
我们初始化这个HadeConfig的函数,它从服务提供者provider/config/provider.go中获取到三个参数,除了容器之外,另外两个是文件夹地址和所有的环境变量。
我们这里对provider.go 只列一下参数函数,其他的四个服务提供者函数(Register、Boot、IsDefer、Name) 可以参考GitHub上的代码。
// Paramas 服务提供者实例化的时候参数
func (provider *HadeConfigProvider) Params(c framework.Container) []interface{} {
appService := c.MustMake(contract.AppKey).(contract.App)
envService := c.MustMake(contract.EnvKey).(contract.Env)
env := envService.AppEnv()
// 配置文件夹地址
configFolder := appService.ConfigFolder()
envFolder := filepath.Join(configFolder, env)
// 传递容器,配置文件夹地址,所有环境变量
return []interface{}{c, envFolder, envService.All()}
}
那么在provider/config/service.go中,实例化的函数逻辑如下:
// NewHadeConfig 初始化Config方法
func NewHadeConfig(params ...interface{}) (interface{}, error) {
container := params[0].(framework.Container)
envFolder := params[1].(string)
envMaps := params[2].(map[string]string)
// 检查文件夹是否存在
if _, err := os.Stat(envFolder); os.IsNotExist(err) {
return nil, errors.New("folder " + envFolder + " not exist: " + err.Error())
}
// 实例化
hadeConf := &HadeConfig{
c: container,
folder: envFolder,
envMaps: envMaps,
confMaps: map[string]interface{}{},
confRaws: map[string][]byte{},
keyDelim: ".",
lock: sync.RWMutex{},
}
// 读取每个文件
files, err := ioutil.ReadDir(envFolder)
if err != nil {
return nil, errors.WithStack(err)
}
for _, file := range files {
fileName := file.Name()
err := hadeConf.loadConfigFile(envFolder, fileName)
if err != nil {
log.Println(err)
continue
}
}
...
return hadeConf, nil
}
// 读取某个配置文件
func (conf *HadeConfig) loadConfigFile(folder string, file string) error {
conf.lock.Lock()
defer conf.lock.Unlock()
// 判断文件是否以yaml或者yml作为后缀
s := strings.Split(file, ".")
if len(s) == 2 && (s[1] == "yaml" || s[1] == "yml") {
name := s[0]
// 读取文件内容
bf, err := ioutil.ReadFile(filepath.Join(folder, file))
if err != nil {
return err
}
// 直接针对文本做环境变量的替换
bf = replace(bf, conf.envMaps)
// 解析对应的文件
c := map[string]interface{}{}
if err := yaml.Unmarshal(bf, &c); err != nil {
return err
}
conf.confMaps[name] = c
conf.confRaws[name] = bf
}
return nil
}
逻辑非常清晰。先检查配置文件夹是否存在,然后读取文件夹中的每个以yaml或者yml后缀的文件;读取之后,先用replace对环境变量进行一次替换;替换之后使用 go-yaml,对文件进行解析。
初始化实例就是一个完整的 解析文件的过程,解析结束之后,confMaps里存放的就是解析之后的结果。
配置文件的获取接口上节课已经写好了,定义了接口的系列方法,这里我们就详细实现Get/GetBool/GetInt,其他方法大同小异,就不贴出来了,你可以直接参考GitHub上的代码。
前面已经想好了,用方法find,通过path,从一个嵌套map confMaps中获取数据。所以Get方法就是调用一下find方法而已,同样也在service.go中:
// Get 获取某个配置项
func (conf *HadeConfig) Get(key string) interface{} {
return conf.find(key)
}
而对应的Get系列的方法我们使用cast库进行类型转换,比如:
// GetBool 获取bool类型配置
func (conf *HadeConfig) GetBool(key string) bool {
return cast.ToBool(conf.find(key))
}
// GetInt 获取int类型配置
func (conf *HadeConfig) GetInt(key string) int {
return cast.ToInt(conf.find(key))
}
到这里,配置服务的代码已经基本成型了。但是实际上还有两个细节我们需要认真思考。
首先,因为之前我们设置过App服务,将一个App服务的目录都安排好了,但是如果之后有需求要改变这些目录的配置呢?如果有的话,是否可以通过配置来进行修改呢?所以第一个问题就是,我们要思考配置文件更新App服务的操作。
其次,假设现在配置服务能从文件中获取配置了,但是如果文件修改了,我们是否需要重新启动应用呢?是否有能不启动应用的方法呢?
下面我们来一一解决这两个问题。
现在有了配置文件服务,但在没有配置文件服务之前,我们启动服务的appService,也是有可能要修改这个服务的配置的。回忆第十二课,appService中存放了启动这个业务实例默认设置的文件夹目录和地址。
//BaseFolder 定义项目基础地址
BaseFolder() string
// ConfigFolder 定义了配置文件的路径
ConfigFolder() string
// LogFolder 定义了日志所在路径
LogFolder() string
// ProviderFolder 定义业务自己的服务提供者地址
ProviderFolder() string
// MiddlewareFolder 定义业务自己定义的中间件
MiddlewareFolder() string
// CommandFolder 定义业务定义的命令
CommandFolder() string
// RuntimeFolder 定义业务的运行中间态信息
RuntimeFolder() string
// TestFolder 存放测试所需要的信息
TestFolder() string
现在有需求将这些文件夹目录,在配置文件中进行配置并修改。所以应该在加载到配置服务时,再更新下appService。加载逻辑如下:
可以把设定App的这些配置文件,存放在配置文件夹的app.yaml文件的path设置项下,其中每个配置项的key,对应appService中每个对应的服务。比如log_folder对应LogFolder目录:
path:
log_folder: "/home/jianfengye/hade/log/"
runtime_folder: "/home/jianfengye/hade/runtime/"
现在加载配置服务的时候,当读取到配置服务app.path下有内容,就需要更新appService的配置。首先需要修改appService,修改框架目录下的provider/app/service.go文件。
将HadeApp增加一个configMap字段:
// HadeApp 代表hade框架的App实现
type HadeApp struct {
...
configMap map[string]string // 配置加载
}
同时为HadeApp增加LoadAppConfig方法,用于读取配置文件中的信息:
// LoadAppConfig 加载配置map
func (app *HadeApp) LoadAppConfig(kv map[string]string) {
for key, val := range kv {
app.configMap[key] = val
}
}
再修改对应的LogFolder等一系列XXXFolder的方法,先读取configMap中的值,如果有的话,先用configMap中的值:
// LogFolder 表示日志存放地址
func (app HadeApp) LogFolder() string {
if val, ok := app.configMap["log_folder"]; ok {
return val
}
return filepath.Join(app.StorageFolder(), "log")
}
这样,对appService的修改就完成了。
在configService,读取配置文件loadConfigFile的时候,要注意,如果当前的配置文件是app.yaml, 我们需要调用appService的LoadAppConfig方法:
// 读取某个配置文件
func (conf *HadeConfig) loadConfigFile(folder string, file string) error {
...
// 判断文件是否以yaml或者yml作为后缀
s := strings.Split(file, ".")
if len(s) == 2 && (s[1] == "yaml" || s[1] == "yml") {
name := s[0]
...
// 读取app.path中的信息,更新app对应的folder
if name == "app" && conf.c.IsBind(contract.AppKey) {
if p, ok := c["path"]; ok {
appService := conf.c.MustMake(contract.AppKey).(contract.App)
appService.LoadAppConfig(cast.ToStringMapString(p))
}
}
}
return nil
}
这样在加载app.yaml的配置文件的时候,就同时更新了appService 里面的配置。
正常来说,在程序启动的时候会读取一次配置文件,但是在程序运行过程中,我们难免会遇到需要修改配置文件的操作。也就是之前思考的第二个问题。
这个时候,是否需要重新启动一次程序再加载一次配置文件呢?这当然是没有问题的,但是更为强大的是,我们可以自动监控配置文件目录下的所有文件,当配置文件有修改和更新的时候,能自动更新程序中的配置文件信息,也就是实现配置文件热更新。
这个热更新看起来很麻烦,其实在Golang中是非常简单的事情。我们使用 fsnotify 库能很方便对一个文件夹进行监控,当文件夹中有文件增/删/改的时候,会通过channel进行事件回调。
这个库的使用方式很简单。大致思路就是先使用NewWatcher创建一个监控器watcher,然后使用Add来监控某个文件夹,通过watcher设置的events来判断文件是否有变化,如果有变化,就进行对应的操作,比如更新内存中配置服务存储的map结构。
// NewHadeConfig 初始化Config方法
func NewHadeConfig(params ...interface{}) (interface{}, error) {
...
// 监控文件夹文件
watch, err := fsnotify.NewWatcher()
if err != nil {
return nil, err
}
err = watch.Add(envFolder)
if err != nil {
return nil, err
}
go func() {
defer func() {
if err := recover(); err != nil {
fmt.Println(err)
}
}()
for {
select {
case ev := <-watch.Events:
{
//判断事件发生的类型,如下5种
// Create 创建
// Write 写入
// Remove 删除
path, _ := filepath.Abs(ev.Name)
index := strings.LastIndex(path, string(os.PathSeparator))
folder := path[:index]
fileName := path[index+1:]
if ev.Op&fsnotify.Create == fsnotify.Create {
log.Println("创建文件 : ", ev.Name)
hadeConf.loadConfigFile(folder, fileName)
}
if ev.Op&fsnotify.Write == fsnotify.Write {
log.Println("写入文件 : ", ev.Name)
hadeConf.loadConfigFile(folder, fileName)
}
if ev.Op&fsnotify.Remove == fsnotify.Remove {
log.Println("删除文件 : ", ev.Name)
hadeConf.removeConfigFile(folder, fileName)
}
}
case err := <-watch.Errors:
{
log.Println("error : ", err)
return
}
}
}
}()
return hadeConf, nil
}
代码如上,我们使用NewWatcher创建一个监听器,监听配置文件目录,接着启动一个新的Goroutine作为监听协程。在监听协程中,监听配置文件的创建、更新、删除操作。创建和更新对应 LoadConfigFile 操作。
而删除,对应的是 removeConfigFile操作,这个操作的内容就是删除配置服务中的confMaps中对应的key。
// 删除文件的操作
func (conf *HadeConfig) removeConfigFile(folder string, file string) error {
conf.lock.Lock()
defer conf.lock.Unlock()
s := strings.Split(file, ".")
// 只有yaml或者yml后缀才执行
if len(s) == 2 && (s[1] == "yaml" || s[1] == "yml") {
name := s[0]
// 删除内存中对应的key
delete(conf.confRaws, name)
delete(conf.confMaps, name)
}
return nil
}
这里注意下,由于在运行时增加了对confMaps的写操作,所以需要对confMaps进行锁设置,以防止在写confMaps的时候,读操作进入读取了错误信息。
分析目前的这个场景,读明显多于写。所以我们的锁应该是一个读写锁,读写锁可以让多个读并发读,但是只要有一个写操作,读和写都需要等待。这个很符合当前这个场景。
所以在框架目录的provider/config/service.go中的HadeConfig,我们增加了一个读写锁lock。
// HadeConfig 表示hade框架的配置文件服务
type HadeConfig struct {
...
lock sync.RWMutex // 配置文件读写锁
...
}
而在loadConfigFile和removeConfigFile这两个对配置有修改的情况,使用写锁锁住HadeConfig。
// 读取某个配置文件
func (conf *HadeConfig) loadConfigFile(folder string, file string) error {
conf.lock.Lock()
defer conf.lock.Unlock()
...
}
在Get系列方法调用的find函数中,使用读锁来进行读操作。
// 通过path来获取某个配置项
func (conf *HadeConfig) find(key string) interface{} {
conf.lock.RLock()
defer conf.lock.RUnlock()
...
}
这样,配置服务就开发完成了。
我们先测试环境变量注入配置文件的功能。将业务目录下的config/development/database.yaml 中的mysql.password,使用环境变量进行替换。
mysql:
hostname: 127.0.0.1
username: yejianfeng
password: env(DB_PASSWORD)
timeout: 1
readtime: 2.3
然后修改业务目录下的module/demo/api.go,替换其中/demo/demo对应的路由方法。
func (api *DemoApi) Demo(c *gin.Context) {
// 获取password
configService := c.MustMake(contract.ConfigKey).(contract.Config)
password := configService.GetString("database.mysql.password")
// 打印出来
c.JSON(200, password)
}
最后使用命令行 ./hade app start
启动服务。打开浏览器,看到输出:
说明此时还没注入环境变量。下面使用命令行:
DB_PASSWORD=123 ./hade app start
启动服务。这个命令注入了DB_PASSWORD这个环境变量。
重启打开浏览器看到输出。
环境变量注入成功!
这个时候我们不停止进程,直接修改配置文件database.yaml中的mysql.password:
mysql:
hostname: 127.0.0.1
username: yejianfeng
password: 456789
timeout: 1
readtime: 2.3
打开浏览器,输出已经变化了。
说明热更新已经生效了,测试成功。
今天所有代码的目录结构截图,也贴在这里供你对比检查,代码放在GitHub上的 16分支 里。
配置服务在框架中是一个非常基础且重要的服务。
我们考虑了整个配置服务的实现,先读取配置文件,再替换环境变量,最后再根据路径获取配置项,这样三步走完成了基本的配置服务。在配置服务的基础上,我们又补充了配置服务加载时对App服务的更新,并且为配置服务增加了热更新的机制。
我个人认为,配置服务是一个App中最常用到的服务了,有非常方便的配置服务接口,能为业务代码节省不少的代码量。提供多种设置配置的方式,是真实从业务需求出发的。
比如在实际工作中,有的需求要求数据库密码不能进入git库,必须通过环境变量获取,我们就可以通过环境变量获取配置;而有的需求要求在一个服务器上调试测试和预发布环境,我们可以通过.env切换不同环境。所以,有个多层次的环境配置机制,对于一个框架来说是非常必要的。
现在有配置文件服务了,但是根据路径、获取某个配置却只能在代码中获取。这里我们希望有一个命令行工具 ./hade config get "database.mysql"
能获取到这个path路径对应的配置。你可以尝试实现么?
欢迎在留言区分享你的思考。感谢你的收听,如果觉得有收获,也欢迎把今天的内容分享给你身边的朋友,邀他一起学习。我们下节课见~