你好,我是轩脉刃。

上一节课,我们已经定义好了配置文件服务的接口,这节课就来实现这些接口。先来规划配置文件服务目录,按照上一节课分析的,多个配置文件按类别放在不同配置文件夹中,在框架文件夹中,我们将配置文件接口代码写在框架文件夹下的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服务的操作。

其次,假设现在配置服务能从文件中获取配置了,但是如果文件修改了,我们是否需要重新启动应用呢?是否有能不启动应用的方法呢?

下面我们来一一解决这两个问题。

配置文件更新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路径对应的配置。你可以尝试实现么?

欢迎在留言区分享你的思考。感谢你的收听,如果觉得有收获,也欢迎把今天的内容分享给你身边的朋友,邀他一起学习。我们下节课见~