You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

24 KiB

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版本,配置的说明文档在官网上。它提供多种语言的解析库,其中go-yaml 就是非常通用的一个Go解析库这个库的封装性非常好。

我们通过第一节课讲的快速阅读一个库的命令 go doc github.com/go-yaml/yaml |grep '^func',可以看出来这个库对外提供的方法非常明确,一共三个方法:

  • Marshal 表示序列化一个结构成为YAML格式
  • Unmarshal表示反序列化一个YAML格式文本成为一个结构
  • 还有一个UnmarshalStrict 函数表示严格反序列化比如如果YAML格式文件中包含重复key的字段那么使用UnmarshalStrict 函数反序列化会出现错误。
// 序列化
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路径对应的配置。你可以尝试实现么

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