Viper
是一个用于Go应用程序的完整且灵活的配置解决方案。它支持多种配置源,包括JSON、TOML、YAML、HCL以及Java属性配置文件,并且可以轻松地从远程配置中心(如Consul、Etcd等)读取配置
Viper
是可以实现配置热加载(配置文件更新后会自动重新加载,而不需要重启服务)功能的,通过viper.WatchConfig()
和viper.OnConfigChange
实现
WatchConfig:启动一个协程来监控配置文件变化
OnConfigChange:当配置文件发生变化时,触发回调函数
一、问题描述
使用Viper
的热加载功能,OnConfigChange
回调函数在文件保存后会触发多次,导致配置文件被多次加载
测试记录:
-
环境:Windows
编辑器:
- goland:2次
- vscode:2次
- 记事本:1次
- vim:3次
-
环境:Linux
编辑器:
- vim:2次
- goland:3次
-
代码示例:
func InitConfig(env string) error { cfgOnce.Do(func() { // 根据传参/环境变量设定配置文件路径 err := setConfigPath(env) if err != nil { cfgERR = err } // yaml viper.SetConfigType(constant.ConfigType) if err := viper.ReadInConfig(); err != nil { cfgERR = err } // 首次加载配置 if err := viper.Unmarshal(&cfg); err != nil { cfgERR = err } // 启动热更新监听器 if err := startHotReload(); err != nil { cfgERR = err } }) if cfgERR != nil { return cfgERR } return nil } func startHotReload() error { viper.WatchConfig() viper.OnConfigChange(func(e fsnotify.Event) { // 重载配置文件 reloadConfig() }) return nil } func reloadConfig() { if err := viper.Unmarshal(&cfg); err != nil { fmt.Printf("Error loading config: %v\n", err) } else { fmt.Println("Config reloaded successfully") } }
二、问题分析
通过Baidu、Google、还有各种AI:ChatGPT、编程助手Baidu Comate、豆包MarsCode等,给出的结果大概是几下几种:
-
文件系统的通知机制:
操作系统(如Linux的inotify或Windows的ReadDirectoryChangesW)可能在文件修改时发送多个通知。这可能是因为文件首先被打开以进行修改,然后内容被写入,最后文件被关闭。每个步骤都可能触发一个通知
-
编辑器的保存行为:
不同的编辑器在保存文件时可能会执行多个步骤,如备份旧文件、写入新内容、再检查文件权限等,每个步骤都可能触发文件系统通知。
电脑上装了类似加密软件之类的影响
viper本身内部机制的问题
个人觉得,根据测试数据分析,第1、2种情况比较靠谱
三、解决方案
下边两种方案,亲测都可以解决此问题,也可以两种方案结合起来一起使用
-
延迟合并处理多次回调
// ...省略其他代码 const configChangeDelay = 20 * time.Millisecond func startHotReload() { viper.WatchConfig() viper.OnConfigChange(func(e fsnotify.Event) { fmt.Println("config file changed:", e.Name) if configChangeTimer != nil { configChangeTimer.Stop() } // 延迟20ms,合并多次回调 configChangeTimer = time.AfterFunc(configChangeDelay, func() { // 这里处理配置文件变化后的操作 reloadConfig() }) }) }
-
使用MD5校验配置文件是否发生变化,只处理有变化的配置文件
这种方式也可以解决另一个问题,就是没有进行修改文件内容,只保存一下,也会进行文件重载
// ...省略其他代码 func startHotReload(filePath string) (err error) { initialConfMD5, err := ReadFileMd5(filePath) if err != nil { return } viper.WatchConfig() viper.OnConfigChange(func(e fsnotify.Event) { // 过滤不是写入的操作或者keys为空的操作 if e.Op != 2 || len(viper.AllKeys()) == 0 { return } currentConfMD5, err := ReadFileMd5(filePath) fmt.Println("initialConfMD5: ", initialConfMD5) fmt.Println("currentConfMD5: ", currentConfMD5) if err != nil { return } // 过滤 MD5值相同,则不执行后续逻辑 if currentConfMD5 == initialConfMD5 { return } // 重载配置文件 reloadConfig() // 更新MD5值以便下次比较 initialConfMD5 = currentConfMD5 }) return nil } func ReadFileMd5(filepath string) (string, error) { file, err := os.Open(filepath) if err != nil { return "", err } defer file.Close() hasher := md5.New() if _, err := io.Copy(hasher, file); err != nil { return "", err } return hex.EncodeToString(hasher.Sum(nil)), nil }