# 16|配置和环境(下):配置服务中的设计思路 你好,我是轩脉刃。 上一节课,我们已经定义好了配置文件服务的接口,这节课就来实现这些接口。先来规划配置文件服务目录,按照上一节课分析的,多个配置文件按类别放在不同配置文件夹中,在框架文件夹中,我们将配置文件接口代码写在框架文件夹下的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版本,配置的说明文档在[官网](https://yaml.org/)上。它提供多种语言的解析库,其中[go-yaml](https://github.com/go-yaml/yaml) 就是非常通用的一个Go解析库,这个库的封装性非常好。 我们通过第一节课讲的快速阅读一个库的命令 `go doc github.com/go-yaml/yaml |grep '^func'`,可以看出来这个库对外提供的方法非常明确,一共三个方法: * Marshal 表示序列化一个结构成为YAML格式; * Unmarshal表示反序列化一个YAML格式文本成为一个结构; * 还有一个UnmarshalStrict 函数,表示严格反序列化,比如如果YAML格式文件中包含重复key的字段,那么使用UnmarshalStrict 函数反序列化会出现错误。 ```plain // 序列化 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 文件中的数据库密码,使用占位符表示如下: ```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中,可以先实现这个方法。 ```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中。 ```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存放每个配置的原始文件信息。 ```go // 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上的代码](https://github.com/gohade/coredemo/blob/geekbang/16/framework/provider/config/provider.go)。 ```go // 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中,实例化的函数逻辑如下: ```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上的代码](https://github.com/gohade/coredemo/blob/geekbang/16/framework/provider/config/service.go)。 前面已经想好了,用方法find,通过path,从一个嵌套map confMaps中获取数据。所以Get方法就是调用一下find方法而已,同样也在service.go中: ```go // Get 获取某个配置项 func (conf *HadeConfig) Get(key string) interface{} { return conf.find(key) } ``` 而对应的Get系列的方法我们使用cast库进行类型转换,比如: ```go // 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服务的操作。 其次,假设现在配置服务能从文件中获取配置了,但是如果文件修改了,我们是否需要重新启动应用呢?是否有能不启动应用的方法呢? 下面我们来一一解决这两个问题。 ## 配置文件更新App服务 现在有了配置文件服务,但在没有配置文件服务之前,我们启动服务的appService,也是有可能要修改这个服务的配置的。回忆[第十二](https://time.geekbang.org/column/article/423982)[课](https://time.geekbang.org/column/article/423982),appService中存放了启动这个业务实例默认设置的文件夹目录和地址。 ```go //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。加载逻辑如下: ![图片](https://static001.geekbang.org/resource/image/f5/ee/f5141333501ce140314fb985b75c6eee.jpg?wh=1920x1080) 可以把设定App的这些配置文件,存放在配置文件夹的app.yaml文件的path设置项下,其中每个配置项的key,对应appService中每个对应的服务。比如log\_folder对应LogFolder目录: ```go path: log_folder: "/home/jianfengye/hade/log/" runtime_folder: "/home/jianfengye/hade/runtime/" ``` 现在加载配置服务的时候,当读取到配置服务app.path下有内容,就需要更新appService的配置。首先需要修改appService,修改框架目录下的provider/app/service.go文件。 将HadeApp增加一个configMap字段: ```go // HadeApp 代表hade框架的App实现 type HadeApp struct { ... configMap map[string]string // 配置加载 } ``` 同时为HadeApp增加LoadAppConfig方法,用于读取配置文件中的信息: ```go // LoadAppConfig 加载配置map func (app *HadeApp) LoadAppConfig(kv map[string]string) { for key, val := range kv { app.configMap[key] = val } } ``` 再修改对应的LogFolder等一系列XXXFolder的方法,先读取configMap中的值,如果有的话,先用configMap中的值: ```go // 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方法: ```go // 读取某个配置文件 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](https://github.com/fsnotify/fsnotify) 库能很方便对一个文件夹进行监控,当文件夹中有文件增/删/改的时候,会通过channel进行事件回调。 这个库的使用方式很简单。大致思路就是先使用NewWatcher创建一个监控器watcher,然后使用Add来监控某个文件夹,通过watcher设置的events来判断文件是否有变化,如果有变化,就进行对应的操作,比如更新内存中配置服务存储的map结构。 ```go // 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。 ```go // 删除文件的操作 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。 ```go // HadeConfig 表示hade框架的配置文件服务 type HadeConfig struct { ... lock sync.RWMutex // 配置文件读写锁 ... } ``` 而在loadConfigFile和removeConfigFile这两个对配置有修改的情况,使用写锁锁住HadeConfig。 ```go // 读取某个配置文件 func (conf *HadeConfig) loadConfigFile(folder string, file string) error { conf.lock.Lock() defer conf.lock.Unlock() ... } ``` 在Get系列方法调用的find函数中,使用读锁来进行读操作。 ```go // 通过path来获取某个配置项 func (conf *HadeConfig) find(key string) interface{} { conf.lock.RLock() defer conf.lock.RUnlock() ... } ``` 这样,配置服务就开发完成了。 ## 验证 我们先测试环境变量注入配置文件的功能。将业务目录下的config/development/database.yaml 中的mysql.password,使用环境变量进行替换。 ```yaml mysql: hostname: 127.0.0.1 username: yejianfeng password: env(DB_PASSWORD) timeout: 1 readtime: 2.3 ``` 然后修改业务目录下的module/demo/api.go,替换其中/demo/demo对应的路由方法。 ```go 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` 启动服务。打开浏览器,看到输出: ![图片](https://static001.geekbang.org/resource/image/27/f1/275ddfb18f04549dd62e6b45fc3cccf1.png?wh=478x86) 说明此时还没注入环境变量。下面使用命令行: ```plain DB_PASSWORD=123 ./hade app start ``` 启动服务。这个命令注入了DB\_PASSWORD这个环境变量。 重启打开浏览器看到输出。 ![图片](https://static001.geekbang.org/resource/image/33/b6/33f14f5277ea8294458141d5393269b6.png?wh=405x90) 环境变量注入成功! 这个时候我们不停止进程,直接修改配置文件database.yaml中的mysql.password: ```yaml mysql: hostname: 127.0.0.1 username: yejianfeng password: 456789 timeout: 1 readtime: 2.3 ``` 打开浏览器,输出已经变化了。 ![图片](https://static001.geekbang.org/resource/image/6c/b7/6c86bf1f4f055d94fb17c756415a94b7.png?wh=377x82) 说明热更新已经生效了,测试成功。 今天所有代码的目录结构截图,也贴在这里供你对比检查,代码放在GitHub上的 [16分支](https://github.com/gohade/coredemo/tree/geekbang/16) 里。 ![图片](https://static001.geekbang.org/resource/image/6c/38/6c9b9046f41e4b5c0719fe205540c438.png?wh=351x1140) ## 小结 配置服务在框架中是一个非常基础且重要的服务。 我们考虑了整个配置服务的实现,先读取配置文件,再替换环境变量,最后再根据路径获取配置项,这样三步走完成了基本的配置服务。在配置服务的基础上,我们又补充了配置服务加载时对App服务的更新,并且为配置服务增加了热更新的机制。 我个人认为,配置服务是一个App中最常用到的服务了,有非常方便的配置服务接口,能为业务代码节省不少的代码量。**提供多种设置配置的方式,是真实从业务需求出发的**。 比如在实际工作中,有的需求要求数据库密码不能进入git库,必须通过环境变量获取,我们就可以通过环境变量获取配置;而有的需求要求在一个服务器上调试测试和预发布环境,我们可以通过.env切换不同环境。所以,有个多层次的环境配置机制,对于一个框架来说是非常必要的。 ## 思考题 现在有配置文件服务了,但是根据路径、获取某个配置却只能在代码中获取。这里我们希望有一个命令行工具 `./hade config get "database.mysql"` 能获取到这个path路径对应的配置。你可以尝试实现么? 欢迎在留言区分享你的思考。感谢你的收听,如果觉得有收获,也欢迎把今天的内容分享给你身边的朋友,邀他一起学习。我们下节课见~